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:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
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:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v4
|
||||||
- uses: jdx/mise-action@v2
|
- uses: jdx/mise-action@v2
|
||||||
|
|||||||
Generated
+240
-3
@@ -571,7 +571,7 @@ checksum = "591eba69b7c085632e22ca03606f0bbea68d53bbe26c8962e2d549af20c6a561"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"paste",
|
"paste",
|
||||||
"roman-numerals-rs",
|
"roman-numerals-rs",
|
||||||
"strum",
|
"strum 0.27.2",
|
||||||
"unic-langid",
|
"unic-langid",
|
||||||
"unicode-normalization",
|
"unicode-normalization",
|
||||||
"unscanny",
|
"unscanny",
|
||||||
@@ -700,6 +700,12 @@ dependencies = [
|
|||||||
"piper",
|
"piper",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "borrow-or-share"
|
||||||
|
version = "0.2.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc0b364ead1874514c8c2855ab558056ebfeb775653e7ae45ff72f28f8f3166c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "borsh"
|
name = "borsh"
|
||||||
version = "1.8.1"
|
version = "1.8.1"
|
||||||
@@ -757,6 +763,12 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bytecount"
|
||||||
|
version = "0.6.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "175812e0be2bccb6abe50bb8d566126198344f707e304f45c648fd8f2cc0365e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytemuck"
|
name = "bytemuck"
|
||||||
version = "1.25.2"
|
version = "1.25.2"
|
||||||
@@ -1373,6 +1385,12 @@ dependencies = [
|
|||||||
"syn 3.0.7",
|
"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]]
|
[[package]]
|
||||||
name = "data-url"
|
name = "data-url"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@@ -1742,6 +1760,15 @@ version = "1.19.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
|
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]]
|
[[package]]
|
||||||
name = "emath"
|
name = "emath"
|
||||||
version = "0.36.2"
|
version = "0.36.2"
|
||||||
@@ -1937,6 +1964,17 @@ dependencies = [
|
|||||||
"regex-syntax",
|
"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]]
|
[[package]]
|
||||||
name = "fastrand"
|
name = "fastrand"
|
||||||
version = "2.5.0"
|
version = "2.5.0"
|
||||||
@@ -2002,6 +2040,17 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "fnv"
|
name = "fnv"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@@ -2092,6 +2141,16 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"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]]
|
[[package]]
|
||||||
name = "fsevent-sys"
|
name = "fsevent-sys"
|
||||||
version = "4.1.0"
|
version = "4.1.0"
|
||||||
@@ -2249,9 +2308,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
|
"js-sys",
|
||||||
"libc",
|
"libc",
|
||||||
"r-efi 5.3.0",
|
"r-efi 5.3.0",
|
||||||
"wasip2",
|
"wasip2",
|
||||||
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2496,6 +2557,8 @@ version = "0.17.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
"foldhash",
|
"foldhash",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3340,6 +3403,59 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "kamadak-exif"
|
name = "kamadak-exif"
|
||||||
version = "0.6.1"
|
version = "0.6.1"
|
||||||
@@ -3476,8 +3592,11 @@ version = "0.1.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
"ledger-core",
|
"ledger-core",
|
||||||
|
"proptest",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"serde",
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"tempfile",
|
||||||
"toml 1.1.8+spec-1.1.0",
|
"toml 1.1.8+spec-1.1.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -3543,11 +3662,13 @@ dependencies = [
|
|||||||
"axum",
|
"axum",
|
||||||
"base64 0.22.1",
|
"base64 0.22.1",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"jsonschema",
|
||||||
"ledger-config",
|
"ledger-config",
|
||||||
"ledger-core",
|
"ledger-core",
|
||||||
"ledger-import",
|
"ledger-import",
|
||||||
"ledger-store",
|
"ledger-store",
|
||||||
"mime_guess",
|
"mime_guess",
|
||||||
|
"proptest",
|
||||||
"rmcp",
|
"rmcp",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
"schemars",
|
"schemars",
|
||||||
@@ -3732,6 +3853,12 @@ dependencies = [
|
|||||||
"autocfg",
|
"autocfg",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "micromap"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c2a86d3146ed3995b5913c414f6664344b9617457320782e64f0bb44afd49d74"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.17"
|
version = "0.3.17"
|
||||||
@@ -3921,6 +4048,20 @@ dependencies = [
|
|||||||
"bitflags 2.13.2",
|
"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]]
|
[[package]]
|
||||||
name = "num-bigint"
|
name = "num-bigint"
|
||||||
version = "0.4.8"
|
version = "0.4.8"
|
||||||
@@ -3931,6 +4072,21 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "num-conv"
|
name = "num-conv"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@@ -3946,6 +4102,27 @@ dependencies = [
|
|||||||
"num-traits",
|
"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]]
|
[[package]]
|
||||||
name = "num-traits"
|
name = "num-traits"
|
||||||
version = "0.2.19"
|
version = "0.2.19"
|
||||||
@@ -4354,6 +4531,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "outref"
|
||||||
|
version = "0.5.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "page_size"
|
name = "page_size"
|
||||||
version = "0.6.0"
|
version = "0.6.0"
|
||||||
@@ -5122,6 +5305,23 @@ dependencies = [
|
|||||||
"syn 3.0.7",
|
"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]]
|
[[package]]
|
||||||
name = "regex"
|
name = "regex"
|
||||||
version = "1.13.1"
|
version = "1.13.1"
|
||||||
@@ -5866,7 +6066,16 @@ version = "0.27.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
checksum = "af23d6f6c1a224baef9d3f61e287d2761385a5b88fdab4eb4c6f11aeb54c4bcf"
|
||||||
dependencies = [
|
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]]
|
[[package]]
|
||||||
@@ -5881,6 +6090,18 @@ dependencies = [
|
|||||||
"syn 2.0.119",
|
"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]]
|
[[package]]
|
||||||
name = "subsetter"
|
name = "subsetter"
|
||||||
version = "0.2.6"
|
version = "0.2.6"
|
||||||
@@ -5949,7 +6170,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
checksum = "656b45c05d95a5704399aeef6bd0ddec7b2b3531b7c9e900abbf7c4d2190c925"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bincode",
|
"bincode",
|
||||||
"fancy-regex",
|
"fancy-regex 0.16.2",
|
||||||
"flate2",
|
"flate2",
|
||||||
"fnv",
|
"fnv",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
@@ -6929,6 +7150,16 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"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]]
|
[[package]]
|
||||||
name = "vcpkg"
|
name = "vcpkg"
|
||||||
version = "0.2.15"
|
version = "0.2.15"
|
||||||
@@ -7044,6 +7275,12 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vsimd"
|
||||||
|
version = "0.8.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5c3082ca00d5a5ef149bb8b555a72ae84c9c59f7250f013ac822ac2e49b19c64"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wait-timeout"
|
name = "wait-timeout"
|
||||||
version = "0.2.1"
|
version = "0.2.1"
|
||||||
|
|||||||
@@ -11,7 +11,12 @@ ledger-core = { path = "../ledger-core" }
|
|||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
rust_decimal = "1"
|
rust_decimal = "1"
|
||||||
serde = { version = "1", features = ["derive"] }
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_json = "1"
|
||||||
toml = { version = "1", features = ["preserve_order"] }
|
toml = { version = "1", features = ["preserve_order"] }
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
proptest = "1"
|
||||||
|
tempfile = "3"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
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
|
//! 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.
|
//! the next start, so the two can't disagree.
|
||||||
|
|
||||||
|
pub mod receipt_policy;
|
||||||
|
pub use receipt_policy::ReceiptPolicy;
|
||||||
|
|
||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
@@ -148,21 +151,44 @@ struct BooksSection {
|
|||||||
investing_accounts: Vec<String>,
|
investing_accounts: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The return a federal tax classification files: "partnership" → 1065,
|
/// The return a federal tax classification files (docs/spec/sources/
|
||||||
/// "s corporation" → 1120-S, "c corporation" → 1120, "sole proprietorship"
|
/// irs-entity-classification.md), read by whole words, ignoring case:
|
||||||
/// or "disregarded entity" → Schedule C. Unrecognized: unknown.
|
/// "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> {
|
fn form_for_classification(c: &str) -> Option<ledger_core::TaxForm> {
|
||||||
let c = c.to_lowercase();
|
use ledger_core::TaxForm;
|
||||||
if c.contains("partnership") {
|
let lower = c.to_lowercase();
|
||||||
Some(ledger_core::TaxForm::Form1065)
|
let words: Vec<&str> = lower
|
||||||
} else if c.contains("s corp") || c.contains("s-corp") || c.contains("s_corp") {
|
.split(|ch: char| !ch.is_alphanumeric())
|
||||||
Some(ledger_core::TaxForm::Form1120S)
|
.filter(|w| !w.is_empty())
|
||||||
} else if c.contains("c corp") || c.contains("c-corp") || c.contains("c_corp") {
|
.collect();
|
||||||
Some(ledger_core::TaxForm::Form1120)
|
let has = |w: &str| words.contains(&w);
|
||||||
} else if c.contains("sole") || c.contains("disregarded") {
|
let mut found = Vec::new();
|
||||||
Some(ledger_core::TaxForm::ScheduleC)
|
if has("partnership") {
|
||||||
} else {
|
found.push(TaxForm::Form1065);
|
||||||
None
|
}
|
||||||
|
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)
|
Ok(share)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parse `"MM-DD"`. February 29 is rejected: a fiscal year must start on a
|
/// Parse `"MM-DD"`: the first day of a month (a fiscal year ends on the
|
||||||
/// day every year has.
|
/// 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> {
|
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 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) = s.split_once('-').ok_or_else(bad)?;
|
||||||
let (m, d): (u32, u32) = (m.parse().map_err(|_| bad())?, d.parse().map_err(|_| 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.
|
if !(1..=12).contains(&m) {
|
||||||
NaiveDate::from_ymd_opt(2001, m, d)
|
return Err(bad());
|
||||||
.ok_or_else(|| format!("fiscal_year_start {s:?} is not a day every year has"))?;
|
}
|
||||||
|
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))
|
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.
|
/// 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> {
|
pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
||||||
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
|
let raw: BooksToml = toml::from_str(text).map_err(|e| e.to_string())?;
|
||||||
let mut company = Company::default();
|
let mut company = Company::default();
|
||||||
@@ -218,6 +346,30 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
|||||||
.filter(|d| !d.is_empty()),
|
.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() {
|
if !company.members.is_empty() {
|
||||||
let total: Decimal = company.members.iter().map(|m| m.share).sum();
|
let total: Decimal = company.members.iter().map(|m| m.share).sum();
|
||||||
if total != Decimal::ONE {
|
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(|| {
|
(Some(f), _) => Some(ledger_core::TaxForm::parse(f).ok_or_else(|| {
|
||||||
format!("tax_form {f:?}: expected schedule_c, 1065, 1120s, or 1120")
|
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,
|
(None, None) => None,
|
||||||
};
|
};
|
||||||
if !company.members.is_empty() && raw.books.close_into.is_some() {
|
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(),
|
.to_string(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
company.close_into = raw.books.close_into.map(|c| c.trim().to_string());
|
company.close_into = match raw.books.close_into {
|
||||||
company.close_sweep = raw.books.close_sweep;
|
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 {
|
if let Some(basis) = raw.books.accounting_basis {
|
||||||
company.basis = match basis.trim().to_lowercase().as_str() {
|
company.basis = match basis.trim().to_lowercase().as_str() {
|
||||||
"accrual" => "Accrual".to_string(),
|
"accrual" => "Accrual".to_string(),
|
||||||
@@ -253,9 +412,11 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
|||||||
}
|
}
|
||||||
let receipt_threshold = match raw.books.receipt_threshold {
|
let receipt_threshold = match raw.books.receipt_threshold {
|
||||||
Some(t) => {
|
Some(t) => {
|
||||||
t.trim()
|
let v = decimal(t.trim())
|
||||||
.parse::<Decimal>()
|
.ok_or_else(|| format!("receipt_threshold {t:?} is not a decimal"))?;
|
||||||
.map_err(|_| 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())
|
Some(t.trim().to_string())
|
||||||
}
|
}
|
||||||
None => None,
|
None => None,
|
||||||
@@ -280,9 +441,9 @@ pub fn parse(text: &str) -> Result<BooksSettings, String> {
|
|||||||
Ok(BooksSettings {
|
Ok(BooksSettings {
|
||||||
company,
|
company,
|
||||||
receipt_threshold,
|
receipt_threshold,
|
||||||
cash_accounts: raw.books.cash_accounts,
|
cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?,
|
||||||
financing_accounts: raw.books.financing_accounts,
|
financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?,
|
||||||
investing_accounts: raw.books.investing_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));
|
assert_eq!((z.revenue, z.expenses), (Decimal::ZERO, Decimal::ZERO));
|
||||||
// None counts every line: identical to the account-based P&L.
|
// None counts every line: identical to the account-based P&L.
|
||||||
let all = crate::reports::profit_and_loss_filtered(&ledger, None).unwrap();
|
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), (plain.revenue, plain.expenses));
|
||||||
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000)));
|
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[1].profit_and_loss.net_income(), d(40_000));
|
||||||
assert_eq!(rows[2].profit_and_loss.net_income(), d(15_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.
|
// 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 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();
|
let exp: Decimal = rows.iter().map(|r| r.profit_and_loss.expenses).sum();
|
||||||
assert_eq!((rev, exp), (total.revenue, total.expenses));
|
assert_eq!((rev, exp), (total.revenue, total.expenses));
|
||||||
|
|||||||
@@ -12,7 +12,7 @@
|
|||||||
//! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`,
|
//! * All mutations go through [`Ledger`] commands (`create_entry`, `add_line`,
|
||||||
//! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections
|
//! `post_entry`, `reverse_entry`). Posted entries are immutable; corrections
|
||||||
//! are reversing entries.
|
//! 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
|
//! pure functions of a ledger, which is what makes the semantic oracle
|
||||||
//! (`docs/oracle.md`) possible.
|
//! (`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
|
/// Revenue and expense totals: from [`unclosed_earnings`], what the books
|
||||||
/// date range, with a line per account, use [`profit_and_loss_for_period`].
|
/// 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)]
|
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||||
pub struct ProfitAndLoss {
|
pub struct ProfitAndLoss {
|
||||||
/// Total revenue (credit-normal balances, reported positive).
|
/// Total revenue (credit-normal balances, reported positive).
|
||||||
@@ -143,15 +144,19 @@ impl ProfitAndLoss {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the profit and loss statement from all posted entries.
|
/// Earnings not yet closed: revenue and expenses over every posted entry,
|
||||||
pub fn profit_and_loss(ledger: &Ledger) -> Result<ProfitAndLoss, LedgerError> {
|
/// 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)
|
profit_and_loss_filtered(ledger, None)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the profit and loss statement, optionally filtered to one class.
|
/// Build the profit and loss statement, optionally filtered to one class.
|
||||||
///
|
///
|
||||||
/// With `None` every line counts and the result is identical to
|
/// 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
|
/// class count. Unlike the account-at-a-time accumulation this works line
|
||||||
/// by line, because classes live on lines: each line contributes its signed
|
/// 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
|
/// 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.
|
/// sorted by class name; the unclassified row comes last.
|
||||||
///
|
///
|
||||||
/// The rows partition the ledger: summing every row's revenue and expenses
|
/// The rows partition the ledger: summing every row's revenue and expenses
|
||||||
/// reproduces [`profit_and_loss`].
|
/// reproduces [`unclosed_earnings`].
|
||||||
///
|
///
|
||||||
/// ```text
|
/// ```text
|
||||||
/// GOAL: a profit and loss per class (department, location, job), plus the
|
/// 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 => {}
|
AccountType::Revenue | AccountType::Expense => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let current_earnings = profit_and_loss(ledger)?.net_income();
|
let current_earnings = unclosed_earnings(ledger)?.net_income();
|
||||||
equity += current_earnings;
|
equity += current_earnings;
|
||||||
Ok(BalanceSheet {
|
Ok(BalanceSheet {
|
||||||
assets,
|
assets,
|
||||||
@@ -1031,7 +1036,7 @@ pub fn members_equity(
|
|||||||
/// ```
|
/// ```
|
||||||
pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
|
pub fn snapshot(ledger: &Ledger) -> Result<serde_json::Value, LedgerError> {
|
||||||
let tb = trial_balance(ledger);
|
let tb = trial_balance(ledger);
|
||||||
let pl = profit_and_loss(ledger)?;
|
let pl = unclosed_earnings(ledger)?;
|
||||||
let bs = balance_sheet(ledger)?;
|
let bs = balance_sheet(ledger)?;
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
"trial_balance": {
|
"trial_balance": {
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
//! semantic oracle (see `docs/oracle.md`).
|
//! semantic oracle (see `docs/oracle.md`).
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
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::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
|
AccountId, AccountType, ChartOfAccounts, Currency, EntryStatus, JournalEntry, Ledger,
|
||||||
LedgerError, Money, Side,
|
LedgerError, Money, Side,
|
||||||
@@ -565,7 +565,7 @@ fn accounting_equation_holds() {
|
|||||||
#[test]
|
#[test]
|
||||||
fn profit_and_loss_is_correct() {
|
fn profit_and_loss_is_correct() {
|
||||||
let c = fixture_company();
|
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.revenue, Decimal::from(4_000));
|
||||||
assert_eq!(pl.expenses, Decimal::from(800));
|
assert_eq!(pl.expenses, Decimal::from(800));
|
||||||
assert_eq!(pl.net_income(), Decimal::from(3_200));
|
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,
|
AccountType, Currency, Ledger, Money, NewAsset, Side, accumulated_depreciation, book_value,
|
||||||
depreciation_schedule,
|
depreciation_schedule,
|
||||||
reports::{
|
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, trial_balance,
|
||||||
trial_balance,
|
unclosed_earnings,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
use proptest::prelude::*;
|
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));
|
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_revenue + r2, whole.total_revenue);
|
||||||
prop_assert_eq!(first.total_expenses + e2, whole.total_expenses);
|
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));
|
prop_assert_eq!((whole.total_revenue, whole.total_expenses), (all.revenue, all.expenses));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
use std::collections::{BTreeMap, BTreeSet};
|
use std::collections::{BTreeMap, BTreeSet};
|
||||||
|
|
||||||
use chrono::{Days, NaiveDate};
|
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::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, BalanceSheetStatement, CashFlowParams, CashFlowStatement, ClassId,
|
AccountId, AccountType, BalanceSheetStatement, CashFlowParams, CashFlowStatement, ClassId,
|
||||||
CloseTo, Currency, EntryId, GeneralLedger, Ledger, MemberCapital, MemberClose,
|
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)),
|
(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.revenue, c(15000));
|
||||||
assert_eq!(whole.expenses, c(5000));
|
assert_eq!(whole.expenses, c(5000));
|
||||||
}
|
}
|
||||||
@@ -2134,7 +2134,7 @@ proptest! {
|
|||||||
fn prop_pnl_by_class_rows_sum_to_whole(gens in arb_books()) {
|
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 b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true);
|
||||||
let rows = profit_and_loss_by_class(&b.l).unwrap();
|
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.revenue).sum::<Decimal>(), whole.revenue);
|
||||||
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses);
|
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));
|
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);
|
let mut b = build_ordered(&gens, 1, &(0..N).collect::<Vec<_>>(), true);
|
||||||
close_through(&mut b, Some(month), &re_close());
|
close_through(&mut b, Some(month), &re_close());
|
||||||
let rows = profit_and_loss_by_class(&b.l).unwrap();
|
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.revenue).sum::<Decimal>(), whole.revenue);
|
||||||
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses);
|
prop_assert_eq!(rows.iter().map(|r| r.profit_and_loss.expenses).sum::<Decimal>(), whole.expenses);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::reports::{
|
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::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, CashFlowParams, ClassId, Currency, Ledger, LedgerError, MemberCapital,
|
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))
|
(dec(3_500), dec(920))
|
||||||
);
|
);
|
||||||
// ... which is the life-of-ledger P&L, because nothing is outside it.
|
// ... 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)));
|
assert_eq!((all.revenue, all.expenses), (dec(3_500), dec(920)));
|
||||||
|
|
||||||
// One class: only the tagged software line.
|
// 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 ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
|
||||||
use rust_decimal::Decimal;
|
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> {
|
pub fn find(b: &Books) -> Vec<Attention> {
|
||||||
let mut out = Vec::new();
|
let mut out = Vec::new();
|
||||||
let today = b.today;
|
let today = b.today;
|
||||||
@@ -49,9 +73,9 @@ pub fn find(b: &Books) -> Vec<Attention> {
|
|||||||
.collect();
|
.collect();
|
||||||
out.push(Attention {
|
out.push(Attention {
|
||||||
severity: Severity::Problem,
|
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!(
|
detail: format!(
|
||||||
"{} of spending has no receipt attached ({}).",
|
"{} in entries with no receipt attached ({}).",
|
||||||
fmt::dollars(total),
|
fmt::dollars(total),
|
||||||
b.receipt_rule()
|
b.receipt_rule()
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ pub struct EntryInfo {
|
|||||||
pub vendor: Option<String>,
|
pub vendor: Option<String>,
|
||||||
pub reversal_of: Option<EntryId>,
|
pub reversal_of: Option<EntryId>,
|
||||||
pub reversed_by: 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,
|
pub missing_receipt: bool,
|
||||||
/// Net effect on cash accounts: positive in, negative out, zero if the
|
/// Net effect on cash accounts: positive in, negative out, zero if the
|
||||||
/// entry doesn't touch cash.
|
/// entry doesn't touch cash.
|
||||||
@@ -162,11 +162,13 @@ impl Books {
|
|||||||
let ledger = snap.ledger().clone();
|
let ledger = snap.ledger().clone();
|
||||||
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
let attachments = snap.attachments(None).map_err(|e| e.to_string())?;
|
||||||
let journal = snap.journal().to_vec();
|
let journal = snap.journal().to_vec();
|
||||||
let receipt_threshold = settings
|
// The server's own rule (config.json, else wanderbooks.toml), so
|
||||||
.receipt_threshold
|
// the viewer flags exactly what the server would refuse.
|
||||||
.as_deref()
|
let policy = ledger_config::effective_receipt_policy(
|
||||||
.and_then(|t| t.parse().ok())
|
&snap.data_dir().join("config.json"),
|
||||||
.unwrap_or(Decimal::new(25, 0));
|
&settings,
|
||||||
|
)?;
|
||||||
|
let receipt_threshold = policy.threshold();
|
||||||
let cash_accounts = cash_accounts(&ledger, &settings);
|
let cash_accounts = cash_accounts(&ledger, &settings);
|
||||||
let codes = |cs: &[String]| -> Vec<AccountId> {
|
let codes = |cs: &[String]| -> Vec<AccountId> {
|
||||||
cs.iter()
|
cs.iter()
|
||||||
@@ -238,23 +240,14 @@ impl Books {
|
|||||||
.entry_vendor(id)
|
.entry_vendor(id)
|
||||||
.and_then(|v| ledger.get_vendor(v))
|
.and_then(|v| ledger.get_vendor(v))
|
||||||
.map(|v| v.name.clone());
|
.map(|v| v.name.clone());
|
||||||
// Receipts prove spending: hand-made entries (and recurring
|
// The receipt policy covers what is posted by hand (journal
|
||||||
// drafts) that debit an expense or buy an asset from outside.
|
// entries and recurring drafts); invoices, payments, closes,
|
||||||
// Invoices, payments, transfers, draws, closes, and schedules
|
// depreciation, and schedules carry their own evidence, and a
|
||||||
// carry their own evidence.
|
// reversal leans on its original's.
|
||||||
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"))
|
|
||||||
})
|
|
||||||
});
|
|
||||||
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
|
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
|
||||||
&& spends
|
&& e.reversal_of().is_none()
|
||||||
&& rec.is_empty()
|
&& rec.is_empty()
|
||||||
&& amount > receipt_threshold;
|
&& policy.receipt_required(&ledger, e);
|
||||||
let cash_effect: Decimal = e
|
let cash_effect: Decimal = e
|
||||||
.lines()
|
.lines()
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -15,6 +15,13 @@ fn f(d: Decimal) -> f64 {
|
|||||||
d.try_into().unwrap_or(0.0)
|
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].
|
/// "Nice" axis ticks covering [lo, hi].
|
||||||
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
|
fn ticks(lo: f64, hi: f64, n: usize) -> Vec<f64> {
|
||||||
let span = (hi - lo).max(1e-9);
|
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.
|
/// `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 {
|
pub fn file_name(name: &str, ext: &str) -> String {
|
||||||
let clean: String = name
|
let clean: String = name
|
||||||
.chars()
|
.chars()
|
||||||
@@ -29,7 +46,54 @@ pub fn file_name(name: &str, ext: &str) -> String {
|
|||||||
c => c,
|
c => c,
|
||||||
})
|
})
|
||||||
.collect();
|
.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
|
/// 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
|
//! Formatting money, dates, and counts. Amounts are exact decimals in and
|
||||||
//! exact strings out: nothing here rounds except `compact`, which says so.
|
//! 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 chrono::NaiveDate;
|
||||||
use rust_decimal::Decimal;
|
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 {
|
pub fn money(d: Decimal) -> String {
|
||||||
let neg = d.is_sign_negative() && !d.is_zero();
|
let s = group(d.abs());
|
||||||
let s = group(d.abs().round_dp(2));
|
if is_negative(d) {
|
||||||
if neg { format!("\u{2212}{s}") } else { s }
|
format!("\u{2212}{s}")
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Accounting style: negatives in parentheses, `(1,234.56)`.
|
/// Accounting style: negatives in parentheses, `(1,234.56)`.
|
||||||
pub fn accounting(d: Decimal) -> String {
|
pub fn accounting(d: Decimal) -> String {
|
||||||
if d.is_sign_negative() && !d.is_zero() {
|
let s = group(d.abs());
|
||||||
format!("({})", group(d.abs().round_dp(2)))
|
if is_negative(d) { format!("({s})") } else { s }
|
||||||
} else {
|
|
||||||
group(d.round_dp(2))
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// With the currency sign: `$1,234.56`, `−$12.00`.
|
/// With the currency sign: `$1,234.56`, `−$12.00`.
|
||||||
pub fn dollars(d: Decimal) -> String {
|
pub fn dollars(d: Decimal) -> String {
|
||||||
let neg = d.is_sign_negative() && !d.is_zero();
|
let s = format!("${}", group(d.abs()));
|
||||||
let s = format!("${}", group(d.abs().round_dp(2)));
|
if is_negative(d) {
|
||||||
if neg { format!("\u{2212}{s}") } else { s }
|
format!("\u{2212}{s}")
|
||||||
|
} else {
|
||||||
|
s
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
|
/// Rounded for chart axes and tight tiles: `$1.2k`, `$3.4M`, `$950`.
|
||||||
pub fn compact(d: Decimal) -> String {
|
pub fn compact(d: Decimal) -> String {
|
||||||
let f: f64 = d.try_into().unwrap_or(0.0);
|
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 {
|
let s = if a >= 1_000_000.0 {
|
||||||
format!("${:.1}M", a / 1_000_000.0)
|
format!("${:.1}M", a / 1_000_000.0)
|
||||||
} else if a >= 10_000.0 {
|
} else if a >= 10_000.0 {
|
||||||
@@ -40,11 +60,22 @@ pub fn compact(d: Decimal) -> String {
|
|||||||
} else {
|
} else {
|
||||||
format!("${a:.0}")
|
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 {
|
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 (int, frac) = s.split_once('.').unwrap_or((&s, "00"));
|
||||||
let mut out = String::new();
|
let mut out = String::new();
|
||||||
for (i, c) in int.chars().enumerate() {
|
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")), "0.00");
|
||||||
assert_eq!(money(dec("-0.00")), "0.00");
|
assert_eq!(money(dec("-0.00")), "0.00");
|
||||||
assert_eq!(accounting(dec("-207.68")), "(207.68)");
|
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("1234")), "$1.2k");
|
||||||
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
|
assert_eq!(compact(dec("-25000")), "\u{2212}$25k");
|
||||||
assert_eq!(compact(dec("950.4")), "$950");
|
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.
|
/// Back/forward history.
|
||||||
pub struct Nav {
|
pub struct Nav {
|
||||||
back: Vec<Route>,
|
back: Vec<Route>,
|
||||||
|
|||||||
@@ -148,6 +148,18 @@ impl Palette {
|
|||||||
|
|
||||||
/// Subsequence match: every query character in order, rewarding
|
/// Subsequence match: every query character in order, rewarding
|
||||||
/// contiguous runs and word starts. None if no match.
|
/// 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> {
|
fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||||||
if query.is_empty() {
|
if query.is_empty() {
|
||||||
return Some(0);
|
return Some(0);
|
||||||
|
|||||||
@@ -26,9 +26,13 @@ serde = { version = "1", features = ["derive"] }
|
|||||||
serde_json = "1"
|
serde_json = "1"
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
thiserror = "2"
|
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"
|
zip = "9.0.1"
|
||||||
toml = "1"
|
toml = "1"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
workspace = true
|
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 policy;
|
||||||
mod server;
|
mod server;
|
||||||
mod types;
|
mod types;
|
||||||
|
mod wire;
|
||||||
|
|
||||||
use ledger_core::Currency;
|
use ledger_core::Currency;
|
||||||
use ledger_store::Store;
|
use ledger_store::Store;
|
||||||
use policy::ReceiptPolicy;
|
use policy::ReceiptPolicy;
|
||||||
use rmcp::{ServiceExt, transport::stdio};
|
use rmcp::ServiceExt;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use server::BooksServer;
|
use server::BooksServer;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
@@ -28,10 +29,7 @@ struct Config {
|
|||||||
/// ISO 4217 base currency for a new store. Default "USD".
|
/// ISO 4217 base currency for a new store. Default "USD".
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
base_currency: Option<String>,
|
base_currency: Option<String>,
|
||||||
/// Receipt policy. Overrides `wanderbooks.toml`'s receipt_threshold;
|
// `receipt_policy` is read by ledger_config::effective_receipt_policy.
|
||||||
/// with neither, receipts are required above $25.00.
|
|
||||||
#[serde(default)]
|
|
||||||
receipt_policy: Option<ReceiptPolicy>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn default_data_dir() -> PathBuf {
|
fn default_data_dir() -> PathBuf {
|
||||||
@@ -105,14 +103,7 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
eprintln!("ledger-mcp: company {:?}", settings.company.name);
|
eprintln!("ledger-mcp: company {:?}", settings.company.name);
|
||||||
let policy = match (config.receipt_policy, settings.receipt_threshold) {
|
let policy = company::effective_receipt_policy(&config_path, &settings)?;
|
||||||
(Some(policy), _) => policy,
|
|
||||||
(None, Some(threshold)) => ReceiptPolicy {
|
|
||||||
require_receipts_above: threshold,
|
|
||||||
..ReceiptPolicy::default()
|
|
||||||
},
|
|
||||||
(None, None) => ReceiptPolicy::default(),
|
|
||||||
};
|
|
||||||
let roles = server::CashRoles {
|
let roles = server::CashRoles {
|
||||||
cash: settings.cash_accounts,
|
cash: settings.cash_accounts,
|
||||||
financing: settings.financing_accounts,
|
financing: settings.financing_accounts,
|
||||||
@@ -152,8 +143,11 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
.with_company(company)
|
.with_company(company)
|
||||||
.with_cash_roles(roles);
|
.with_cash_roles(roles);
|
||||||
rt.block_on(async {
|
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 _ = running.waiting().await;
|
||||||
|
let _ = writer.await;
|
||||||
Ok::<(), Box<dyn std::error::Error>>(())
|
Ok::<(), Box<dyn std::error::Error>>(())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,192 +1,4 @@
|
|||||||
//! The receipt policy: server-side enforcement, not prose.
|
//! The receipt policy lives in `ledger-config` (shared with the viewer, so
|
||||||
//!
|
//! both read one rule); re-exported here.
|
||||||
//! 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.
|
|
||||||
|
|
||||||
use ledger_core::{JournalEntry, Ledger, Side};
|
pub use ledger_config::ReceiptPolicy;
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+102
-42
@@ -1,5 +1,34 @@
|
|||||||
//! The MCP server: narrow typed tools over the store, with the receipt
|
//! The MCP server: narrow typed tools over the store, with the receipt
|
||||||
//! policy enforced server-side.
|
//! 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::policy::ReceiptPolicy;
|
||||||
use crate::types::*;
|
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"))
|
.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> {
|
fn parse_amount(s: &str) -> Result<Decimal, String> {
|
||||||
s.parse::<Decimal>()
|
ledger_config::decimal(s)
|
||||||
.map_err(|_| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
|
.ok_or_else(|| format!("bad amount {s:?}: expected a decimal string like \"123.45\""))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn parse_side(s: &str) -> Result<Side, String> {
|
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> {
|
fn resolve_account(store: &Store, account: &str) -> Result<AccountId, String> {
|
||||||
if let Ok(id) = account.parse::<u64>()
|
let chart = store.ledger().chart();
|
||||||
&& store.ledger().chart().get(id).is_some()
|
let by_code = chart.find_by_code(account).map(|a| a.id);
|
||||||
{
|
let by_id = account
|
||||||
return Ok(id);
|
.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> {
|
fn resolve_vendor(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||||
if let Ok(id) = vendor.parse::<u64>()
|
let by_id = vendor
|
||||||
&& store.ledger().get_vendor(id).is_some()
|
.parse::<u64>()
|
||||||
{
|
.ok()
|
||||||
return Ok(id);
|
.filter(|id| store.ledger().get_vendor(*id).is_some());
|
||||||
}
|
|
||||||
let matches: Vec<u64> = store
|
let matches: Vec<u64> = store
|
||||||
.ledger()
|
.ledger()
|
||||||
.list_vendors()
|
.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()))
|
.filter(|v| v.name.eq_ignore_ascii_case(vendor.trim()))
|
||||||
.map(|v| v.id)
|
.map(|v| v.id)
|
||||||
.collect();
|
.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() {
|
match matches.as_slice() {
|
||||||
[id] => Ok(*id),
|
[id] => Ok(*id),
|
||||||
[] => Err(format!(
|
[] => Err(format!(
|
||||||
@@ -420,19 +466,23 @@ fn form_1099_label(form: Option<ledger_core::Form1099>) -> Option<String> {
|
|||||||
form.map(|f| f.label().to_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> {
|
fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||||
if let Ok(id) = class.parse::<u32>()
|
let by_id = class
|
||||||
&& store.ledger().get_class(id).is_some()
|
.parse::<u32>()
|
||||||
{
|
.ok()
|
||||||
return Ok(id);
|
.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",
|
/// 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 mut store = self.lock()?;
|
||||||
let account = resolve_account(&store, &p.account)?;
|
let account = resolve_account(&store, &p.account)?;
|
||||||
let side = parse_side(&p.side)?;
|
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?
|
// Foreign-currency line?
|
||||||
if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() {
|
if p.foreign_currency.is_some() || p.foreign_amount.is_some() || p.fx_rate.is_some() {
|
||||||
let fc = p
|
let fc = p
|
||||||
@@ -949,8 +1004,7 @@ impl BooksServer {
|
|||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
}
|
}
|
||||||
// Class tag, if requested. The line just added is the last one.
|
// Class tag, if requested. The line just added is the last one.
|
||||||
if let Some(class_ref) = p.class {
|
if let Some(class_id) = class_id {
|
||||||
let class_id = resolve_class(&store, &class_ref)?;
|
|
||||||
let line_index = store
|
let line_index = store
|
||||||
.ledger()
|
.ledger()
|
||||||
.get_entry(p.entry_id)
|
.get_entry(p.entry_id)
|
||||||
@@ -3362,11 +3416,11 @@ impl BooksServer {
|
|||||||
let total = r.total().to_string();
|
let total = r.total().to_string();
|
||||||
AgingRowOut {
|
AgingRowOut {
|
||||||
name: r.name,
|
name: r.name,
|
||||||
current: r.current.to_string(),
|
current: money(r.current),
|
||||||
days_30: r.days_30.to_string(),
|
days_30: money(r.days_30),
|
||||||
days_60: r.days_60.to_string(),
|
days_60: money(r.days_60),
|
||||||
days_90: r.days_90.to_string(),
|
days_90: money(r.days_90),
|
||||||
days_90_plus: r.days_90_plus.to_string(),
|
days_90_plus: money(r.days_90_plus),
|
||||||
total,
|
total,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -3389,11 +3443,11 @@ impl BooksServer {
|
|||||||
let total = r.total().to_string();
|
let total = r.total().to_string();
|
||||||
AgingRowOut {
|
AgingRowOut {
|
||||||
name: r.name,
|
name: r.name,
|
||||||
current: r.current.to_string(),
|
current: money(r.current),
|
||||||
days_30: r.days_30.to_string(),
|
days_30: money(r.days_30),
|
||||||
days_60: r.days_60.to_string(),
|
days_60: money(r.days_60),
|
||||||
days_90: r.days_90.to_string(),
|
days_90: money(r.days_90),
|
||||||
days_90_plus: r.days_90_plus.to_string(),
|
days_90_plus: money(r.days_90_plus),
|
||||||
total,
|
total,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -3584,6 +3638,11 @@ impl BooksServer {
|
|||||||
if end < start {
|
if end < start {
|
||||||
return Err("end is before start".to_string());
|
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.
|
// Per-class P&L over the date range: accumulate line by line.
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
#[derive(Default)]
|
#[derive(Default)]
|
||||||
@@ -3621,6 +3680,7 @@ impl BooksServer {
|
|||||||
// Sort: named classes by name, unclassified last.
|
// Sort: named classes by name, unclassified last.
|
||||||
let mut rows: Vec<(Option<String>, Acc)> = by_class
|
let mut rows: Vec<(Option<String>, Acc)> = by_class
|
||||||
.into_iter()
|
.into_iter()
|
||||||
|
.filter(|(cid, _)| only.is_none() || *cid == only)
|
||||||
.map(|(cid, acc)| {
|
.map(|(cid, acc)| {
|
||||||
let name = cid
|
let name = cid
|
||||||
.and_then(|id| ledger.get_class(id))
|
.and_then(|id| ledger.get_class(id))
|
||||||
|
|||||||
@@ -566,9 +566,9 @@ pub struct TrialBalanceLineOut {
|
|||||||
pub code: String,
|
pub code: String,
|
||||||
/// Account name.
|
/// Account name.
|
||||||
pub name: String,
|
pub name: String,
|
||||||
/// Debit balance as a decimal string, or "0".
|
/// Debit balance, decimal string ("0.00" when none).
|
||||||
pub debit: String,
|
pub debit: String,
|
||||||
/// Credit balance as a decimal string, or "0".
|
/// Credit balance, decimal string ("0.00" when none).
|
||||||
pub credit: String,
|
pub credit: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1570,7 +1570,7 @@ pub struct DisposeAssetOut {
|
|||||||
/// Gain (positive) or loss (negative), decimal string.
|
/// Gain (positive) or loss (negative), decimal string.
|
||||||
pub gain_loss: String,
|
pub gain_loss: String,
|
||||||
/// Depreciation auto-posted for the final partial period, decimal 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,
|
pub final_depreciation: String,
|
||||||
/// The disposal entry's lines with resulting balances.
|
/// The disposal entry's lines with resulting balances.
|
||||||
pub lines: Vec<PreviewLine>,
|
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
|
makes the ledger auditable and what will make the future oracle harness
|
||||||
trustworthy — the harness only needs to drive the command surface.
|
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
|
are free functions: pure, total, and deterministic. Same ledger in, same
|
||||||
report out, every time, on every platform. That determinism is the entire
|
report out, every time, on every platform. That determinism is the entire
|
||||||
basis of `docs/oracle.md`.
|
basis of `docs/oracle.md`.
|
||||||
@@ -31,7 +31,7 @@ crates/ledger-core/
|
|||||||
account.rs # AccountType, Side, Account, ChartOfAccounts
|
account.rs # AccountType, Side, Account, ChartOfAccounts
|
||||||
journal.rs # JournalLine, JournalEntry, validate()
|
journal.rs # JournalLine, JournalEntry, validate()
|
||||||
ledger.rs # Ledger: the command surface, append-only log
|
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/
|
tests/
|
||||||
invariants.rs # accounting-law tests + randomized property test
|
invariants.rs # accounting-law tests + randomized property test
|
||||||
golden/ # hand-verified expected report snapshots
|
golden/ # hand-verified expected report snapshots
|
||||||
|
|||||||
+10
-10
@@ -85,14 +85,13 @@ below.
|
|||||||
- `books.draft_entry { date: "2026-10-10", description: "..." }`
|
- `books.draft_entry { date: "2026-10-10", description: "..." }`
|
||||||
→ `{ entry_id, status: "draft" }`.
|
→ `{ entry_id, status: "draft" }`.
|
||||||
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
|
- `books.add_line { entry_id, account: "1000" | <id>, side: "debit" | "credit",
|
||||||
amount: "123.45" }`
|
amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or
|
||||||
→ `{ lines: [...], totals: { debits, credits, balanced } }`. Running totals
|
class, bad amount) adds nothing.
|
||||||
after every line: the agent watches the entry balance in real time.
|
- `books.post_entry { entry_id, confirmed }` → `{ status, entry_id, date,
|
||||||
- `books.post_entry { entry_id, confirmed }` → without confirmation:
|
description, total, lines (with resulting balances), receipts,
|
||||||
`{ preview: { lines, resulting_balances, receipts: { attached: [...],
|
receipt_required, receipt_missing, split_detected }`. Without
|
||||||
required_but_missing: bool } } }`; with confirmation: validates (including
|
`confirmed: true`, `status` is `"preview"` and nothing changes; with it,
|
||||||
the receipt policy), posts, returns `{ entry, balances_changed: [{ code,
|
the receipt policy is enforced and `status` is `"posted"`.
|
||||||
before, after }] }`.
|
|
||||||
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
- `books.reverse_entry { entry_id, date, description, confirmed }` → same
|
||||||
preview/confirm pattern as post.
|
preview/confirm pattern as post.
|
||||||
- `books.get_entry { entry_id }` → full entry with lines.
|
- `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.
|
- `books.add_line` gains optional `class` (name or id): tags the new line.
|
||||||
Shared costs are split explicitly across lines at entry time.
|
Shared costs are split explicitly across lines at entry time.
|
||||||
- `books.profit_and_loss` gains optional `class` filter.
|
- `books.profit_and_loss` gains optional `class` filter.
|
||||||
- `books.profit_and_loss_by_class { start, end }` → per-class revenue,
|
- `books.profit_and_loss_by_class { start, end, class? }` → per-class revenue,
|
||||||
expenses, net income, plus an unclassified row. Reversals carry their
|
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.
|
class so divisional P&L nets correctly.
|
||||||
|
|
||||||
## Tools — CPA handoff
|
## 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 |
|
| 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 |
|
| 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 |
|
| 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 |
|
| 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_...` |
|
| 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",
|
"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]]
|
[[package]]
|
||||||
name = "ledger-core"
|
name = "ledger-core"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@@ -1430,6 +1441,7 @@ version = "0.0.0"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"arbitrary",
|
"arbitrary",
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"ledger-config",
|
||||||
"ledger-core",
|
"ledger-core",
|
||||||
"ledger-import",
|
"ledger-import",
|
||||||
"ledger-store",
|
"ledger-store",
|
||||||
@@ -2337,6 +2349,15 @@ dependencies = [
|
|||||||
"serde",
|
"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]]
|
[[package]]
|
||||||
name = "serde_yaml"
|
name = "serde_yaml"
|
||||||
version = "0.9.34+deprecated"
|
version = "0.9.34+deprecated"
|
||||||
@@ -2656,11 +2677,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.11",
|
"toml_datetime 0.6.11",
|
||||||
"toml_edit 0.22.27",
|
"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]]
|
[[package]]
|
||||||
name = "toml_datetime"
|
name = "toml_datetime"
|
||||||
version = "0.6.11"
|
version = "0.6.11"
|
||||||
@@ -2687,7 +2723,7 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"indexmap",
|
"indexmap",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_spanned",
|
"serde_spanned 0.6.9",
|
||||||
"toml_datetime 0.6.11",
|
"toml_datetime 0.6.11",
|
||||||
"toml_write",
|
"toml_write",
|
||||||
"winnow 0.7.15",
|
"winnow 0.7.15",
|
||||||
@@ -2720,6 +2756,12 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
|
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]]
|
[[package]]
|
||||||
name = "ttf-parser"
|
name = "ttf-parser"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@@ -2807,7 +2849,7 @@ dependencies = [
|
|||||||
"indexmap",
|
"indexmap",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"stacker",
|
"stacker",
|
||||||
"toml",
|
"toml 0.8.23",
|
||||||
"typst-library",
|
"typst-library",
|
||||||
"typst-macros",
|
"typst-macros",
|
||||||
"typst-syntax",
|
"typst-syntax",
|
||||||
@@ -2923,7 +2965,7 @@ dependencies = [
|
|||||||
"smallvec",
|
"smallvec",
|
||||||
"syntect",
|
"syntect",
|
||||||
"time",
|
"time",
|
||||||
"toml",
|
"toml 0.8.23",
|
||||||
"ttf-parser",
|
"ttf-parser",
|
||||||
"two-face",
|
"two-face",
|
||||||
"typed-arena",
|
"typed-arena",
|
||||||
@@ -3038,7 +3080,7 @@ dependencies = [
|
|||||||
"ecow",
|
"ecow",
|
||||||
"rustc-hash",
|
"rustc-hash",
|
||||||
"serde",
|
"serde",
|
||||||
"toml",
|
"toml 0.8.23",
|
||||||
"typst-timing",
|
"typst-timing",
|
||||||
"typst-utils",
|
"typst-utils",
|
||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
|
|||||||
@@ -24,6 +24,9 @@ path = "../crates/ledger-store"
|
|||||||
[dependencies.ledger-import]
|
[dependencies.ledger-import]
|
||||||
path = "../crates/ledger-import"
|
path = "../crates/ledger-import"
|
||||||
|
|
||||||
|
[dependencies.ledger-config]
|
||||||
|
path = "../crates/ledger-config"
|
||||||
|
|
||||||
[[bin]]
|
[[bin]]
|
||||||
name = "fuzz_journal_line"
|
name = "fuzz_journal_line"
|
||||||
path = "fuzz_targets/fuzz_journal_line.rs"
|
path = "fuzz_targets/fuzz_journal_line.rs"
|
||||||
@@ -74,3 +77,10 @@ path = "fuzz_targets/fuzz_import_csv.rs"
|
|||||||
test = false
|
test = false
|
||||||
doc = false
|
doc = false
|
||||||
bench = 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