config, mcp, gui: specs from sources, spec-blind suites, and the bugs they found
check / check (push) Canceled after 0s
check / check (push) Canceled after 0s
ledger-config: parse gets a sourced spec. A fiscal year starts on the first of a month (IRS Pub. 538); tax_classification is read by whole words (Treas. Reg. 301.7701-3, Schedule C instructions; "business corp" no longer reads as an S corp, "corporation or S corporation" is refused); duplicate or blank members and shared capital accounts are refused; a negative or non-grammatical receipt threshold is refused; codes trimmed. tests/spec_config.rs (65 spec-blind tests, 14 properties), fuzz target fuzz_config_parse (weekly). Mutation: 55 of 55 viable caught (from 25/38). The receipt policy moves to ledger-config, with one loader (effective_receipt_policy) and one decimal grammar, so the server and the viewer enforce and show the same rule (the viewer had its own: only expense-like debits, a silent $25 default, and no config.json). ledger-mcp: a module spec (MCP 2025-06-18 tools, JSON-RPC 2.0, the operating contract). Amounts in are exactly -?digits[.digits]; a number that is one account's code and another's id (vendors, classes alike) is refused, not guessed; the threshold is validated at startup. Fixed: add_line with a bad class journaled the line before refusing; profit_and_loss_by_class ignored `class`; aging buckets said "0". A wire in front of rmcp answers malformed JSON with -32700 and invalid requests with -32600 and their id (rmcp dropped the first, omitted the id). tests/spec_mcp.rs (26 spec-blind tests: every tool's result against its own outputSchema, a random-call state machine checking the journal is untouched by every refusal, amount grammar, previews, the receipt policy). ledger-gui: money, accounting, and dollars never round (they rounded to cents, banker's style); export file names avoid Windows device names, trailing dots and spaces, empty names, and 255-byte overflow; specs on attention, history, fuzzy search, and axis ticks. ledger-core: the lifetime profit_and_loss is renamed unclosed_earnings (docs/testing.md #3). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX
This commit is contained in:
1 parent
5b8910a1cd
commit
3f20df9700
42 files changed
+4636
-363
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]
|
||||
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]
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: jdx/mise-action@v2
|
||||
|
||||
Generated
+240
-3
@@ -571,7 +571,7 @@ checksum = "591eba69b7c085632e22ca03606f0bbea68d53bbe26c8962e2d549af20c6a561"
|
||||
dependencies = [
|
||||
"paste",
|
||||
"roman-numerals-rs",
|
||||
"strum",
|
||||
"strum 0.27.2",
|
||||
"unic-langid",
|
||||
"unicode-normalization",
|
||||
"unscanny",
|
||||
@@ -700,6 +700,12 @@ dependencies = [
|
||||
"piper",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "borrow-or-share"
|
||||
version = "0.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||
|
||||
[[package]]
|
||||
name = "borsh"
|
||||
version = "1.8.1"
|
||||
@@ -757,6 +763,12 @@ version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||
|
||||
[[package]]
|
||||
name = "bytecount"
|
||||
version = "0.6.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||
|
||||
[[package]]
|
||||
name = "bytemuck"
|
||||
version = "1.25.2"
|
||||
@@ -1373,6 +1385,12 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "data-encoding"
|
||||
version = "2.11.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
|
||||
|
||||
[[package]]
|
||||
name = "data-url"
|
||||
version = "0.3.2"
|
||||
@@ -1742,6 +1760,15 @@ version = "1.19.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
||||
|
||||
[[package]]
|
||||
name = "email_address"
|
||||
version = "0.2.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449"
|
||||
dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "emath"
|
||||
version = "0.36.2"
|
||||
@@ -1937,6 +1964,17 @@ dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fancy-regex"
|
||||
version = "0.19.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d301f5bf187b3c295fce6468d3875037a0bccc5f6b151c63cac2f85babf21912"
|
||||
dependencies = [
|
||||
"bit-set 0.8.0",
|
||||
"regex-automata",
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastrand"
|
||||
version = "2.5.0"
|
||||
@@ -2002,6 +2040,17 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fluent-uri"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bc74ac4d8359ae70623506d512209619e5cf8f347124910440dbc221714b328e"
|
||||
dependencies = [
|
||||
"borrow-or-share",
|
||||
"ref-cast",
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fnv"
|
||||
version = "1.0.7"
|
||||
@@ -2092,6 +2141,16 @@ dependencies = [
|
||||
"percent-encoding",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fraction"
|
||||
version = "0.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e246562084dde8ebbcc943b261c406ce4f68e5032ec28029a251a47d6a295500"
|
||||
dependencies = [
|
||||
"num",
|
||||
"num-bigint",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fsevent-sys"
|
||||
version = "4.1.0"
|
||||
@@ -2249,9 +2308,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi 5.3.0",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2496,6 +2557,8 @@ version = "0.17.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||
dependencies = [
|
||||
"allocator-api2",
|
||||
"equivalent",
|
||||
"foldhash",
|
||||
]
|
||||
|
||||
@@ -3340,6 +3403,59 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f622c574a29fb3034a0a90645afee194dfedc75fc124228801b6e77041075bd1"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"data-encoding",
|
||||
"email_address",
|
||||
"fancy-regex 0.19.2",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"itoa",
|
||||
"jsonschema-regex",
|
||||
"jsonschema-value",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"percent-encoding",
|
||||
"referencing",
|
||||
"regex",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"strum 0.28.0",
|
||||
"unicode-general-category",
|
||||
"uuid-simd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-regex"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a968fffd387b2ab005bf00c1851f5d26e90df05b6f7ad609ed0d620033cca5e7"
|
||||
dependencies = [
|
||||
"regex-syntax",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-value"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39b7670345e5ce87371339d13705dbb9276b7e3e934bbe1efa2c6734a1f65a96"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"bytecount",
|
||||
"fraction",
|
||||
"getrandom 0.3.4",
|
||||
"num-cmp",
|
||||
"num-traits",
|
||||
"serde_json",
|
||||
"zmij",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "kamadak-exif"
|
||||
version = "0.6.1"
|
||||
@@ -3476,8 +3592,11 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ledger-core",
|
||||
"proptest",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
@@ -3543,11 +3662,13 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"jsonschema",
|
||||
"ledger-config",
|
||||
"ledger-core",
|
||||
"ledger-import",
|
||||
"ledger-store",
|
||||
"mime_guess",
|
||||
"proptest",
|
||||
"rmcp",
|
||||
"rust_decimal",
|
||||
"schemars",
|
||||
@@ -3732,6 +3853,12 @@ dependencies = [
|
||||
"autocfg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "micromap"
|
||||
version = "0.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
|
||||
|
||||
[[package]]
|
||||
name = "mime"
|
||||
version = "0.3.17"
|
||||
@@ -3921,6 +4048,20 @@ dependencies = [
|
||||
"bitflags 2.13.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num"
|
||||
version = "0.4.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-complex",
|
||||
"num-integer",
|
||||
"num-iter",
|
||||
"num-rational",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-bigint"
|
||||
version = "0.4.8"
|
||||
@@ -3931,6 +4072,21 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-cmp"
|
||||
version = "0.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63335b2e2c34fae2fb0aa2cecfd9f0832a1e24b3b32ecec612c3426d46dc8aaa"
|
||||
|
||||
[[package]]
|
||||
name = "num-complex"
|
||||
version = "0.4.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495"
|
||||
dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-conv"
|
||||
version = "0.2.2"
|
||||
@@ -3946,6 +4102,27 @@ dependencies = [
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-iter"
|
||||
version = "0.1.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b"
|
||||
dependencies = [
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-rational"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
|
||||
dependencies = [
|
||||
"num-bigint",
|
||||
"num-integer",
|
||||
"num-traits",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "num-traits"
|
||||
version = "0.2.19"
|
||||
@@ -4354,6 +4531,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "outref"
|
||||
version = "0.5.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||
|
||||
[[package]]
|
||||
name = "page_size"
|
||||
version = "0.6.0"
|
||||
@@ -5122,6 +5305,23 @@ dependencies = [
|
||||
"syn 3.0.7",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "referencing"
|
||||
version = "0.58.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "558f3855529c0a7e78a48e409fa653456e55f1b167eab4109c16a255b7727e3f"
|
||||
dependencies = [
|
||||
"ahash",
|
||||
"fluent-uri",
|
||||
"getrandom 0.3.4",
|
||||
"hashbrown 0.17.1",
|
||||
"itoa",
|
||||
"micromap",
|
||||
"parking_lot",
|
||||
"percent-encoding",
|
||||
"serde_json",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "regex"
|
||||
version = "1.13.1"
|
||||
@@ -5866,7 +6066,16 @@ version = "0.27.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||
dependencies = [
|
||||
"strum_macros",
|
||||
"strum_macros 0.27.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9628de9b8791db39ceda2b119bbe13134770b56c138ec1d3af810d045c04f9bd"
|
||||
dependencies = [
|
||||
"strum_macros 0.28.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5881,6 +6090,18 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "strum_macros"
|
||||
version = "0.28.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ab85eea0270ee17587ed4156089e10b9e6880ee688791d45a905f5b1ca36f664"
|
||||
dependencies = [
|
||||
"heck",
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "subsetter"
|
||||
version = "0.2.6"
|
||||
@@ -5949,7 +6170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
||||
dependencies = [
|
||||
"bincode",
|
||||
"fancy-regex",
|
||||
"fancy-regex 0.16.2",
|
||||
"flate2",
|
||||
"fnv",
|
||||
"once_cell",
|
||||
@@ -6929,6 +7150,16 @@ dependencies = [
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uuid-simd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "23b082222b4f6619906941c17eb2297fff4c2fb96cb60164170522942a200bd8"
|
||||
dependencies = [
|
||||
"outref",
|
||||
"vsimd",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
@@ -7044,6 +7275,12 @@ version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||
|
||||
[[package]]
|
||||
name = "vsimd"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||
|
||||
[[package]]
|
||||
name = "wait-timeout"
|
||||
version = "0.2.1"
|
||||
|
||||
@@ -11,7 +11,12 @@ ledger-core = { path = "../ledger-core" }
|
||||
chrono = "0.4"
|
||||
rust_decimal = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
toml = { version = "1", features = ["preserve_order"] }
|
||||
|
||||
[dev-dependencies]
|
||||
proptest = "1"
|
||||
tempfile = "3"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
+189
-28
@@ -9,6 +9,9 @@
|
||||
//! 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 receipt_policy;
|
||||
pub use receipt_policy::ReceiptPolicy;
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
@@ -148,21 +151,44 @@ struct BooksSection {
|
||||
investing_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
/// The return a federal tax classification files: "partnership" → 1065,
|
||||
/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship"
|
||||
/// or "disregarded entity" → Schedule C. Unrecognized: unknown.
|
||||
/// 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> {
|
||||
let c = c.to_lowercase();
|
||||
if c.contains("partnership") {
|
||||
Some(ledger_core::TaxForm::Form1065)
|
||||
} else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") {
|
||||
Some(ledger_core::TaxForm::Form1120S)
|
||||
} else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") {
|
||||
Some(ledger_core::TaxForm::Form1120)
|
||||
} else if c.contains("sole") || c.contains("disregarded") {
|
||||
Some(ledger_core::TaxForm::ScheduleC)
|
||||
} else {
|
||||
None
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -180,19 +206,121 @@ fn parse_share(name: &str, s: &str) -> Result<Decimal, String> {
|
||||
Ok(share)
|
||||
}
|
||||
|
||||
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
|
||||
/// day every year has.
|
||||
/// Parse `"MM-DD"`: the first day of a month (a fiscal year ends on the
|
||||
/// last day of a month: IRS Pub. 538, docs/spec/sources/
|
||||
/// irs-periods-and-methods.md).
|
||||
fn parse_month_day(s: &str) -> Result<(u32, u32), String> {
|
||||
let bad = || format!("fiscal_year_start {s:?}: expected \"MM-DD\", e.g. \"01-01\"");
|
||||
let (m, d) = s.split_once('-').ok_or_else(bad)?;
|
||||
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| bad())?);
|
||||
// 2001 is not a leap year: a valid date there exists every year.
|
||||
NaiveDate::from_ymd_opt(2001, m, d)
|
||||
.ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?;
|
||||
if !(1..=12).contains(&m) {
|
||||
return Err(bad());
|
||||
}
|
||||
if d != 1 {
|
||||
return Err(format!(
|
||||
"fiscal_year_start {s:?}: a fiscal year starts on the first of a month (it ends on the last day of one: IRS Pub. 538)"
|
||||
));
|
||||
}
|
||||
Ok((m, d))
|
||||
}
|
||||
|
||||
/// Account codes as written, trimmed; a blank one is refused.
|
||||
fn codes(key: &str, list: Vec<String>) -> Result<Vec<String>, String> {
|
||||
list.into_iter()
|
||||
.map(|c| {
|
||||
let c = c.trim().to_string();
|
||||
if c.is_empty() {
|
||||
Err(format!("{key}: an account code is blank"))
|
||||
} else {
|
||||
Ok(c)
|
||||
}
|
||||
})
|
||||
.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
|
||||
/// the viewer both call this, so they enforce and show one rule.
|
||||
pub fn effective_receipt_policy(
|
||||
config_json: &Path,
|
||||
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 policy = match (from_json, &settings.receipt_threshold) {
|
||||
(Some(p), _) => p,
|
||||
(None, Some(t)) => ReceiptPolicy {
|
||||
require_receipts_above: t.clone(),
|
||||
..ReceiptPolicy::default()
|
||||
},
|
||||
(None, None) => ReceiptPolicy::default(),
|
||||
};
|
||||
policy.validate()?;
|
||||
Ok(policy)
|
||||
}
|
||||
|
||||
/// Parse `wanderbooks.toml` text.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: read the company's settings, refusing any the books can't honor
|
||||
/// rather than guessing.
|
||||
/// SOURCE: IRS Pub. 538 (docs/spec/sources/irs-periods-and-methods.md:
|
||||
/// a fiscal year ends on the last day of a month); Treas. Reg.
|
||||
/// §301.7701-3 and the Schedule C instructions
|
||||
/// (docs/spec/sources/irs-entity-classification.md); IRC §704(a)
|
||||
/// (shares from the agreement: docs/spec/sources/irs-partnerships.md).
|
||||
/// INVARIANTS:
|
||||
/// 1. Every setting is optional; an empty file is a calendar-year,
|
||||
/// accrual-basis "Unnamed company" with no members.
|
||||
/// 2. fiscal_year_start MUST be the first of a month ("MM-01").
|
||||
/// 3. Members: each MUST have a name and a capital account, names and
|
||||
/// capital accounts MUST be distinct (and no member's distributions
|
||||
/// account another's capital), and shares (each in (0, 1], "50%" or
|
||||
/// "0.5") MUST sum to exactly 1.
|
||||
/// 4. tax_form wins over tax_classification (which is then not read);
|
||||
/// a classification is read by whole words, ignoring case, and MUST
|
||||
/// name exactly one return (source table). tax_form, basis, and
|
||||
/// classification values ignore case.
|
||||
/// 5. receipt_threshold MUST be a decimal at least zero.
|
||||
/// 6. Account codes (cash, financing, investing, close_into,
|
||||
/// close_sweep, members' capital and distributions) are trimmed; a
|
||||
/// blank one is refused (a blank distributions account means none). close_into is
|
||||
/// refused with members (a close goes to their capital accounts).
|
||||
/// 7. profile holds every simple [company] value in file order:
|
||||
/// strings as written, numbers, booleans, and dates in TOML's
|
||||
/// spelling; tables and arrays (members) are left out.
|
||||
/// 8. NEVER accept a file that would be read differently than written:
|
||||
/// an unknown basis, tax form, or classification is refused.
|
||||
/// ```
|
||||
pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||
let mut company = Company::default();
|
||||
@@ -218,6 +346,30 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
.filter(|d| !d.is_empty()),
|
||||
});
|
||||
}
|
||||
for (i, m) in company.members.iter().enumerate() {
|
||||
if m.name.is_empty() {
|
||||
return Err(format!("member {}: name is blank", i + 1));
|
||||
}
|
||||
for other in &company.members[..i] {
|
||||
if other.name == m.name {
|
||||
return Err(format!("member {} is listed twice", m.name));
|
||||
}
|
||||
if other.capital_account == m.capital_account {
|
||||
return Err(format!(
|
||||
"members {} and {} share capital account {}",
|
||||
other.name, m.name, m.capital_account
|
||||
));
|
||||
}
|
||||
}
|
||||
for other in &company.members {
|
||||
if other.distribution_account.as_deref() == Some(m.capital_account.as_str()) {
|
||||
return Err(format!(
|
||||
"{}'s distributions account {} is {}'s capital account",
|
||||
other.name, m.capital_account, m.name
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
if !company.members.is_empty() {
|
||||
let total: Decimal = company.members.iter().map(|m| m.share).sum();
|
||||
if total != Decimal::ONE {
|
||||
@@ -229,7 +381,11 @@ 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)) => form_for_classification(c),
|
||||
(None, Some(c)) => Some(form_for_classification(c).ok_or_else(|| {
|
||||
format!(
|
||||
"tax_classification {c:?}: expected partnership, S corporation, C corporation, or disregarded entity (or set tax_form)"
|
||||
)
|
||||
})?),
|
||||
(None, None) => None,
|
||||
};
|
||||
if !company.members.is_empty() && raw.books.close_into.is_some() {
|
||||
@@ -238,8 +394,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
company.close_into = raw.books.close_into.map(|c| c.trim().to_string());
|
||||
company.close_sweep = raw.books.close_sweep;
|
||||
company.close_into = match raw.books.close_into {
|
||||
Some(c) => Some(codes("close_into", vec![c])?.remove(0)),
|
||||
None => None,
|
||||
};
|
||||
company.close_sweep = codes("close_sweep", raw.books.close_sweep)?;
|
||||
if let Some(basis) = raw.books.accounting_basis {
|
||||
company.basis = match basis.trim().to_lowercase().as_str() {
|
||||
"accrual" => "Accrual".to_string(),
|
||||
@@ -253,9 +412,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
}
|
||||
let receipt_threshold = match raw.books.receipt_threshold {
|
||||
Some(t) => {
|
||||
t.trim()
|
||||
.parse::<Decimal>()
|
||||
.map_err(|_| format!("receipt_threshold {t:?} is not a decimal"))?;
|
||||
let v = decimal(t.trim())
|
||||
.ok_or_else(|| format!("receipt_threshold {t:?} is not a decimal"))?;
|
||||
if v.is_sign_negative() && !v.is_zero() {
|
||||
return Err(format!("receipt_threshold {t:?} is below zero"));
|
||||
}
|
||||
Some(t.trim().to_string())
|
||||
}
|
||||
None => None,
|
||||
@@ -280,9 +441,9 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||
Ok(BooksSettings {
|
||||
company,
|
||||
receipt_threshold,
|
||||
cash_accounts: raw.books.cash_accounts,
|
||||
financing_accounts: raw.books.financing_accounts,
|
||||
investing_accounts: raw.books.investing_accounts,
|
||||
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)?,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,224 @@
|
||||
//! The receipt policy: server-side enforcement, not prose.
|
||||
//!
|
||||
//! Posting an entry whose debits are strictly greater than the configured
|
||||
//! threshold requires at least one attached receipt, unless every line is in
|
||||
//! an exempt account. Selected accounts can require receipts regardless of
|
||||
//! amount. Reversals are exempt: the original entry carries the evidence.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: no entry posts without the document that supports it.
|
||||
//! SOURCE: docs/spec/sources/irs-records.md (Pub. 583: "you must keep
|
||||
//! supporting documents"; they "support the entries in your
|
||||
//! books"); DECISION (Stephen: the threshold, strictly greater
|
||||
//! than; Wanderland's is 0.00, every entry, 2026-10-12).
|
||||
//! INVARIANTS:
|
||||
//! 1. An entry touching a required account always needs a receipt.
|
||||
//! 2. Otherwise it needs one exactly when its debits are strictly
|
||||
//! greater than the threshold and some line is outside the exempt
|
||||
//! accounts; at threshold 0.00 every entry with debits needs one.
|
||||
//! 3. Splitting does not dodge it: posted entries the same day with the
|
||||
//! same description (whitespace and case aside) and the same debit
|
||||
//! accounts count together (split_group), reversals aside.
|
||||
//! 4. A threshold that isn't a decimal, or is below zero, is refused at
|
||||
//! startup (validate).
|
||||
//! ```
|
||||
|
||||
use ledger_core::{JournalEntry, Ledger, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Server-side receipt policy, loaded from config.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReceiptPolicy {
|
||||
/// Receipts required when an entry's debits are strictly greater than
|
||||
/// this amount. Decimal string, e.g. `"25.00"`.
|
||||
#[serde(default = "default_threshold")]
|
||||
pub require_receipts_above: String,
|
||||
/// Account codes that always require a receipt, regardless of amount.
|
||||
#[serde(default)]
|
||||
pub receipt_required_accounts: Vec<String>,
|
||||
/// Account codes exempt from the policy. The exemption applies only when
|
||||
/// *every* line of the entry is in an exempt account.
|
||||
#[serde(default)]
|
||||
pub receipt_exempt_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_threshold() -> String {
|
||||
"25.00".to_string()
|
||||
}
|
||||
|
||||
impl Default for ReceiptPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
require_receipts_above: default_threshold(),
|
||||
receipt_required_accounts: Vec::new(),
|
||||
receipt_exempt_accounts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReceiptPolicy {
|
||||
/// Refuse a policy the server can't honor: a threshold that isn't a
|
||||
/// decimal, or is below zero. Checked at startup, so a typo can't
|
||||
/// quietly become some other threshold.
|
||||
pub fn validate(&self) -> Result<(), String> {
|
||||
match crate::decimal(self.require_receipts_above.trim()) {
|
||||
Some(t) if !t.is_sign_negative() || t.is_zero() => Ok(()),
|
||||
_ => Err(format!(
|
||||
"receipt threshold {:?} must be a decimal at least zero",
|
||||
self.require_receipts_above
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The threshold: receipts are required strictly above it.
|
||||
pub fn threshold(&self) -> Decimal {
|
||||
// validate() runs at startup; an unvalidated policy (tests) that
|
||||
// fails to parse requires a receipt on everything.
|
||||
crate::decimal(self.require_receipts_above.trim()).unwrap_or(Decimal::ZERO)
|
||||
}
|
||||
|
||||
fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec<String> {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(l.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total debits of the entry (one side of the balanced entry).
|
||||
pub fn entry_debits(entry: &JournalEntry) -> Decimal {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// True when posting this entry requires at least one receipt.
|
||||
pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool {
|
||||
let codes = self.account_codes(ledger, entry);
|
||||
let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect();
|
||||
self.receipt_required_for(&refs, Self::entry_debits(entry))
|
||||
}
|
||||
|
||||
/// True when an entry touching these account codes with this debit total
|
||||
/// requires at least one receipt. The code-level check behind
|
||||
/// [`Self::receipt_required`], for postings (like disposals) computed
|
||||
/// without a draft entry in hand.
|
||||
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
|
||||
// Per-account override: any line in a required account → required.
|
||||
if codes
|
||||
.iter()
|
||||
.any(|c| self.receipt_required_accounts.iter().any(|r| r == *c))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Strictly greater than the threshold (Stephen: ">$25").
|
||||
if debits <= self.threshold() {
|
||||
return false;
|
||||
}
|
||||
// Exempt only when every line is in an exempt account.
|
||||
!codes
|
||||
.iter()
|
||||
.all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c))
|
||||
}
|
||||
|
||||
/// Human-readable policy summary for previews and errors.
|
||||
pub fn describe(&self) -> String {
|
||||
if self.threshold().is_zero() {
|
||||
"receipts required on every entry".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"receipts required when entry debits are strictly greater than {}",
|
||||
self.threshold()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt rule, phrased for the agent's operating instructions.
|
||||
pub fn rule(&self) -> String {
|
||||
if self.threshold().is_zero() {
|
||||
"every entry needs at least one receipt".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"any entry moving more than ${} in debits needs at least one receipt",
|
||||
self.threshold().round_dp(2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a description for split detection.
|
||||
fn normalize_description(desc: &str) -> String {
|
||||
desc.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// Debit account ids of an entry, sorted.
|
||||
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
|
||||
let mut ids: Vec<u64> = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.account)
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Detect a potential threshold split: posted entries on the same date
|
||||
/// with the same normalized description and debit accounts, whose
|
||||
/// combined debits with this entry exceed the threshold while this
|
||||
/// entry alone does not. Returns the matching posted entry ids.
|
||||
///
|
||||
/// This is the anti-gaming rule: splitting a $100 purchase into four
|
||||
/// $25 entries does not dodge the receipt policy. Same-day,
|
||||
/// same-description, same-accounts is the signature; recurring spending
|
||||
/// spread across days is not flagged.
|
||||
pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec<u64> {
|
||||
let debits = Self::entry_debits(entry);
|
||||
if debits > self.threshold() {
|
||||
return Vec::new(); // Normal policy already applies.
|
||||
}
|
||||
let desc = Self::normalize_description(entry.description());
|
||||
let accounts = Self::debit_accounts(entry);
|
||||
let date = entry.date();
|
||||
let mut group = Vec::new();
|
||||
let mut total = debits;
|
||||
for other in ledger.posted_entries() {
|
||||
if other.id() == entry_id {
|
||||
continue;
|
||||
}
|
||||
if other.date() != date {
|
||||
continue;
|
||||
}
|
||||
if Self::normalize_description(other.description()) != desc {
|
||||
continue;
|
||||
}
|
||||
if Self::debit_accounts(other) != accounts {
|
||||
continue;
|
||||
}
|
||||
// Reversals are exempt from the policy; don't count them.
|
||||
if other.reversal_of().is_some() {
|
||||
continue;
|
||||
}
|
||||
group.push(other.id());
|
||||
total += Self::entry_debits(other);
|
||||
}
|
||||
if !group.is_empty() && total > self.threshold() {
|
||||
group
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -180,7 +180,7 @@ mod tests {
|
||||
assert_eq!((z.revenue, z.expenses), (Decimal::ZERO, Decimal::ZERO));
|
||||
// None counts every line: identical to the account-based P&L.
|
||||
let all = crate::reports::profit_and_loss_filtered(&ledger, None).unwrap();
|
||||
let plain = crate::reports::profit_and_loss(&ledger).unwrap();
|
||||
let plain = crate::reports::unclosed_earnings(&ledger).unwrap();
|
||||
assert_eq!((all.revenue, all.expenses), (plain.revenue, plain.expenses));
|
||||
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000)));
|
||||
}
|
||||
@@ -205,7 +205,7 @@ mod tests {
|
||||
assert_eq!(rows[1].profit_and_loss.net_income(), d(40_000));
|
||||
assert_eq!(rows[2].profit_and_loss.net_income(), d(15_000));
|
||||
// The rows partition the ledger: they sum to the total P&L.
|
||||
let total = crate::reports::profit_and_loss(&ledger).unwrap();
|
||||
let total = crate::reports::unclosed_earnings(&ledger).unwrap();
|
||||
let rev: Decimal = rows.iter().map(|r| r.profit_and_loss.revenue).sum();
|
||||
let exp: Decimal = rows.iter().map(|r| r.profit_and_loss.expenses).sum();
|
||||
assert_eq!((rev, exp), (total.revenue, total.expenses));
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
//! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`,
|
||||
//! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections
|
||||
//! are reversing entries.
|
||||
//! * Reports ([`trial_balance`](reports::trial_balance), [`balance_sheet`](reports::balance_sheet), [`profit_and_loss`](reports::profit_and_loss)) are
|
||||
//! * Reports ([`trial_balance`](reports::trial_balance), [`balance_sheet`](reports::balance_sheet), [`unclosed_earnings`](reports::unclosed_earnings)) are
|
||||
//! pure functions of a ledger, which is what makes the semantic oracle
|
||||
//! (`docs/oracle.md`) possible.
|
||||
|
||||
|
||||
@@ -126,8 +126,9 @@ fn trial_balance_where(ledger: &Ledger, include: &dyn Fn(&JournalEntry) -> bool)
|
||||
}
|
||||
}
|
||||
|
||||
/// Profit and loss: revenue and expenses for the life of the ledger. For a
|
||||
/// date range, with a line per account, use [`profit_and_loss_for_period`].
|
||||
/// Revenue and expense totals: from [`unclosed_earnings`], what the books
|
||||
/// hold now (closed periods already moved to equity); for a date range,
|
||||
/// with a line per account, use [`profit_and_loss_for_period`].
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct ProfitAndLoss {
|
||||
/// Total revenue (credit-normal balances, reported positive).
|
||||
@@ -143,15 +144,19 @@ impl ProfitAndLoss {
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the profit and loss statement from all posted entries.
|
||||
pub fn profit_and_loss(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
|
||||
/// Earnings not yet closed: revenue and expenses over every posted entry,
|
||||
/// closing entries included, so a closed period's income (already moved
|
||||
/// to equity) nets out. This is the balance sheet's current-earnings line,
|
||||
/// not a profit and loss for any period (docs/testing.md #3; OpenStax,
|
||||
/// Principles of Financial Accounting 5.1).
|
||||
pub fn unclosed_earnings(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
|
||||
profit_and_loss_filtered(ledger, None)
|
||||
}
|
||||
|
||||
/// Build the profit and loss statement, optionally filtered to one class.
|
||||
///
|
||||
/// With `None` every line counts and the result is identical to
|
||||
/// [`profit_and_loss`]. With `Some(class)` only lines tagged with that
|
||||
/// [`unclosed_earnings`]. With `Some(class)` only lines tagged with that
|
||||
/// class count. Unlike the account-at-a-time accumulation this works line
|
||||
/// by line, because classes live on lines: each line contributes its signed
|
||||
/// amount (positive on the account's normal side) to its account type's
|
||||
@@ -181,7 +186,7 @@ pub struct ClassProfitAndLoss {
|
||||
/// sorted by class name; the unclassified row comes last.
|
||||
///
|
||||
/// The rows partition the ledger: summing every row's revenue and expenses
|
||||
/// reproduces [`profit_and_loss`].
|
||||
/// reproduces [`unclosed_earnings`].
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a profit and loss per class (department, location, job), plus the
|
||||
@@ -313,7 +318,7 @@ pub fn balance_sheet(ledger: &Ledger) -> Result<BalanceSheet, LedgerError> {
|
||||
AccountType::Revenue | AccountType::Expense => {}
|
||||
}
|
||||
}
|
||||
let current_earnings = profit_and_loss(ledger)?.net_income();
|
||||
let current_earnings = unclosed_earnings(ledger)?.net_income();
|
||||
equity += current_earnings;
|
||||
Ok(BalanceSheet {
|
||||
assets,
|
||||
@@ -1031,7 +1036,7 @@ pub fn members_equity(
|
||||
/// ```
|
||||
pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
|
||||
let tb = trial_balance(ledger);
|
||||
let pl = profit_and_loss(ledger)?;
|
||||
let pl = unclosed_earnings(ledger)?;
|
||||
let bs = balance_sheet(ledger)?;
|
||||
Ok(serde_json::json!({
|
||||
"trial_balance": {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
//! semantic oracle (see `docs/oracle.md`).
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{balance_sheet, profit_and_loss, snapshot, trial_balance};
|
||||
use ledger_core::reports::{balance_sheet, snapshot, trial_balance, unclosed_earnings};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
|
||||
LedgerError, Money, Side,
|
||||
@@ -565,7 +565,7 @@ fn accounting_equation_holds() {
|
||||
#[test]
|
||||
fn profit_and_loss_is_correct() {
|
||||
let c = fixture_company();
|
||||
let pl = profit_and_loss(&c.ledger).unwrap();
|
||||
let pl = unclosed_earnings(&c.ledger).unwrap();
|
||||
assert_eq!(pl.revenue, Decimal::from(4_000));
|
||||
assert_eq!(pl.expenses, Decimal::from(800));
|
||||
assert_eq!(pl.net_income(), Decimal::from(3_200));
|
||||
|
||||
@@ -10,8 +10,8 @@ use ledger_core::{
|
||||
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
||||
depreciation_schedule,
|
||||
reports::{
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
|
||||
trial_balance,
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, trial_balance,
|
||||
unclosed_earnings,
|
||||
},
|
||||
};
|
||||
use proptest::prelude::*;
|
||||
@@ -157,7 +157,7 @@ proptest! {
|
||||
let (r2, e2) = rest.map_or((Decimal::ZERO, Decimal::ZERO), |p| (p.total_revenue, p.total_expenses));
|
||||
prop_assert_eq!(first.total_revenue + r2, whole.total_revenue);
|
||||
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses);
|
||||
let all = profit_and_loss(&ledger).unwrap();
|
||||
let all = unclosed_earnings(&ledger).unwrap();
|
||||
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
|
||||
}
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
|
||||
use chrono::{Days, NaiveDate};
|
||||
use ledger_core::reports::{profit_and_loss, profit_and_loss_by_class, snapshot, trial_balance};
|
||||
use ledger_core::reports::{profit_and_loss_by_class, snapshot, trial_balance, unclosed_earnings};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, BalanceSheetStatement, CashFlowParams, CashFlowStatement, ClassId,
|
||||
CloseTo, Currency, EntryId, GeneralLedger, Ledger, MemberCapital, MemberClose,
|
||||
@@ -651,7 +651,7 @@ fn pnl_by_class_rows_sorted_by_name_unclassified_last() {
|
||||
(None, c(0), c(3000)),
|
||||
]
|
||||
);
|
||||
let whole = profit_and_loss(&co.l).unwrap();
|
||||
let whole = unclosed_earnings(&co.l).unwrap();
|
||||
assert_eq!(whole.revenue, c(15000));
|
||||
assert_eq!(whole.expenses, c(5000));
|
||||
}
|
||||
@@ -2134,7 +2134,7 @@ proptest! {
|
||||
fn prop_pnl_by_class_rows_sum_to_whole(gens in arb_books()) {
|
||||
let b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true);
|
||||
let rows = profit_and_loss_by_class(&b.l).unwrap();
|
||||
let whole = profit_and_loss(&b.l).unwrap();
|
||||
let whole = unclosed_earnings(&b.l).unwrap();
|
||||
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::<Decimal>(), whole.revenue);
|
||||
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses);
|
||||
let (wr, we) = model_pnl(&model(&gens, early(), d(2030, 1, 1), None));
|
||||
@@ -2171,7 +2171,7 @@ proptest! {
|
||||
let mut b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true);
|
||||
close_through(&mut b, Some(month), &re_close());
|
||||
let rows = profit_and_loss_by_class(&b.l).unwrap();
|
||||
let whole = profit_and_loss(&b.l).unwrap();
|
||||
let whole = unclosed_earnings(&b.l).unwrap();
|
||||
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.revenue).sum::<Decimal>(), whole.revenue);
|
||||
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses);
|
||||
}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::reports::{
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss, profit_and_loss_for_period,
|
||||
balance_sheet, balance_sheet_as_of, profit_and_loss_for_period, unclosed_earnings,
|
||||
};
|
||||
use ledger_core::{
|
||||
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
|
||||
@@ -127,7 +127,7 @@ fn profit_and_loss_is_scoped_to_the_period() {
|
||||
(dec(3_500), dec(920))
|
||||
);
|
||||
// ... which is the life-of-ledger P&L, because nothing is outside it.
|
||||
let all = profit_and_loss(&b.ledger).unwrap();
|
||||
let all = unclosed_earnings(&b.ledger).unwrap();
|
||||
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
|
||||
|
||||
// One class: only the tagged software line.
|
||||
|
||||
@@ -12,6 +12,30 @@ use ledger_core::reports::profit_and_loss_for_period;
|
||||
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// Everything the books need, as items to show.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: tell the humans what's unfinished or wrong, and hand them the
|
||||
/// prompt that gets the agent to fix it; never change the books.
|
||||
/// SOURCE: DECISION (Stephen: the viewer is read-only; its "Needs
|
||||
/// attention" list offers prompts, AGENTS.md); the conditions
|
||||
/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s).
|
||||
/// INVARIANTS:
|
||||
/// 1. Pure: reads `b`, writes nothing.
|
||||
/// 2. One item per: draft entry (Todo); posted entry missing a receipt
|
||||
/// (Problem; four or more are one combined item); recurring entry or
|
||||
/// schedule due through today (Todo); period ended and still open
|
||||
/// (Todo); fiscal-year account with activity and no tax category
|
||||
/// (Info); legal rule known only through a past year (Todo); 1099
|
||||
/// vendor without a W-9 (Problem); overdue bills, and overdue
|
||||
/// invoices (Problem, one each, with the total); money owed to
|
||||
/// owners (Info, per account with a balance); the database differing
|
||||
/// from the journal or unreadable, and damaged or missing stored
|
||||
/// files (Problem).
|
||||
/// 3. Nothing else: books with none of those yield no items.
|
||||
/// 4. Every item's prompt starts "In wanderbooks," or names
|
||||
/// wanderbooks, and its amounts are the books' exact amounts.
|
||||
/// ```
|
||||
pub fn find(b: &Books) -> Vec<Attention> {
|
||||
let mut out = Vec::new();
|
||||
let today = b.today;
|
||||
@@ -49,9 +73,9 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
||||
.collect();
|
||||
out.push(Attention {
|
||||
severity: Severity::Problem,
|
||||
title: format!("{} without receipts", fmt::count(missing.len(), "expense", "expenses")),
|
||||
title: format!("{} without receipts", fmt::count(missing.len(), "entry", "entries")),
|
||||
detail: format!(
|
||||
"{} of spending has no receipt attached ({}).",
|
||||
"{} in entries with no receipt attached ({}).",
|
||||
fmt::dollars(total),
|
||||
b.receipt_rule()
|
||||
),
|
||||
|
||||
@@ -34,7 +34,7 @@ pub struct EntryInfo {
|
||||
pub vendor: Option<String>,
|
||||
pub reversal_of: Option<EntryId>,
|
||||
pub reversed_by: Option<EntryId>,
|
||||
/// Spending over the receipt threshold with no receipt.
|
||||
/// Covered by the receipt policy (the server's rule) and has no receipt.
|
||||
pub missing_receipt: bool,
|
||||
/// Net effect on cash accounts: positive in, negative out, zero if the
|
||||
/// entry doesn't touch cash.
|
||||
@@ -162,11 +162,13 @@ impl Books {
|
||||
let ledger = snap.ledger().clone();
|
||||
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
||||
let journal = snap.journal().to_vec();
|
||||
let receipt_threshold = settings
|
||||
.receipt_threshold
|
||||
.as_deref()
|
||||
.and_then(|t| t.parse().ok())
|
||||
.unwrap_or(Decimal::new(25, 0));
|
||||
// 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,
|
||||
)?;
|
||||
let receipt_threshold = policy.threshold();
|
||||
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||
let codes = |cs: &[String]| -> Vec<AccountId> {
|
||||
cs.iter()
|
||||
@@ -238,23 +240,14 @@ impl Books {
|
||||
.entry_vendor(id)
|
||||
.and_then(|v| ledger.get_vendor(v))
|
||||
.map(|v| v.name.clone());
|
||||
// Receipts prove spending: hand-made entries (and recurring
|
||||
// drafts) that debit an expense or buy an asset from outside.
|
||||
// Invoices, payments, transfers, draws, closes, and schedules
|
||||
// carry their own evidence.
|
||||
let spends = e.lines().iter().any(|l| {
|
||||
l.side == Side::Debit
|
||||
&& ledger.chart().get(l.account).is_some_and(|a| {
|
||||
a.account_type == AccountType::Expense
|
||||
|| (a.account_type == AccountType::Asset
|
||||
&& !cash_accounts.contains(&a.id)
|
||||
&& !a.name.to_lowercase().contains("receivable"))
|
||||
})
|
||||
});
|
||||
// The receipt policy covers what is posted by hand (journal
|
||||
// entries and recurring drafts); invoices, payments, closes,
|
||||
// depreciation, and schedules carry their own evidence, and a
|
||||
// reversal leans on its original's.
|
||||
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
|
||||
&& spends
|
||||
&& e.reversal_of().is_none()
|
||||
&& rec.is_empty()
|
||||
&& amount > receipt_threshold;
|
||||
&& policy.receipt_required(&ledger, e);
|
||||
let cash_effect: Decimal = e
|
||||
.lines()
|
||||
.iter()
|
||||
|
||||
@@ -15,6 +15,13 @@ fn f(d: Decimal) -> f64 {
|
||||
d.try_into().unwrap_or(0.0)
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// INVARIANTS (finite lo <= hi, n >= 1):
|
||||
/// 1. Evenly spaced by a step of 1, 2, 2.5, or 5 times a power of ten.
|
||||
/// 2. The first tick is at or below lo and the last at or above hi, so
|
||||
/// no data falls outside the gridlines.
|
||||
/// 3. At most n + 2 ticks.
|
||||
/// ```
|
||||
/// "Nice" axis ticks covering [lo, hi].
|
||||
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
|
||||
let span = (hi - lo).max(1e-9);
|
||||
|
||||
@@ -20,6 +20,23 @@ pub fn downloads_dir() -> PathBuf {
|
||||
}
|
||||
|
||||
/// `name` made safe as a file name on every platform.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a report title (company data) can never name a file outside the
|
||||
/// downloads folder, or one a platform refuses.
|
||||
/// SOURCE: docs/spec/sources/file-names.md (Microsoft's naming rules;
|
||||
/// POSIX NAME_MAX).
|
||||
/// INVARIANTS:
|
||||
/// 1. The result is `<base>.<ext>`, `base` holding no reserved
|
||||
/// character (< > : " / \ | ? *) and no control character: each is
|
||||
/// replaced ('-' or a space).
|
||||
/// 2. `base` is never empty, never "." or "..", never ends with a space
|
||||
/// or a period, and is never a reserved device name (CON, NUL, COM1,
|
||||
/// LPT¹, ... in any case, before its first period): such a base gets
|
||||
/// a leading '_'.
|
||||
/// 3. The whole name is at most 255 bytes of UTF-8, cut at a character
|
||||
/// boundary.
|
||||
/// ```
|
||||
pub fn file_name(name: &str, ext: &str) -> String {
|
||||
let clean: String = name
|
||||
.chars()
|
||||
@@ -29,7 +46,54 @@ pub fn file_name(name: &str, ext: &str) -> String {
|
||||
c => c,
|
||||
})
|
||||
.collect();
|
||||
format!("{}.{ext}", clean.trim())
|
||||
let mut base = clean.trim().trim_end_matches(['.', ' ']).to_string();
|
||||
if base.is_empty() {
|
||||
base = "report".to_string();
|
||||
}
|
||||
let stem = base
|
||||
.split('.')
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.trim_end()
|
||||
.to_uppercase();
|
||||
let reserved = matches!(stem.as_str(), "CON" | "PRN" | "AUX" | "NUL")
|
||||
|| ["COM", "LPT"].iter().any(|p| {
|
||||
stem.strip_prefix(p).is_some_and(|n| {
|
||||
matches!(
|
||||
n,
|
||||
"1" | "2"
|
||||
| "3"
|
||||
| "4"
|
||||
| "5"
|
||||
| "6"
|
||||
| "7"
|
||||
| "8"
|
||||
| "9"
|
||||
| "\u{b9}"
|
||||
| "\u{b2}"
|
||||
| "\u{b3}"
|
||||
)
|
||||
})
|
||||
});
|
||||
if reserved {
|
||||
base.insert(0, '_');
|
||||
}
|
||||
let limit = 255usize.saturating_sub(ext.len() + 1);
|
||||
if base.len() > limit {
|
||||
let mut cut = limit;
|
||||
while !base.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
base.truncate(cut);
|
||||
// A cut can leave a trailing space or period: trim again.
|
||||
while base.ends_with(['.', ' ']) {
|
||||
base.pop();
|
||||
}
|
||||
if base.is_empty() {
|
||||
base = "report".to_string();
|
||||
}
|
||||
}
|
||||
format!("{base}.{ext}")
|
||||
}
|
||||
|
||||
/// Write `bytes` to the downloads folder as `name.ext` (replacing a file
|
||||
|
||||
@@ -1,36 +1,56 @@
|
||||
//! Formatting money, dates, and counts. Amounts are exact decimals in and
|
||||
//! exact strings out: nothing here rounds except `compact`, which says so.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: show every amount exactly as the books hold it.
|
||||
//! SOURCE: DECISION (the MCP contract: "money is exact or it is wrong");
|
||||
//! CONVENTION (US accounting display: thousands commas,
|
||||
//! negatives in parentheses or with a minus sign).
|
||||
//! INVARIANTS:
|
||||
//! 1. money, accounting, and dollars NEVER round: every decimal place
|
||||
//! the amount has (at least two) is shown, thousands grouped by
|
||||
//! commas in threes, so the text reads back to the same value.
|
||||
//! 2. Zero (and negative zero) shows without a sign; a negative shows
|
||||
//! U+2212 (money, dollars) or parentheses (accounting), never both
|
||||
//! and never "-0".
|
||||
//! 3. compact rounds (chart axes, tight tiles): $950, $1.2k, $25k,
|
||||
//! $3.4M; it never shows a sign on what rounds to zero.
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// Thousands separators, two decimals, a true minus sign: `−1,234.56`.
|
||||
/// Thousands separators, every decimal (at least two), a true minus sign:
|
||||
/// `−1,234.56`.
|
||||
pub fn money(d: Decimal) -> String {
|
||||
let neg = d.is_sign_negative() && !d.is_zero();
|
||||
let s = group(d.abs().round_dp(2));
|
||||
if neg { format!("\u{2212}{s}") } else { s }
|
||||
let s = group(d.abs());
|
||||
if is_negative(d) {
|
||||
format!("\u{2212}{s}")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Accounting style: negatives in parentheses, `(1,234.56)`.
|
||||
pub fn accounting(d: Decimal) -> String {
|
||||
if d.is_sign_negative() && !d.is_zero() {
|
||||
format!("({})", group(d.abs().round_dp(2)))
|
||||
} else {
|
||||
group(d.round_dp(2))
|
||||
}
|
||||
let s = group(d.abs());
|
||||
if is_negative(d) { format!("({s})") } else { s }
|
||||
}
|
||||
|
||||
/// With the currency sign: `$1,234.56`, `−$12.00`.
|
||||
pub fn dollars(d: Decimal) -> String {
|
||||
let neg = d.is_sign_negative() && !d.is_zero();
|
||||
let s = format!("${}", group(d.abs().round_dp(2)));
|
||||
if neg { format!("\u{2212}{s}") } else { s }
|
||||
let s = format!("${}", group(d.abs()));
|
||||
if is_negative(d) {
|
||||
format!("\u{2212}{s}")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
|
||||
pub fn compact(d: Decimal) -> String {
|
||||
let f: f64 = d.try_into().unwrap_or(0.0);
|
||||
let (neg, a) = (f < 0.0, f.abs());
|
||||
let a = f.abs();
|
||||
let s = if a >= 1_000_000.0 {
|
||||
format!("${:.1}M", a / 1_000_000.0)
|
||||
} else if a >= 10_000.0 {
|
||||
@@ -40,11 +60,22 @@ pub fn compact(d: Decimal) -> String {
|
||||
} else {
|
||||
format!("${a:.0}")
|
||||
};
|
||||
if neg { format!("\u{2212}{s}") } else { s }
|
||||
// No sign on what rounds to zero ("$0", not "−$0").
|
||||
if f < 0.0 && s != "$0" {
|
||||
format!("\u{2212}{s}")
|
||||
} else {
|
||||
s
|
||||
}
|
||||
}
|
||||
|
||||
fn is_negative(d: Decimal) -> bool {
|
||||
d.is_sign_negative() && !d.is_zero()
|
||||
}
|
||||
|
||||
/// A non-negative amount, every decimal it has (at least two), grouped.
|
||||
fn group(d: Decimal) -> String {
|
||||
let s = format!("{:.2}", d);
|
||||
let places = d.normalize().scale().max(2) as usize;
|
||||
let s = format!("{:.*}", places, d);
|
||||
let (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
|
||||
let mut out = String::new();
|
||||
for (i, c) in int.chars().enumerate() {
|
||||
@@ -172,7 +203,9 @@ mod tests {
|
||||
assert_eq!(money(dec("0")), "0.00");
|
||||
assert_eq!(money(dec("-0.00")), "0.00");
|
||||
assert_eq!(accounting(dec("-207.68")), "(207.68)");
|
||||
assert_eq!(dollars(dec("999.999")), "$1,000.00");
|
||||
assert_eq!(dollars(dec("999.999")), "$999.999");
|
||||
assert_eq!(money(dec("0.125")), "0.125");
|
||||
assert_eq!(compact(dec("-0.4")), "$0");
|
||||
assert_eq!(compact(dec("1234")), "$1.2k");
|
||||
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
|
||||
assert_eq!(compact(dec("950.4")), "$950");
|
||||
|
||||
@@ -150,6 +150,17 @@ impl Section {
|
||||
}
|
||||
}
|
||||
|
||||
/// ```text
|
||||
/// GOAL: back and forward behave like a web browser's.
|
||||
/// SOURCE: CONVENTION (browser session history: going somewhere new
|
||||
/// drops the forward list).
|
||||
/// INVARIANTS:
|
||||
/// 1. go(r) to a different route makes it current, pushes the old one
|
||||
/// on back, and clears forward; go to the current route does nothing.
|
||||
/// 2. back() then forward() returns to the same route (and vice versa);
|
||||
/// either does nothing when its list is empty.
|
||||
/// 3. can_back/can_forward say whether back/forward would move.
|
||||
/// ```
|
||||
/// Back/forward history.
|
||||
pub struct Nav {
|
||||
back: Vec<Route>,
|
||||
|
||||
@@ -148,6 +148,18 @@ impl Palette {
|
||||
|
||||
/// Subsequence match: every query character in order, rewarding
|
||||
/// contiguous runs and word starts. None if no match.
|
||||
/// Fuzzy match score, higher is better.
|
||||
///
|
||||
/// ```text
|
||||
/// INVARIANTS:
|
||||
/// 1. Matches (Some) exactly when the query's non-space characters,
|
||||
/// lower-cased, appear in order in the lower-cased hay; the empty
|
||||
/// query matches everything with score 0.
|
||||
/// 2. A match's score is at least the number of non-space query
|
||||
/// characters; consecutive matches and matches at a word start score
|
||||
/// more, so a word-start prefix scores at least as high as any
|
||||
/// other match of the same query.
|
||||
/// ```
|
||||
fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||||
if query.is_empty() {
|
||||
return Some(0);
|
||||
|
||||
@@ -26,9 +26,13 @@ serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
chrono = "0.4"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "sync"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io-std", "sync"] }
|
||||
zip = "9.0.1"
|
||||
toml = "1"
|
||||
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
jsonschema = { version = "0.58", default-features = false }
|
||||
proptest = "1"
|
||||
@@ -13,11 +13,12 @@ use ledger_config as company;
|
||||
mod policy;
|
||||
mod server;
|
||||
mod types;
|
||||
mod wire;
|
||||
|
||||
use ledger_core::Currency;
|
||||
use ledger_store::Store;
|
||||
use policy::ReceiptPolicy;
|
||||
use rmcp::{ServiceExt, transport::stdio};
|
||||
use rmcp::ServiceExt;
|
||||
use serde::Deserialize;
|
||||
use server::BooksServer;
|
||||
use std::path::PathBuf;
|
||||
@@ -28,10 +29,7 @@ struct Config {
|
||||
/// ISO 4217 base currency for a new store. Default "USD".
|
||||
#[serde(default)]
|
||||
base_currency: Option<String>,
|
||||
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
|
||||
/// with neither, receipts are required above $25.00.
|
||||
#[serde(default)]
|
||||
receipt_policy: Option<ReceiptPolicy>,
|
||||
// `receipt_policy` is read by ledger_config::effective_receipt_policy.
|
||||
}
|
||||
|
||||
fn default_data_dir() -> PathBuf {
|
||||
@@ -105,14 +103,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
},
|
||||
};
|
||||
eprintln!("ledger-mcp: company {:?}", settings.company.name);
|
||||
let policy = match (config.receipt_policy, settings.receipt_threshold) {
|
||||
(Some(policy), _) => policy,
|
||||
(None, Some(threshold)) => ReceiptPolicy {
|
||||
require_receipts_above: threshold,
|
||||
..ReceiptPolicy::default()
|
||||
},
|
||||
(None, None) => ReceiptPolicy::default(),
|
||||
};
|
||||
let policy = company::effective_receipt_policy(&config_path, &settings)?;
|
||||
let roles = server::CashRoles {
|
||||
cash: settings.cash_accounts,
|
||||
financing: settings.financing_accounts,
|
||||
@@ -152,8 +143,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||
.with_company(company)
|
||||
.with_cash_roles(roles);
|
||||
rt.block_on(async {
|
||||
let running = server.serve(stdio()).await?;
|
||||
// The wire answers what JSON-RPC requires that rmcp drops (wire.rs).
|
||||
let (input, output, writer) = wire::spawn(tokio::io::stdin(), tokio::io::stdout());
|
||||
let running = server.serve((input, output)).await?;
|
||||
let _ = running.waiting().await;
|
||||
let _ = writer.await;
|
||||
Ok::<(), Box<dyn std::error::Error>>(())
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,192 +1,4 @@
|
||||
//! The receipt policy: server-side enforcement, not prose.
|
||||
//!
|
||||
//! Posting an entry whose debits are strictly greater than the configured
|
||||
//! threshold requires at least one attached receipt, unless every line is in
|
||||
//! an exempt account. Selected accounts can require receipts regardless of
|
||||
//! amount. Reversals are exempt: the original entry carries the evidence.
|
||||
//! The receipt policy lives in `ledger-config` (shared with the viewer, so
|
||||
//! both read one rule); re-exported here.
|
||||
|
||||
use ledger_core::{JournalEntry, Ledger, Side};
|
||||
use rust_decimal::Decimal;
|
||||
use serde::Deserialize;
|
||||
|
||||
/// Server-side receipt policy, loaded from config.
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ReceiptPolicy {
|
||||
/// Receipts required when an entry's debits are strictly greater than
|
||||
/// this amount. Decimal string, e.g. `"25.00"`.
|
||||
#[serde(default = "default_threshold")]
|
||||
pub require_receipts_above: String,
|
||||
/// Account codes that always require a receipt, regardless of amount.
|
||||
#[serde(default)]
|
||||
pub receipt_required_accounts: Vec<String>,
|
||||
/// Account codes exempt from the policy. The exemption applies only when
|
||||
/// *every* line of the entry is in an exempt account.
|
||||
#[serde(default)]
|
||||
pub receipt_exempt_accounts: Vec<String>,
|
||||
}
|
||||
|
||||
fn default_threshold() -> String {
|
||||
"25.00".to_string()
|
||||
}
|
||||
|
||||
impl Default for ReceiptPolicy {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
require_receipts_above: default_threshold(),
|
||||
receipt_required_accounts: Vec::new(),
|
||||
receipt_exempt_accounts: Vec::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ReceiptPolicy {
|
||||
fn threshold(&self) -> Decimal {
|
||||
self.require_receipts_above
|
||||
.parse()
|
||||
.unwrap_or(Decimal::from(25))
|
||||
}
|
||||
|
||||
fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec<String> {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
ledger
|
||||
.chart()
|
||||
.get(l.account)
|
||||
.map(|a| a.code.clone())
|
||||
.unwrap_or_default()
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Total debits of the entry (one side of the balanced entry).
|
||||
pub fn entry_debits(entry: &JournalEntry) -> Decimal {
|
||||
entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.amount.amount)
|
||||
.sum()
|
||||
}
|
||||
|
||||
/// True when posting this entry requires at least one receipt.
|
||||
pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool {
|
||||
let codes = self.account_codes(ledger, entry);
|
||||
let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect();
|
||||
self.receipt_required_for(&refs, Self::entry_debits(entry))
|
||||
}
|
||||
|
||||
/// True when an entry touching these account codes with this debit total
|
||||
/// requires at least one receipt. The code-level check behind
|
||||
/// [`Self::receipt_required`], for postings (like disposals) computed
|
||||
/// without a draft entry in hand.
|
||||
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
|
||||
// Per-account override: any line in a required account → required.
|
||||
if codes
|
||||
.iter()
|
||||
.any(|c| self.receipt_required_accounts.iter().any(|r| r == *c))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
// Strictly greater than the threshold (Stephen: ">$25").
|
||||
if debits <= self.threshold() {
|
||||
return false;
|
||||
}
|
||||
// Exempt only when every line is in an exempt account.
|
||||
!codes
|
||||
.iter()
|
||||
.all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c))
|
||||
}
|
||||
|
||||
/// Human-readable policy summary for previews and errors.
|
||||
pub fn describe(&self) -> String {
|
||||
if self.threshold().is_zero() {
|
||||
"receipts required on every entry".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"receipts required when entry debits are strictly greater than {}",
|
||||
self.threshold()
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The receipt rule, phrased for the agent's operating instructions.
|
||||
pub fn rule(&self) -> String {
|
||||
if self.threshold().is_zero() {
|
||||
"every entry needs at least one receipt".to_string()
|
||||
} else {
|
||||
format!(
|
||||
"any entry moving more than ${} in debits needs at least one receipt",
|
||||
self.threshold().round_dp(2)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a description for split detection.
|
||||
fn normalize_description(desc: &str) -> String {
|
||||
desc.split_whitespace()
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ")
|
||||
.to_lowercase()
|
||||
}
|
||||
|
||||
/// Debit account ids of an entry, sorted.
|
||||
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
|
||||
let mut ids: Vec<u64> = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == Side::Debit)
|
||||
.map(|l| l.account)
|
||||
.collect();
|
||||
ids.sort_unstable();
|
||||
ids.dedup();
|
||||
ids
|
||||
}
|
||||
|
||||
/// Detect a potential threshold split: posted entries on the same date
|
||||
/// with the same normalized description and debit accounts, whose
|
||||
/// combined debits with this entry exceed the threshold while this
|
||||
/// entry alone does not. Returns the matching posted entry ids.
|
||||
///
|
||||
/// This is the anti-gaming rule: splitting a $100 purchase into four
|
||||
/// $25 entries does not dodge the receipt policy. Same-day,
|
||||
/// same-description, same-accounts is the signature; recurring spending
|
||||
/// spread across days is not flagged.
|
||||
pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec<u64> {
|
||||
let debits = Self::entry_debits(entry);
|
||||
if debits > self.threshold() {
|
||||
return Vec::new(); // Normal policy already applies.
|
||||
}
|
||||
let desc = Self::normalize_description(entry.description());
|
||||
let accounts = Self::debit_accounts(entry);
|
||||
let date = entry.date();
|
||||
let mut group = Vec::new();
|
||||
let mut total = debits;
|
||||
for other in ledger.posted_entries() {
|
||||
if other.id() == entry_id {
|
||||
continue;
|
||||
}
|
||||
if other.date() != date {
|
||||
continue;
|
||||
}
|
||||
if Self::normalize_description(other.description()) != desc {
|
||||
continue;
|
||||
}
|
||||
if Self::debit_accounts(other) != accounts {
|
||||
continue;
|
||||
}
|
||||
// Reversals are exempt from the policy; don't count them.
|
||||
if other.reversal_of().is_some() {
|
||||
continue;
|
||||
}
|
||||
group.push(other.id());
|
||||
total += Self::entry_debits(other);
|
||||
}
|
||||
if !group.is_empty() && total > self.threshold() {
|
||||
group
|
||||
} else {
|
||||
Vec::new()
|
||||
}
|
||||
}
|
||||
}
|
||||
pub use ledger_config::ReceiptPolicy;
|
||||
+102
-42
@@ -1,5 +1,34 @@
|
||||
//! The MCP server: narrow typed tools over the store, with the receipt
|
||||
//! policy enforced server-side.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: let an AI agent keep the books through tools that cannot leave
|
||||
//! them wrong, and say exactly what happened.
|
||||
//! SOURCE: docs/spec/sources/mcp-tools.md (MCP 2025-06-18 tools: structured
|
||||
//! results conform to the declared output schema; tool errors are
|
||||
//! results with isError; "validate all tool inputs");
|
||||
//! docs/spec/sources/jsonrpc-2.0.md (one response per request, id
|
||||
//! echoed, result xor error); DECISION (Stephen, the operating
|
||||
//! contract in INSTRUCTIONS and docs/mcp-interface.md).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every tool's structured result MUST conform to its declared output
|
||||
//! schema, and the same JSON MUST be in a text block.
|
||||
//! 2. A refused call (isError) MUST leave the books unchanged: the
|
||||
//! journal byte for byte.
|
||||
//! 3. Amounts in MUST be decimal strings, exactly `-?digits[.digits]`
|
||||
//! (no exponent, sign "+", separators, or spaces); amounts out are
|
||||
//! decimal strings, never JSON numbers, with at least two places and
|
||||
//! never rounded.
|
||||
//! 4. Posting and reversing MUST preview without `confirmed: true`, and a
|
||||
//! preview MUST change nothing.
|
||||
//! 5. An entry the receipt policy covers MUST NOT post, or keep posted,
|
||||
//! without a receipt (crate::policy).
|
||||
//! 6. A reference (account, vendor, class) given as a number that names
|
||||
//! one thing by id and another by code or name is refused, never
|
||||
//! guessed.
|
||||
//! 7. The server MUST answer every request with exactly one response
|
||||
//! whose id is the request's, and keep serving after any tool error.
|
||||
//! ```
|
||||
|
||||
use crate::policy::ReceiptPolicy;
|
||||
use crate::types::*;
|
||||
@@ -330,9 +359,10 @@ fn parse_date(s: &str) -> Result<NaiveDate, String> {
|
||||
.map_err(|_| format!("bad date {s:?}: expected YYYY-MM-DD"))
|
||||
}
|
||||
|
||||
/// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3).
|
||||
fn parse_amount(s: &str) -> Result<Decimal, String> {
|
||||
s.parse::<Decimal>()
|
||||
.map_err(|_| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
|
||||
ledger_config::decimal(s)
|
||||
.ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
|
||||
}
|
||||
|
||||
fn parse_side(s: &str) -> Result<Side, String> {
|
||||
@@ -378,28 +408,35 @@ fn parse_account_type(s: &str) -> Result<AccountType, String> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolve an account reference: a numeric id, or an account code.
|
||||
/// Resolve an account reference: an account code, or a numeric id. A
|
||||
/// number that is one account's code and another's id is refused
|
||||
/// (INVARIANT 6).
|
||||
fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
|
||||
if let Ok(id) = account.parse::<u64>()
|
||||
&& store.ledger().chart().get(id).is_some()
|
||||
{
|
||||
return Ok(id);
|
||||
let chart = store.ledger().chart();
|
||||
let by_code = chart.find_by_code(account).map(|a| a.id);
|
||||
let by_id = account
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| chart.get(*id).is_some());
|
||||
match (by_code, by_id) {
|
||||
(Some(c), Some(i)) if c != i => Err(format!(
|
||||
"account {account:?} is ambiguous: it is the code of account {c} and the id of account {i}; pass the code of the one you mean"
|
||||
)),
|
||||
(Some(c), _) => Ok(c),
|
||||
(None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown account {account:?}: use an id or code from books.chart"
|
||||
)),
|
||||
}
|
||||
store
|
||||
.ledger()
|
||||
.chart()
|
||||
.find_by_code(account)
|
||||
.map(|a| a.id)
|
||||
.ok_or_else(|| format!("unknown account {account:?}: use an id or code from books.chart"))
|
||||
}
|
||||
|
||||
/// Resolve a class by id or name (case-insensitive).
|
||||
/// Resolve a vendor by id or name (case-insensitive); a number that is
|
||||
/// one vendor's id and another's name is refused (INVARIANT 6).
|
||||
fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||
if let Ok(id) = vendor.parse::<u64>()
|
||||
&& store.ledger().get_vendor(id).is_some()
|
||||
{
|
||||
return Ok(id);
|
||||
}
|
||||
let by_id = vendor
|
||||
.parse::<u64>()
|
||||
.ok()
|
||||
.filter(|id| store.ledger().get_vendor(*id).is_some());
|
||||
let matches: Vec<u64> = store
|
||||
.ledger()
|
||||
.list_vendors()
|
||||
@@ -407,6 +444,15 @@ fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||
.filter(|v| v.name.eq_ignore_ascii_case(vendor.trim()))
|
||||
.map(|v| v.id)
|
||||
.collect();
|
||||
if let Some(id) = by_id {
|
||||
return match matches.as_slice() {
|
||||
[] => Ok(id),
|
||||
[m] if *m == id => Ok(id),
|
||||
_ => Err(format!(
|
||||
"vendor {vendor:?} is ambiguous: it is vendor {id}'s id and another vendor's name; pass the id"
|
||||
)),
|
||||
};
|
||||
}
|
||||
match matches.as_slice() {
|
||||
[id] => Ok(*id),
|
||||
[] => Err(format!(
|
||||
@@ -420,19 +466,23 @@ fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
|
||||
form.map(|f| f.label().to_string())
|
||||
}
|
||||
|
||||
/// Resolve a class by id or name; a number that is one class's id and
|
||||
/// another's name is refused (INVARIANT 6).
|
||||
fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||
if let Ok(id) = class.parse::<u32>()
|
||||
&& store.ledger().get_class(id).is_some()
|
||||
{
|
||||
return Ok(id);
|
||||
let by_id = class
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|id| store.ledger().get_class(*id).is_some());
|
||||
let by_name = store.ledger().find_class_by_name(class).map(|c| c.id);
|
||||
match (by_id, by_name) {
|
||||
(Some(i), Some(n)) if i != n => Err(format!(
|
||||
"class {class:?} is ambiguous: it is class {i}'s id and class {n}'s name; pass the id"
|
||||
)),
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown class {class:?}: use an id or name from books.list_classes"
|
||||
)),
|
||||
}
|
||||
store
|
||||
.ledger()
|
||||
.find_class_by_name(class)
|
||||
.map(|c| c.id)
|
||||
.ok_or_else(|| {
|
||||
format!("unknown class {class:?}: use an id or name from books.list_classes")
|
||||
})
|
||||
}
|
||||
|
||||
/// A money amount as a decimal string with at least two places: "0.00",
|
||||
@@ -923,6 +973,11 @@ impl BooksServer {
|
||||
let mut store = self.lock()?;
|
||||
let account = resolve_account(&store, &p.account)?;
|
||||
let side = parse_side(&p.side)?;
|
||||
// Resolve the class before writing anything (INVARIANT 2).
|
||||
let class_id = match &p.class {
|
||||
Some(c) => Some(resolve_class(&store, c)?),
|
||||
None => None,
|
||||
};
|
||||
// Foreign-currency line?
|
||||
if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() {
|
||||
let fc = p
|
||||
@@ -949,8 +1004,7 @@ impl BooksServer {
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
// Class tag, if requested. The line just added is the last one.
|
||||
if let Some(class_ref) = p.class {
|
||||
let class_id = resolve_class(&store, &class_ref)?;
|
||||
if let Some(class_id) = class_id {
|
||||
let line_index = store
|
||||
.ledger()
|
||||
.get_entry(p.entry_id)
|
||||
@@ -3362,11 +3416,11 @@ impl BooksServer {
|
||||
let total = r.total().to_string();
|
||||
AgingRowOut {
|
||||
name: r.name,
|
||||
current: r.current.to_string(),
|
||||
days_30: r.days_30.to_string(),
|
||||
days_60: r.days_60.to_string(),
|
||||
days_90: r.days_90.to_string(),
|
||||
days_90_plus: r.days_90_plus.to_string(),
|
||||
current: money(r.current),
|
||||
days_30: money(r.days_30),
|
||||
days_60: money(r.days_60),
|
||||
days_90: money(r.days_90),
|
||||
days_90_plus: money(r.days_90_plus),
|
||||
total,
|
||||
}
|
||||
})
|
||||
@@ -3389,11 +3443,11 @@ impl BooksServer {
|
||||
let total = r.total().to_string();
|
||||
AgingRowOut {
|
||||
name: r.name,
|
||||
current: r.current.to_string(),
|
||||
days_30: r.days_30.to_string(),
|
||||
days_60: r.days_60.to_string(),
|
||||
days_90: r.days_90.to_string(),
|
||||
days_90_plus: r.days_90_plus.to_string(),
|
||||
current: money(r.current),
|
||||
days_30: money(r.days_30),
|
||||
days_60: money(r.days_60),
|
||||
days_90: money(r.days_90),
|
||||
days_90_plus: money(r.days_90_plus),
|
||||
total,
|
||||
}
|
||||
})
|
||||
@@ -3584,6 +3638,11 @@ impl BooksServer {
|
||||
if end < start {
|
||||
return Err("end is before start".to_string());
|
||||
}
|
||||
// One class, if asked (validated like every reference).
|
||||
let only = match &p.class {
|
||||
Some(c) => Some(resolve_class(&store, c)?),
|
||||
None => None,
|
||||
};
|
||||
// Per-class P&L over the date range: accumulate line by line.
|
||||
use std::collections::HashMap;
|
||||
#[derive(Default)]
|
||||
@@ -3621,6 +3680,7 @@ impl BooksServer {
|
||||
// Sort: named classes by name, unclassified last.
|
||||
let mut rows: Vec<(Option<String>, Acc)> = by_class
|
||||
.into_iter()
|
||||
.filter(|(cid, _)| only.is_none() || *cid == only)
|
||||
.map(|(cid, acc)| {
|
||||
let name = cid
|
||||
.and_then(|id| ledger.get_class(id))
|
||||
|
||||
@@ -566,9 +566,9 @@ pub struct TrialBalanceLineOut {
|
||||
pub code: String,
|
||||
/// Account name.
|
||||
pub name: String,
|
||||
/// Debit balance as a decimal string, or "0".
|
||||
/// Debit balance, decimal string ("0.00" when none).
|
||||
pub debit: String,
|
||||
/// Credit balance as a decimal string, or "0".
|
||||
/// Credit balance, decimal string ("0.00" when none).
|
||||
pub credit: String,
|
||||
}
|
||||
|
||||
@@ -1570,7 +1570,7 @@ pub struct DisposeAssetOut {
|
||||
/// Gain (positive) or loss (negative), decimal string.
|
||||
pub gain_loss: String,
|
||||
/// Depreciation auto-posted for the final partial period, decimal string
|
||||
/// ("0" when the period was already posted).
|
||||
/// ("0.00" when the period was already posted).
|
||||
pub final_depreciation: String,
|
||||
/// The disposal entry's lines with resulting balances.
|
||||
pub lines: Vec<PreviewLine>,
|
||||
|
||||
@@ -0,0 +1,197 @@
|
||||
//! The stdio wire, in front of rmcp: what JSON-RPC 2.0 requires that the
|
||||
//! transport doesn't do.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: every line the client sends gets the response JSON-RPC 2.0 says
|
||||
//! it gets, even one rmcp would drop.
|
||||
//! SOURCE: docs/spec/sources/jsonrpc-2.0.md (5: one response per request,
|
||||
//! id echoed, or null when it can't be detected; 5.1: -32700 Parse
|
||||
//! error, -32600 Invalid Request); MCP 2025-06-18 has no batches.
|
||||
//! INVARIANTS:
|
||||
//! 1. A line that isn't JSON gets {"code": -32700} with id null.
|
||||
//! 2. JSON that isn't a request, notification, or response object (an
|
||||
//! array, a scalar, an object without "jsonrpc": "2.0" and a string
|
||||
//! "method" or a result/error) gets {"code": -32600}, with the
|
||||
//! line's id when it has a valid one (string, number, null), else
|
||||
//! null.
|
||||
//! 3. Everything else goes to rmcp unchanged; a blank line is ignored.
|
||||
//! 4. Output lines are written whole: replies made here never split a
|
||||
//! line rmcp writes.
|
||||
//! ```
|
||||
|
||||
use serde_json::{Value, json};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncRead, AsyncWrite, AsyncWriteExt, BufReader};
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
/// What to do with one line from the client.
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub enum Line {
|
||||
/// Hand it to rmcp.
|
||||
Forward,
|
||||
/// Ignore it (blank).
|
||||
Skip,
|
||||
/// Answer it here with this JSON-RPC response.
|
||||
Reply(Value),
|
||||
}
|
||||
|
||||
/// Classify one line (INVARIANTS 1-3).
|
||||
pub fn classify(line: &str) -> Line {
|
||||
if line.trim().is_empty() {
|
||||
return Line::Skip;
|
||||
}
|
||||
let v: Value = match serde_json::from_str(line) {
|
||||
Ok(v) => v,
|
||||
Err(_) => return Line::Reply(error(Value::Null, -32700, "Parse error")),
|
||||
};
|
||||
let Some(obj) = v.as_object() else {
|
||||
return Line::Reply(error(Value::Null, -32600, "Invalid Request"));
|
||||
};
|
||||
let id = match obj.get("id") {
|
||||
Some(id @ (Value::String(_) | Value::Number(_) | Value::Null)) => id.clone(),
|
||||
_ => Value::Null,
|
||||
};
|
||||
let version_ok = obj.get("jsonrpc").and_then(Value::as_str) == Some("2.0");
|
||||
let id_ok = match obj.get("id") {
|
||||
None => true,
|
||||
Some(v) => matches!(v, Value::String(_) | Value::Number(_) | Value::Null),
|
||||
};
|
||||
let request = obj.get("method").is_some_and(Value::is_string);
|
||||
let response =
|
||||
obj.contains_key("id") && (obj.contains_key("result") ^ obj.contains_key("error"));
|
||||
if version_ok && id_ok && (request || response) {
|
||||
Line::Forward
|
||||
} else {
|
||||
Line::Reply(error(id, -32600, "Invalid Request"))
|
||||
}
|
||||
}
|
||||
|
||||
fn error(id: Value, code: i64, message: &str) -> Value {
|
||||
json!({"jsonrpc": "2.0", "id": id, "error": {"code": code, "message": message}})
|
||||
}
|
||||
|
||||
/// Run the wire between the real `input`/`output` and rmcp's ends:
|
||||
/// returns (what rmcp reads, what rmcp writes) and the task that must be
|
||||
/// awaited after rmcp finishes, so the last lines are flushed.
|
||||
pub fn spawn<R, W>(
|
||||
input: R,
|
||||
mut output: W,
|
||||
) -> (
|
||||
tokio::io::DuplexStream,
|
||||
tokio::io::DuplexStream,
|
||||
tokio::task::JoinHandle<()>,
|
||||
)
|
||||
where
|
||||
R: AsyncRead + Unpin + Send + 'static,
|
||||
W: AsyncWrite + Unpin + Send + 'static,
|
||||
{
|
||||
const BUF: usize = 1 << 20;
|
||||
let (rmcp_in, mut feed) = tokio::io::duplex(BUF);
|
||||
let (rmcp_out, from_rmcp) = tokio::io::duplex(BUF);
|
||||
let (tx, mut rx) = mpsc::unbounded_channel::<String>();
|
||||
let replies = tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(input).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
match classify(&line) {
|
||||
Line::Skip => {}
|
||||
Line::Forward => {
|
||||
if feed
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
Line::Reply(v) => {
|
||||
let _ = replies.send(v.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Dropping `feed` ends rmcp's input.
|
||||
});
|
||||
tokio::spawn(async move {
|
||||
let mut lines = BufReader::new(from_rmcp).lines();
|
||||
while let Ok(Some(line)) = lines.next_line().await {
|
||||
if tx.send(line).is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
let writer = tokio::spawn(async move {
|
||||
while let Some(line) = rx.recv().await {
|
||||
if output
|
||||
.write_all(format!("{line}\n").as_bytes())
|
||||
.await
|
||||
.is_err()
|
||||
|| output.flush().await.is_err()
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
(rmcp_in, rmcp_out, writer)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn code(l: Line) -> (i64, Value) {
|
||||
match l {
|
||||
Line::Reply(v) => (v["error"]["code"].as_i64().unwrap(), v["id"].clone()),
|
||||
other => panic!("{other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classifies_by_the_jsonrpc_spec() {
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":99,"method":"#)),
|
||||
(-32700, Value::Null)
|
||||
);
|
||||
assert_eq!(code(classify("not json")), (-32700, Value::Null));
|
||||
assert_eq!(code(classify("[]")), (-32600, Value::Null));
|
||||
assert_eq!(code(classify("[1]")), (-32600, Value::Null));
|
||||
assert_eq!(code(classify("5")), (-32600, Value::Null));
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":98}"#)),
|
||||
(-32600, json!(98))
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"1.0","id":"a","method":"x"}"#)),
|
||||
(-32600, json!("a"))
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","id":[1],"method":"x"}"#)),
|
||||
(-32600, Value::Null)
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(r#"{"jsonrpc":"2.0","method":5}"#)),
|
||||
(-32600, Value::Null)
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":7,"result":{}}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
classify(r#"{"jsonrpc":"2.0","id":7,"error":{"code":1,"message":"x"}}"#),
|
||||
Line::Forward
|
||||
);
|
||||
assert_eq!(
|
||||
code(classify(
|
||||
r#"{"jsonrpc":"2.0","id":7,"result":{},"error":{}}"#
|
||||
)),
|
||||
(-32600, json!(7))
|
||||
);
|
||||
assert_eq!(classify(" "), Line::Skip);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -16,7 +16,7 @@ private, and constructors are `pub(crate)` where it matters. This is what
|
||||
makes the ledger auditable and what will make the future oracle harness
|
||||
trustworthy — the harness only needs to drive the command surface.
|
||||
|
||||
Reports (`trial_balance`, `balance_sheet`, `profit_and_loss`, `snapshot`)
|
||||
Reports (`trial_balance`, `balance_sheet`, `unclosed_earnings`, `snapshot`)
|
||||
are free functions: pure, total, and deterministic. Same ledger in, same
|
||||
report out, every time, on every platform. That determinism is the entire
|
||||
basis of `docs/oracle.md`.
|
||||
@@ -31,7 +31,7 @@ crates/ledger-core/
|
||||
account.rs # AccountType, Side, Account, ChartOfAccounts
|
||||
journal.rs # JournalLine, JournalEntry, validate()
|
||||
ledger.rs # Ledger: the command surface, append-only log
|
||||
reports.rs # trial_balance, profit_and_loss, balance_sheet, snapshot
|
||||
reports.rs # trial_balance, unclosed_earnings, balance_sheet, snapshot
|
||||
tests/
|
||||
invariants.rs # accounting-law tests + randomized property test
|
||||
golden/ # hand-verified expected report snapshots
|
||||
|
||||
+10
-10
@@ -85,14 +85,13 @@ below.
|
||||
- `books.draft_entry { date: "2026-10-10", description: "..." }`
|
||||
→ `{ entry_id, status: "draft" }`.
|
||||
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
|
||||
amount: "123.45" }`
|
||||
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals
|
||||
after every line: the agent watches the entry balance in real time.
|
||||
- `books.post_entry { entry_id, confirmed }` → without confirmation:
|
||||
`{ preview: { lines, resulting_balances, receipts: { attached: [...],
|
||||
required_but_missing: bool } } }`; with confirmation: validates (including
|
||||
the receipt policy), posts, returns `{ entry, balances_changed: [{ code,
|
||||
before, after }] }`.
|
||||
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
|
||||
class, bad amount) adds nothing.
|
||||
- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
|
||||
description, total, lines (with resulting balances), receipts,
|
||||
receipt_required, receipt_missing, split_detected }`. Without
|
||||
`confirmed: true`, `status` is `"preview"` and nothing changes; with it,
|
||||
the receipt policy is enforced and `status` is `"posted"`.
|
||||
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
||||
preview/confirm pattern as post.
|
||||
- `books.get_entry { entry_id }` → full entry with lines.
|
||||
@@ -492,8 +491,9 @@ entries.
|
||||
- `books.add_line` gains optional `class` (name or id): tags the new line.
|
||||
Shared costs are split explicitly across lines at entry time.
|
||||
- `books.profit_and_loss` gains optional `class` filter.
|
||||
- `books.profit_and_loss_by_class { start, end }` → per-class revenue,
|
||||
expenses, net income, plus an unclassified row. Reversals carry their
|
||||
- `books.profit_and_loss_by_class { start, end, class? }` → per-class revenue,
|
||||
expenses, net income, plus an unclassified row (only that class's row
|
||||
when `class` is given; an unknown or ambiguous class is refused). Reversals carry their
|
||||
class so divisional P&L nets correctly.
|
||||
|
||||
## Tools — CPA handoff
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
# File names the viewer writes
|
||||
|
||||
Governs `ledger_gui::export::file_name`. Fetched 2026-10-14.
|
||||
|
||||
## Microsoft, "Naming Files, Paths, and Namespaces" (learn.microsoft.com/en-us/windows/win32/fileio/naming-a-file)
|
||||
|
||||
> Use any character in the current code page for a name, including
|
||||
> Unicode characters and characters in the extended character set
|
||||
> (128–255), except for the following: The following reserved characters:
|
||||
> < (less than) > (greater than) : (colon) " (double quote) / (forward
|
||||
> slash) \ (backslash) | (vertical bar or pipe) ? (question mark) *
|
||||
> (asterisk) Integer value zero, sometimes referred to as the ASCII NUL
|
||||
> character. Characters whose integer representations are in the range
|
||||
> from 1 through 31, except for alternate data streams where these
|
||||
> characters are allowed.
|
||||
|
||||
> Do not use the following reserved names for the name of a file: CON,
|
||||
> PRN, AUX, NUL, COM1, COM2, COM3, COM4, COM5, COM6, COM7, COM8, COM9,
|
||||
> COM¹, COM², COM³, LPT1, LPT2, LPT3, LPT4, LPT5, LPT6, LPT7, LPT8, LPT9,
|
||||
> LPT¹, LPT², and LPT³. Also avoid these names followed immediately by an
|
||||
> extension; for example, NUL.txt and NUL.tar.gz are both equivalent to
|
||||
> NUL.
|
||||
|
||||
> Do not end a file or directory name with a space or a period. Although
|
||||
> the underlying file system may support such names, the Windows shell and
|
||||
> user interface does not. However, it is acceptable to specify a period
|
||||
> as the first character of a name.
|
||||
|
||||
## POSIX (IEEE Std 1003.1-2017, `<limits.h>`)
|
||||
|
||||
`{NAME_MAX}`: "Maximum number of bytes in a filename (not including the
|
||||
terminating null of a filename string)"; Linux's common file systems set
|
||||
it to 255, and NTFS limits a name to 255 UTF-16 units. wanderbooks keeps a
|
||||
name within 255 bytes of UTF-8, which fits both.
|
||||
@@ -0,0 +1,52 @@
|
||||
# Entity classification: which return a business files
|
||||
|
||||
Governs `ledger_config`'s reading of `[company] tax_classification`.
|
||||
Fetched 2026-10-14.
|
||||
|
||||
## Treas. Reg. §301.7701-3 (eCFR, via law.cornell.edu/cfr/text/26/301.7701-3)
|
||||
|
||||
**(a) In general**
|
||||
|
||||
> A business entity that is not classified as a corporation under
|
||||
> § 301.7701-2(b) (1), (3), (4), (5), (6), (7), or (8) (an eligible
|
||||
> entity) can elect its classification for federal tax purposes as
|
||||
> provided in this section. An eligible entity with at least two members
|
||||
> can elect to be classified as either an association (and thus a
|
||||
> corporation under § 301.7701-2(b)(2)) or a partnership, and an eligible
|
||||
> entity with a single owner can elect to be classified as an association
|
||||
> or to be disregarded as an entity separate from its owner.
|
||||
|
||||
**(b)(1) Domestic eligible entities**
|
||||
|
||||
> Except as provided in paragraph (b)(3) of this section, unless the
|
||||
> entity elects otherwise, a domestic eligible entity is— (i) A
|
||||
> partnership if it has two or more members; or (ii) Disregarded as an
|
||||
> entity separate from its owner if it has a single owner.
|
||||
|
||||
## Instructions for Schedule C (Form 1040) (2025), "Single-member limited liability company (LLC)"
|
||||
|
||||
> Generally, a single-member domestic LLC is not treated as a separate
|
||||
> entity for federal income tax purposes. If you are the sole member of a
|
||||
> domestic LLC, file Schedule C (or Schedule E or F, if applicable) unless
|
||||
> you have elected to treat the domestic LLC as a corporation.
|
||||
|
||||
## The returns (titles, from irs-tax-lines.md)
|
||||
|
||||
- Form 1065, U.S. Return of Partnership Income: a partnership.
|
||||
- Form 1120-S, U.S. Income Tax Return for an S Corporation: an S
|
||||
corporation.
|
||||
- Form 1120, U.S. Corporation Income Tax Return: a corporation (an
|
||||
association included) that is not an S corporation.
|
||||
|
||||
## Derived (wanderbooks)
|
||||
|
||||
| classification says | return |
|
||||
|---|---|
|
||||
| partnership | Form 1065 |
|
||||
| S corporation (S corp, S-corp) | Form 1120-S |
|
||||
| C corporation; corporation, corp, or association alone | Form 1120 |
|
||||
| disregarded entity, sole proprietor(ship), single-member | Schedule C |
|
||||
|
||||
A phrase naming two returns ("corporation or S corporation") and anything
|
||||
else names no return wanderbooks can be sure of, so it is
|
||||
refused rather than guessed.
|
||||
@@ -0,0 +1,36 @@
|
||||
# JSON-RPC 2.0 Specification (jsonrpc.org/specification)
|
||||
|
||||
Governs `ledger-mcp`'s wire protocol (MCP is JSON-RPC 2.0). Fetched
|
||||
2026-10-14.
|
||||
|
||||
**5 Response object**
|
||||
|
||||
> When a rpc call is made, the Server MUST reply with a Response, except
|
||||
> for in the case of Notifications. [...]
|
||||
>
|
||||
> result: This member is REQUIRED on success. This member MUST NOT exist
|
||||
> if there was an error invoking the method. [...]
|
||||
>
|
||||
> error: This member is REQUIRED on error. This member MUST NOT exist if
|
||||
> there was no error triggered during invocation. [...]
|
||||
>
|
||||
> id: This member is REQUIRED. It MUST be the same as the value of the id
|
||||
> member in the Request Object. If there was an error in detecting the id
|
||||
> in the Request object (e.g. Parse error/Invalid Request), it MUST be
|
||||
> Null.
|
||||
>
|
||||
> Either the result member or error member MUST be included, but both
|
||||
> members MUST NOT be included.
|
||||
|
||||
**5.1 Error object**
|
||||
|
||||
> code: A Number that indicates the error type that occurred. This MUST be
|
||||
> an integer. [...]
|
||||
>
|
||||
> | code | message | meaning |
|
||||
> |---|---|---|
|
||||
> | -32700 | Parse error | Invalid JSON was received by the server. |
|
||||
> | -32600 | Invalid Request | The JSON sent is not a valid Request object. |
|
||||
> | -32601 | Method not found | The method does not exist / is not available. |
|
||||
> | -32602 | Invalid params | Invalid method parameter(s). |
|
||||
> | -32603 | Internal error | Internal JSON-RPC error. |
|
||||
@@ -0,0 +1,47 @@
|
||||
# Model Context Protocol: tools (specification 2025-06-18)
|
||||
|
||||
Governs `ledger-mcp`'s tool results and errors. Source:
|
||||
https://github.com/modelcontextprotocol/modelcontextprotocol,
|
||||
docs/specification/2025-06-18/server/tools.mdx (MIT). Fetched 2026-10-14.
|
||||
|
||||
**Structured Content**
|
||||
|
||||
> **Structured** content is returned as a JSON object in the
|
||||
> `structuredContent` field of a result.
|
||||
>
|
||||
> For backwards compatibility, a tool that returns structured content
|
||||
> SHOULD also return the serialized JSON in a TextContent block.
|
||||
|
||||
**Output Schema**
|
||||
|
||||
> Tools may also provide an output schema for validation of structured
|
||||
> results. If an output schema is provided:
|
||||
>
|
||||
> - Servers **MUST** provide structured results that conform to this schema.
|
||||
> - Clients **SHOULD** validate structured results against this schema.
|
||||
|
||||
**Error Handling**
|
||||
|
||||
> Tools use two error reporting mechanisms:
|
||||
>
|
||||
> 1. **Protocol Errors**: Standard JSON-RPC errors for issues like:
|
||||
> - Unknown tools
|
||||
> - Invalid arguments
|
||||
> - Server errors
|
||||
>
|
||||
> 2. **Tool Execution Errors**: Reported in tool results with `isError: true`:
|
||||
> - API failures
|
||||
> - Invalid input data
|
||||
> - Business logic errors
|
||||
|
||||
**Security Considerations**
|
||||
|
||||
> 1. Servers **MUST**:
|
||||
> - Validate all tool inputs
|
||||
> - Implement proper access controls
|
||||
> - Rate limit tool invocations
|
||||
> - Sanitize tool outputs
|
||||
|
||||
(A local stdio server for one bookkeeper: access control is the file
|
||||
system's, and rate limiting has no one to limit; wanderbooks takes
|
||||
"validate all tool inputs" and "sanitize tool outputs" as binding.)
|
||||
+1
-1
@@ -83,7 +83,7 @@ accounting question), CHANGE (a source says otherwise; work pending).
|
||||
|---|---|---|---|
|
||||
| 1 | A P&L whose start is after its end is refused | DESIGN | No standard governs a reversed range; refusing beats silently swapping a typo |
|
||||
| 2 | A balance sheet omits zero-balance accounts | CONFIRMED | Reg. S-X 210.4-03(a): "No caption should be shown ... as to which the items and conditions are not present." A comparative statement must still show a zero beside a non-zero prior column |
|
||||
| 3 | The lifetime `profit_and_loss` counts closing entries | CONFIRMED | Used as earnings not yet closed on the balance sheet; closed income is already in capital (OpenStax Principles of Financial Accounting 5.1). Its name misleads: rename to unclosed earnings (pending) |
|
||||
| 3 | The lifetime `profit_and_loss` counts closing entries | CONFIRMED | Used as earnings not yet closed on the balance sheet; closed income is already in capital (OpenStax Principles of Financial Accounting 5.1). Renamed `unclosed_earnings` (2026-10-14) |
|
||||
| 4 | Closing a period with nothing to close still locks it | CONVENTION | A close is a lock (B-12); a dormant month must still lock and count for close-in-order |
|
||||
| 5 | Draws and distributions are closed into capital at period end | CONFIRMED | OpenStax 5.1: dividends "are closed to the permanent account"; drawing accounts close to owner's capital. Monthly closes mean the year's K-1 Item L withdrawals sum across months |
|
||||
| 6 | Reconciliation balances | CONFIRMED | Signed by the account's normal side (a card reads as owed), as statements show it; `reconciling_a_card_reads_its_normal_side_...` |
|
||||
|
||||
Generated
+47
-5
@@ -1413,6 +1413,17 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ledger-config"
|
||||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"ledger-core",
|
||||
"rust_decimal",
|
||||
"serde",
|
||||
"toml 1.1.8+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ledger-core"
|
||||
version = "0.1.0"
|
||||
@@ -1430,6 +1441,7 @@ version = "0.0.0"
|
||||
dependencies = [
|
||||
"arbitrary",
|
||||
"chrono",
|
||||
"ledger-config",
|
||||
"ledger-core",
|
||||
"ledger-import",
|
||||
"ledger-store",
|
||||
@@ -2337,6 +2349,15 @@ dependencies = [
|
||||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_spanned"
|
||||
version = "1.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
|
||||
dependencies = [
|
||||
"serde_core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "serde_yaml"
|
||||
version = "0.9.34+deprecated"
|
||||
@@ -2656,11 +2677,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||
dependencies = [
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_edit 0.22.27",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml"
|
||||
version = "1.1.8+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "20489e00e4d8741d6be680764cc12e270655e375a20d1011e844a9c3379e678d"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde_core",
|
||||
"serde_spanned 1.1.2",
|
||||
"toml_datetime 1.1.2+spec-1.1.0",
|
||||
"toml_parser",
|
||||
"toml_writer",
|
||||
"winnow 1.0.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "toml_datetime"
|
||||
version = "0.6.11"
|
||||
@@ -2687,7 +2723,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"serde",
|
||||
"serde_spanned",
|
||||
"serde_spanned 0.6.9",
|
||||
"toml_datetime 0.6.11",
|
||||
"toml_write",
|
||||
"winnow 0.7.15",
|
||||
@@ -2720,6 +2756,12 @@ version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
||||
|
||||
[[package]]
|
||||
name = "toml_writer"
|
||||
version = "1.1.3+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770"
|
||||
|
||||
[[package]]
|
||||
name = "ttf-parser"
|
||||
version = "0.25.1"
|
||||
@@ -2807,7 +2849,7 @@ dependencies = [
|
||||
"indexmap",
|
||||
"rustc-hash",
|
||||
"stacker",
|
||||
"toml",
|
||||
"toml 0.8.23",
|
||||
"typst-library",
|
||||
"typst-macros",
|
||||
"typst-syntax",
|
||||
@@ -2923,7 +2965,7 @@ dependencies = [
|
||||
"smallvec",
|
||||
"syntect",
|
||||
"time",
|
||||
"toml",
|
||||
"toml 0.8.23",
|
||||
"ttf-parser",
|
||||
"two-face",
|
||||
"typed-arena",
|
||||
@@ -3038,7 +3080,7 @@ dependencies = [
|
||||
"ecow",
|
||||
"rustc-hash",
|
||||
"serde",
|
||||
"toml",
|
||||
"toml 0.8.23",
|
||||
"typst-timing",
|
||||
"typst-utils",
|
||||
"unicode-ident",
|
||||
|
||||
@@ -24,6 +24,9 @@ path = "../crates/ledger-store"
|
||||
[dependencies.ledger-import]
|
||||
path = "../crates/ledger-import"
|
||||
|
||||
[dependencies.ledger-config]
|
||||
path = "../crates/ledger-config"
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_journal_line"
|
||||
path = "fuzz_targets/fuzz_journal_line.rs"
|
||||
@@ -74,3 +77,10 @@ path = "fuzz_targets/fuzz_import_csv.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
|
||||
[[bin]]
|
||||
name = "fuzz_config_parse"
|
||||
path = "fuzz_targets/fuzz_config_parse.rs"
|
||||
test = false
|
||||
doc = false
|
||||
bench = false
|
||||
@@ -0,0 +1,33 @@
|
||||
// Fuzz target: `wanderbooks.toml` is written by people and agents, so its
|
||||
// text is untrusted input. Parsing must return settings or an error, never
|
||||
// panic or hang, and what it accepts must honor parse's invariants: the
|
||||
// fiscal year starts on the first of a month, and members' shares (each in
|
||||
// (0, 1]) sum to exactly 1.
|
||||
#![no_main]
|
||||
|
||||
use libfuzzer_sys::fuzz_target;
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
fuzz_target!(|data: &[u8]| {
|
||||
let Ok(text) = std::str::from_utf8(data) else {
|
||||
return;
|
||||
};
|
||||
if let Ok(s) = ledger_config::parse(text) {
|
||||
let (m, d) = s.company.fiscal_year_start;
|
||||
assert!(
|
||||
(1..=12).contains(&m) && d == 1,
|
||||
"{:?}",
|
||||
s.company.fiscal_year_start
|
||||
);
|
||||
if !s.company.members.is_empty() {
|
||||
let total: Decimal = s.company.members.iter().map(|m| m.share).sum();
|
||||
assert_eq!(total, Decimal::ONE, "{:?}", s.company.members);
|
||||
for mem in &s.company.members {
|
||||
assert!(
|
||||
mem.share > Decimal::ZERO && mem.share <= Decimal::ONE,
|
||||
"{mem:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
[company]
|
||||
name = "Juniper Studio LLC"
|
||||
entity_type = "LLC, single-member"
|
||||
tax_classification = "disregarded entity" # Schedule C
|
||||
fiscal_year_start = "01-01"
|
||||
formation_state = "OR"
|
||||
formed = "2025-06-02"
|
||||
ein = "00-0000000"
|
||||
address = "1200 NW Everett St, Portland, OR 97209"
|
||||
owner = "Alex Rivera"
|
||||
|
||||
[books]
|
||||
accounting_basis = "accrual"
|
||||
receipt_threshold = "25.00"
|
||||
close_into = "3000"
|
||||
close_sweep = ["3100"]
|
||||
cash_accounts = ["1000", "1010"]
|
||||
Whitespace-only changes.
@@ -0,0 +1,25 @@
|
||||
[company]
|
||||
name = "Three Partners LLC"
|
||||
tax_form = "1065"
|
||||
fiscal_year_start = "07-01"
|
||||
|
||||
[[company.members]]
|
||||
name = "A"
|
||||
share = "0.5"
|
||||
capital_account = "3000"
|
||||
distribution_account = "3100"
|
||||
|
||||
[[company.members]]
|
||||
name = "B"
|
||||
share = "25%"
|
||||
capital_account = "3001"
|
||||
|
||||
[[company.members]]
|
||||
name = "C"
|
||||
share = " 0.25"
|
||||
capital_account = " 3002 "
|
||||
|
||||
[books]
|
||||
accounting_basis = "cash"
|
||||
receipt_threshold = "75"
|
||||
investing_accounts = ["1500"]
|
||||
@@ -0,0 +1,6 @@
|
||||
[company]
|
||||
tax_classification = "We are an S corp, formerly a sole proprietorship"
|
||||
fiscal_year_start = "02-29"
|
||||
[books]
|
||||
close_into = " "
|
||||
close_sweep = ["3100", ""]
|
||||
@@ -0,0 +1,28 @@
|
||||
[company]
|
||||
name = "Wanderland Labs LLC"
|
||||
entity_type = "LLC, multi-member, member-managed"
|
||||
tax_classification = "partnership" # Form 1065; a Schedule K-1 per member
|
||||
base_currency = "USD"
|
||||
fiscal_year_start = "01-01" # the year ends the day before: Dec 31
|
||||
formation_state = "UT"
|
||||
formed = "2026-10-04"
|
||||
|
||||
[[company.members]]
|
||||
name = "Stephen Waits"
|
||||
share = "50%"
|
||||
capital_account = "3000"
|
||||
|
||||
[[company.members]]
|
||||
name = "Katherine Pendergast"
|
||||
share = "50%"
|
||||
capital_account = "3001"
|
||||
|
||||
[books]
|
||||
data_dir = "books-data"
|
||||
accounting_basis = "accrual"
|
||||
receipt_threshold = "0.00"
|
||||
cash_accounts = ["1000"]
|
||||
financing_accounts = ["2100"]
|
||||
|
||||
[viewer]
|
||||
open_at_start = "ask"
|
||||
Reference in new issue
Block a user