diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 231444f..0094a6b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -3,8 +3,11 @@ # program linked on a newer one does not start on an older one), attach # them to the GitHub release, and add them to the apt and dnf repositories # on the gh-pages branch (GitHub Pages serves them). Unsigned: step 5 of -# the unit (the release key, Q-4) signs the manifest and the indexes, and -# step 6 adds the macOS and Windows jobs. The release holds no key today. +# the unit (the release key, Q-4) signs the manifest and the indexes. The +# macOS job builds the .dmg UNSIGNED (no Developer ID, no notarization: +# step 5); the Windows job is unit P11c. The release holds no key today. +# To see the artifacts without a tag: Actions > release > Run workflow, +# pick the branch; each job uploads its files as a run artifact. name: release on: @@ -75,3 +78,46 @@ jobs: git add -A git commit -m "wanderbooks $v" git push origin HEAD:gh-pages + - name: Keep them with the run (a dispatch has no release) + uses: actions/upload-artifact@v4 + with: + name: linux-packages + path: target/packages/${{ steps.version.outputs.version }}/ + + macos: + # UNSIGNED: a .dmg that Gatekeeper refuses until step 5 signs and + # notarizes it. Apple silicon (macos-latest); an Intel build is not + # made yet. + runs-on: macos-latest + env: + CARGO_INCREMENTAL: "0" + steps: + - uses: actions/checkout@v4 + - uses: jdx/mise-action@v2 + with: + install_args: "rust just cargo:cargo-packager" + - name: The tag is the program's version + id: version + run: | + own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1) + tag="${GITHUB_REF_NAME#v}" + if [ "${GITHUB_REF_TYPE}" = tag ] && [ "$tag" != "$own" ]; then + echo "tag $GITHUB_REF_NAME is not version $own" >&2 + exit 1 + fi + echo "version=$own" >> "$GITHUB_OUTPUT" + - name: Build the unsigned .dmg + run: just package-macos ${{ steps.version.outputs.version }} + - name: Attach it to the release + if: github.ref_type == 'tag' + env: + GH_TOKEN: ${{ github.token }} + run: | + v=${{ steps.version.outputs.version }} + gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1 || gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes "wanderbooks $v" + gh release upload "$GITHUB_REF_NAME" --clobber target/packages/$v/*.dmg + - name: Keep it with the run (a dispatch has no release) + uses: actions/upload-artifact@v4 + with: + name: macos-dmg-unsigned + path: target/packages/${{ steps.version.outputs.version }}/*.dmg diff --git a/.gitignore b/.gitignore index 2d2387c..1c7ee59 100644 --- a/.gitignore +++ b/.gitignore @@ -7,4 +7,3 @@ site/dist/ /mutants.out*/ /mutants-*/ # bookspec's own workspace target -/bookspec/target/ diff --git a/AGENTS.md b/AGENTS.md index 8126996..d5f7abf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -199,7 +199,7 @@ clone, with status. Rules: | `docs/receipt-store.md` | Blob store + `ledger-store` SQLite persistence spec | | `docs/testing.md` | Every test layer, each unit's test record, and what P12 must mutate | | `docs/parties-and-attachments.md` | The party directory, roles, the TIN vault, attachments to any subject | -| `bookspec/README.md` | The formal accounting specification's examples, checker and runner (P13) | +| https://git.swaits.com/swaits/bookspec | The formal accounting specification's own repository: README, examples, checker, runner (P13); pinned in `crates/ledger-core/Cargo.toml` | | `deploy/README.md` | Running the books on a machine of your own (P9a) | | `docs/glossary.md` | Debit/credit and friends, for agents new to accounting | | `plan/STATUS.md` | Where the project stands right now | diff --git a/Cargo.lock b/Cargo.lock index c9b5e17..5cd2d97 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -763,8 +763,10 @@ dependencies = [ [[package]] name = "bookspec-checker" version = "0.1.0" +source = "git+https://git.swaits.com/swaits/bookspec?rev=74f2853d5f0db0066147c17fed8033cfe3d83a24#74f2853d5f0db0066147c17fed8033cfe3d83a24" dependencies = [ "chrono", + "include_dir", "rust_decimal", "serde", "serde_json", @@ -774,6 +776,7 @@ dependencies = [ [[package]] name = "bookspec-runner" version = "0.1.0" +source = "git+https://git.swaits.com/swaits/bookspec?rev=74f2853d5f0db0066147c17fed8033cfe3d83a24#74f2853d5f0db0066147c17fed8033cfe3d83a24" dependencies = [ "bookspec-checker", "serde_json", @@ -3451,6 +3454,25 @@ version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09e54e57b4c48b40f7aec75635392b12b3421fa26fe8b4332e63138ed278459c" +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -4039,6 +4061,7 @@ dependencies = [ name = "ledger-package" version = "0.1.0" dependencies = [ + "bookspec-checker", "chrono", "ledger-config", "ledger-core", diff --git a/README.md b/README.md index 9642002..84d20f4 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ crates/ledger-package/ # the shared read-only view: the CPA package, the invoice crates/ledger-vault/ # the TIN vault (XChaCha20-Poly1305 under the book's key) crates/ledger-gui/ # the viewer: read-only, live (egui) verify/lean/ # the accounting laws as Lean theorems (`just prove`) -bookspec/ # the formal accounting specification: checker, runner, examples (P13) +# the formal accounting specification lives in its own repository, https://git.swaits.com/swaits/bookspec (pinned dev-dependency, P13) fuzz/, oracle/ # fuzz targets; the GnuCash differential (`just oracle`) deploy/ # the self-host recipe and system unit (P9a) site/ # wanderbooks.ai and install.sh diff --git a/bookspec/Cargo.lock b/bookspec/Cargo.lock deleted file mode 100644 index 6f77824..0000000 --- a/bookspec/Cargo.lock +++ /dev/null @@ -1,714 +0,0 @@ -# This file is automatically @generated by Cargo. -# It is not intended for manual editing. -version = 4 - -[[package]] -name = "android_system_properties" -version = "0.1.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" -dependencies = [ - "libc", -] - -[[package]] -name = "arrayvec" -version = "0.7.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" - -[[package]] -name = "autocfg" -version = "1.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" - -[[package]] -name = "bookspec-checker" -version = "0.1.0" -dependencies = [ - "chrono", - "rust_decimal", - "serde", - "serde_json", - "toml", -] - -[[package]] -name = "bookspec-gnucash" -version = "0.1.0" -dependencies = [ - "bookspec-checker", - "bookspec-runner", - "rust_decimal", - "serde_json", -] - -[[package]] -name = "bookspec-runner" -version = "0.1.0" -dependencies = [ - "bookspec-checker", - "serde_json", -] - -[[package]] -name = "borsh" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" -dependencies = [ - "borsh-derive", - "bytes", - "cfg_aliases", -] - -[[package]] -name = "borsh-derive" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "12cdfe656708a01f89b451a7d36466e6fe6c414de0aa18fc54f864f6f9ca9f56" -dependencies = [ - "once_cell", - "proc-macro-crate", - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "bumpalo" -version = "3.20.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" - -[[package]] -name = "bytes" -version = "1.12.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" - -[[package]] -name = "cc" -version = "1.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86e055fa6e3e40ad6187ba25b6855eeb635d5271f8e0f0e1e9a5826b8d75bea0" -dependencies = [ - "find-msvc-tools", - "shlex", -] - -[[package]] -name = "cfg-if" -version = "1.0.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" - -[[package]] -name = "cfg_aliases" -version = "0.2.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" - -[[package]] -name = "chrono" -version = "0.4.45" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" -dependencies = [ - "iana-time-zone", - "js-sys", - "num-traits", - "serde", - "wasm-bindgen", - "windows-link", -] - -[[package]] -name = "core-foundation-sys" -version = "0.8.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" - -[[package]] -name = "equivalent" -version = "1.0.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" - -[[package]] -name = "find-msvc-tools" -version = "0.1.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48" - -[[package]] -name = "futures-core" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" - -[[package]] -name = "futures-task" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" - -[[package]] -name = "futures-util" -version = "0.3.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" -dependencies = [ - "futures-core", - "futures-task", - "pin-project-lite", - "slab", -] - -[[package]] -name = "getrandom" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" -dependencies = [ - "cfg-if", - "libc", - "wasi", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi", - "wasip2", -] - -[[package]] -name = "hashbrown" -version = "0.17.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" - -[[package]] -name = "iana-time-zone" -version = "0.1.65" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" -dependencies = [ - "android_system_properties", - "core-foundation-sys", - "iana-time-zone-haiku", - "js-sys", - "log", - "wasm-bindgen", - "windows-core", -] - -[[package]] -name = "iana-time-zone-haiku" -version = "0.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" -dependencies = [ - "cc", -] - -[[package]] -name = "indexmap" -version = "2.14.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" -dependencies = [ - "equivalent", - "hashbrown", -] - -[[package]] -name = "itoa" -version = "1.0.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" - -[[package]] -name = "js-sys" -version = "0.3.106" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" -dependencies = [ - "cfg-if", - "futures-util", - "wasm-bindgen", -] - -[[package]] -name = "libc" -version = "0.2.191" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a" - -[[package]] -name = "log" -version = "0.4.34" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" - -[[package]] -name = "memchr" -version = "2.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" - -[[package]] -name = "num-traits" -version = "0.2.19" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" -dependencies = [ - "autocfg", -] - -[[package]] -name = "once_cell" -version = "1.21.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" - -[[package]] -name = "pin-project-lite" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" - -[[package]] -name = "ppv-lite86" -version = "0.2.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" -dependencies = [ - "zerocopy", -] - -[[package]] -name = "proc-macro-crate" -version = "3.5.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" -dependencies = [ - "toml_edit", -] - -[[package]] -name = "proc-macro2" -version = "1.0.107" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "quote" -version = "1.0.47" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" -dependencies = [ - "proc-macro2", -] - -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - -[[package]] -name = "rand" -version = "0.8.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" -dependencies = [ - "libc", - "rand_chacha 0.3.1", - "rand_core 0.6.4", -] - -[[package]] -name = "rand" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" -dependencies = [ - "rand_chacha 0.9.0", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_chacha" -version = "0.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" -dependencies = [ - "ppv-lite86", - "rand_core 0.6.4", -] - -[[package]] -name = "rand_chacha" -version = "0.9.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" -dependencies = [ - "ppv-lite86", - "rand_core 0.9.5", -] - -[[package]] -name = "rand_core" -version = "0.6.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" -dependencies = [ - "getrandom 0.2.17", -] - -[[package]] -name = "rand_core" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" -dependencies = [ - "getrandom 0.3.4", -] - -[[package]] -name = "rust_decimal" -version = "1.43.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7653272e75dcac41dc199fbea6f5797633994fafd339943c06c9af16bf29cd3a" -dependencies = [ - "arrayvec", - "borsh", - "bytes", - "num-traits", - "rand 0.8.8", - "rand 0.9.5", - "serde", - "serde_json", - "wasm-bindgen", -] - -[[package]] -name = "rustversion" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" - -[[package]] -name = "serde" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" -dependencies = [ - "serde_core", - "serde_derive", -] - -[[package]] -name = "serde_core" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" -dependencies = [ - "serde_derive", -] - -[[package]] -name = "serde_derive" -version = "1.0.229" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" -dependencies = [ - "proc-macro2", - "quote", - "syn 3.0.9", -] - -[[package]] -name = "serde_json" -version = "1.0.154" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" -dependencies = [ - "itoa", - "memchr", - "serde", - "serde_core", - "zmij", -] - -[[package]] -name = "serde_spanned" -version = "1.1.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c" -dependencies = [ - "serde_core", -] - -[[package]] -name = "shlex" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" - -[[package]] -name = "slab" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" - -[[package]] -name = "syn" -version = "2.0.119" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[package]] -name = "syn" -version = "3.0.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" -dependencies = [ - "proc-macro2", - "quote", - "unicode-ident", -] - -[[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", - "toml_datetime", - "toml_parser", - "toml_writer", - "winnow", -] - -[[package]] -name = "toml_datetime" -version = "1.1.2+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053" -dependencies = [ - "serde_core", -] - -[[package]] -name = "toml_edit" -version = "0.25.17+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3641d5bbb5349a79e1020a242d251efbc546ad8048d133958323ce9c40a9c9c" -dependencies = [ - "indexmap", - "toml_datetime", - "toml_parser", - "winnow", -] - -[[package]] -name = "toml_parser" -version = "1.1.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c" -dependencies = [ - "winnow", -] - -[[package]] -name = "toml_writer" -version = "1.1.3+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06bdbd8cfc056b8d2e2e85f29b56a3bdbecb527cef81eb39e3e7b98af4652770" - -[[package]] -name = "unicode-ident" -version = "1.0.27" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" - -[[package]] -name = "wasi" -version = "0.11.1+wasi-snapshot-preview1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" - -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - -[[package]] -name = "wasm-bindgen" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" -dependencies = [ - "cfg-if", - "once_cell", - "rustversion", - "serde", - "wasm-bindgen-macro", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-macro" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" -dependencies = [ - "quote", - "wasm-bindgen-macro-support", -] - -[[package]] -name = "wasm-bindgen-macro-support" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" -dependencies = [ - "bumpalo", - "proc-macro2", - "quote", - "syn 3.0.9", - "wasm-bindgen-shared", -] - -[[package]] -name = "wasm-bindgen-shared" -version = "0.2.129" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" -dependencies = [ - "unicode-ident", -] - -[[package]] -name = "windows-core" -version = "0.62.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" -dependencies = [ - "windows-implement", - "windows-interface", - "windows-link", - "windows-result", - "windows-strings", -] - -[[package]] -name = "windows-implement" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-interface" -version = "0.59.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "windows-link" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" - -[[package]] -name = "windows-result" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" -dependencies = [ - "windows-link", -] - -[[package]] -name = "windows-strings" -version = "0.5.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" -dependencies = [ - "windows-link", -] - -[[package]] -name = "winnow" -version = "1.0.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" -dependencies = [ - "memchr", -] - -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - -[[package]] -name = "zerocopy" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" -dependencies = [ - "zerocopy-derive", -] - -[[package]] -name = "zerocopy-derive" -version = "0.8.63" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" -dependencies = [ - "proc-macro2", - "quote", - "syn 2.0.119", -] - -[[package]] -name = "zmij" -version = "1.0.23" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/bookspec/Cargo.toml b/bookspec/Cargo.toml deleted file mode 100644 index 78db5ec..0000000 --- a/bookspec/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -# bookspec's own workspace, inside the wanderbooks repository until the -# specification and its harness are stable enough to move to their own -# repository (P13 step 6, D-34). -[workspace] -resolver = "2" -members = ["checker", "runner", "gnucash"] - -[workspace.package] -edition = "2024" -license = "MIT OR Apache-2.0" -repository = "https://git.swaits.com/swaits/wanderbooks" - -[workspace.lints.rust] -missing_docs = "warn" diff --git a/bookspec/README.md b/bookspec/README.md deleted file mode 100644 index 9510900..0000000 --- a/bookspec/README.md +++ /dev/null @@ -1,534 +0,0 @@ -# bookspec: examples (seed) - -bookspec is the formal accounting specification planned in -`plan/served.md` (P13, D-34): every rule stated normatively, sourced to its -authority, with a symbolic form and concrete examples, independent of any -engine. Its own repository comes later. Until then this directory holds its -first examples: P3's regression tests, each written here as it is built, so -the spec starts from cases a real engine got wrong. - -Every example is checked against wanderbooks on every `cargo test` -(`crates/ledger-core/tests/bookspec.rs`, and for the formats appendix -`crates/ledger-package/tests/package.rs`): an example the engine disagrees -with fails the build, so the examples can't rot. An expected value is worked -by hand from the cited source, never copied from an engine's output. - -The schema, the parser (`Example::from_toml`, every structure with -`deny_unknown_fields`) and the `Engine` trait an accounting engine -implements live in `bookspec/checker`; `bookspec/runner` runs any engine -through the examples and reports per rule: pass, fail or not claimed. The -wanderbooks adapter is `crates/ledger-core/tests/bookspec.rs`. The rule -identifiers are `BS--`, tabled in `bookspec/spec/rules-index.md` -(P13 steps 1 to 3). - -## The shape of an example - -One TOML file per example in `examples/`, named for its `id`. - -```toml -id = "cash-flow.reversed-property-contribution" # stable; never reused -title = "A property contribution reversed the same day" -rule = "One normative sentence: what MUST (or MUST NOT) hold." -status = "AUTHORITY" # AUTHORITY | CONVENTION | CHOICE -sources = ["ASC 230-10-45-24", "ASC 230-10-50-3"] # exact citations -tags = ["accrual", "book", "us"] # applicability - -[setup] -currency = "USD" # ISO 4217 -fiscal_year_start = "01-01" # MM-DD -basis = "accrual" # accrual | cash -rounding = "half-even" # half-even | half-away-from-zero | - # largest-remainder: how a figure is - # rounded to the currency's minor unit, - # where a rule rounds (none here) - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" # asset | liability | equity | revenue | expense - -[[events]] # in order, counting from 1 -date = "2026-01-10" -description = "Property contributed" -lines = [ - { account = "1500", side = "debit", amount = "2340.00" }, - { account = "3000", side = "credit", amount = "2340.00" }, -] - -[[events]] -date = "2026-01-10" -description = "Reverse entry 1" -reverses = 1 # the event (1-based) whose entry this - # one reverses: its lines, sides flipped; - # no `lines` here - -[[expect]] -query = "cash_flow" # what is asked of the books (see below) -from = "2026-01-01" # inclusive -to = "2026-12-31" # inclusive (a query as of a day: as_of) - -[expect.values] # every value an exact decimal string -operating = "0.00" -``` - -Events count from 1: the first `[[events]]` is event 1. An event is an -entry (`lines`), a reversal (`reverses`), a close (`close`, below), a -bill or an invoice (`bill`, `invoice`, below), each posting one entry; -or one of the books' commands that post what is due (`run_depreciation`, `dispose_asset`, -`run_schedules`, below), which post as many entries as are due, none -included; or a schedule's registration (`register_schedule`), which -posts none: exactly one of them. A key that names an event (`reverses = -N`, `entry = N`, a cash flow's ``) names the entry event N posted, one -before it: an event that posted none, or several, can't be named that -way, and `reverses = 0` names no event and is refused. While every event -posts one entry, event N's is entry #N. A change that posts no entry (a -rename, a policy) is a `[[changes]]` entry instead (below). A key the -shape doesn't have is refused too, never ignored. - -An entry, a reversal, a close, a bill, an invoice and a disposal are -dated (`date`); the other commands date their own entries (a run its -month's end) and take no date. An entry, a reversal, a close, a bill and -an invoice are described (`description`); a command describes its own -entries and takes none. - -The books are the engine's own (`ledger_store::Store`, in a directory of -their own): each event is the command the books take it by, journaled as -they journal it, and after every expectation is checked the books must -replay to what they kept (`Store::verify`). - -`rounding` names the rule the example's figures are rounded by where one -rounds: `half-even` or `half-away-from-zero` to the currency's minor unit, -or `largest-remainder` for an amount split among parts (each part rounded -down, the units left over to the largest remainders, ties to the part -listed first; rules.md B-13). An example that closes to the members -declares `largest-remainder`, the rule a members close splits by. - -`status` says what the expected values rest on: `AUTHORITY`, the cited -authorities alone (a standard, a regulation, a statute, a court, the -mechanics of double entry); `CONVENTION`, common practice where no -authority governs (rules.md's CONVENTION); `CHOICE`, an owner's or the -house's decision (a plan D-number, or a CHOICE rules.md records) that -some expected value depends on, even when authorities carry the rest. An -example whose figures follow a decision cites it in `sources`. - -Required everywhere: the top-level keys (`title`, `rule`, `tags` and -`sources` non-empty), `[setup]` with its four keys and its accounts, the -events (`events = []`, before `[setup]`, when nothing is posted), and at -least one `[[expect]]`. A value from a closed list (a type, a kind, a -role, a form, a basis) is spelled exactly as written here. A figure -query's values are -exact decimal strings (`"2340.00"`, `"-12.50"`; never a float), a format -query's are text (below). Amounts on lines are positive; -the side gives the direction. - -### Optional keys, for the rules that need them - -- `appendix = "formats"` (top level): the example shows an interchange - format, not an accounting rule (P13's appendix of formats; the IIF - file today). It loads like any other; only it may ask a format query. -- `parent` on an account: its parent account's code. A parent is declared - before its subaccounts. -- `header = true` on an account: a header, which groups other accounts - and takes no postings (it needs no tax category: rules.md B-32). -- `tax_category` on an account: its tax category, a key of the books' - catalog (`gross_receipts`, `office_expense`, ...; `ledger_core::tax`), - its link to a line of the return (B-32). -- `today` (in `[setup]`): the day the books are asked on, `YYYY-MM-DD`, - in the company's time zone: what an omitted date means (B-30). A query - that omits a date needs it. -- `cash_accounts`, `financing_accounts`, `investing_accounts` (in - `[setup]`): arrays of account codes, the statement of cash flows' - account roles (ASC 230-10-45-10 to 45-15). The cash and financing - accounts are also what a payee is paid from, for `vendor_1099_paid` and - `vendor_1099` (a payment card is neither: its charges go on Form - 1099-K; rules.md B-19); with the cards, what party money is, for - `party_part` and `party_totals` (B-35 item 5: no `cash_accounts`, every - asset account but receivables); the cash accounts are what the K-1 - worksheet reads as money (`k1`, `k1_tax_basis`, `k1_reconciliation`) - and an IIF file's bank accounts (B-34). -- `[[setup.fixed_assets]]` `{ cost_account, accumulated_account, - expense_account }`: accounts that hold property and equipment (their - cost and accumulated depreciation are investing; ASC 230-10-45-12(c), - 45-13(c)), for an example no asset's own figures matter to. An asset - with its figures is a `[[setup.assets]]` entry (below), whose accounts - hold property and equipment too. -- `[[setup.assets]]`: fixed assets registered before the first event, - each named by its `id` in the events and queries that ask about it - (rules.md B-15): - - ```toml - [[setup.assets]] - id = "X" # its name in this example - description = "Asset X" - cost = "12000.00" - salvage = "0.00" - life_months = 12 - in_service = "2026-01-01" # depreciation starts here - method = "straight_line" # the one method the books have - cost_account = "1500" - accumulated_account = "1510" - expense_account = "6200" - acquired_by = 2 # optional: the event whose entry put it - # on the books (B-15 rule 7) - basis = { rule = "personal_use_conversion", - contributor_adjusted_basis = "1688.40", - fmv_at_contribution = "1440.00", - sec704c_method = "traditional" } # optional (B-25) - ``` - - Registering an asset posts nothing: the entry that buys it is an event - of its own. `acquired_by = N` links that entry, event N's, once the - event is applied: its date is the asset's acquired date, which puts it - on the register (before it, the in-service date does). `basis` records - how the company got it and the facts its tax figures follow from, as - the asset is registered: `rule` `"purchase"`, `"carryover_723"` or - `"personal_use_conversion"`; a contribution's - `contributor_adjusted_basis` and `fmv_at_contribution`; the §704(c) - method if chosen (`"traditional"`, `"traditional_with_curative"`, - `"remedial"`). -- `[[setup.schedules]]`, and `register_schedule = { ... }` as an event: a - prepaid expense or deferred revenue recognized over its months (rules.md - B-16), named by its `id`: - `{ id, kind, description, total, start, months, convention, - balance_account, pl_account }`, `kind` `"prepaid_expense"` or - `"deferred_revenue"`, `convention` `"daily"` (by the day, the last month - taking what the others leave) or `"monthly_even"` (equal months from - the 1st). Registering one posts nothing. -- `[[setup.parties]]` `{ name, roles, creditor_of?, tin_last4?, - w9_tin_last4?, tin_purged? }`: the company's directory (B-26). `roles` - are role keys (`member`, `vendor`, `customer`, `contractor`, - `landlord`, `employee`, `financial_institution`, `payout_platform`, - `tax_agency`, `government_agency`); `vendor` or `customer` (one at - most) gives the party its record. `creditor_of`: the liabilities, by - code, the company owes the party itself (a card's issuer, a lender, - each member for what the members advanced: B-35 item 6). `tin_last4`: - its TIN facts' last four (an SSN's); `w9_tin_last4`: its W-9's (a 1099 - role's); `tin_purged = true`: the vault's copy purged, the facts kept (needs - `tin_last4`). - Never a full TIN. Each name once. -- `[[setup.members]]` `{ name, capital_account, share?, - distributions_account? }`: the members of a multi-member LLC. `share` - is the member's share of income, an exact decimal fraction above zero (`"0.5"`, - `"0.3333"`); every member has one or none does, and the shares sum to - 1. A close to the members, `members_equity`, `k1`, `k1_tax_basis` and - `k1_reconciliation` need them. `distributions_account`: the member's - distributions account (B-13). A - member whose `name` is a party's in `setup.parties` with the `member` - role is that party, as the company's settings name them: an entry's - parties' shares resolve with those members (B-35), and so does the - 1099 worksheet. -- `[[setup.payees]]` `{ name, form }`: a payee marked for an information - return from the beginning, `form` `"1099-NEC"` or `"1099-MISC"`; each - name once. A payee in `setup.parties` with the `vendor` role is that - party's vendor record; any other is a vendor record of its own. -- `[[setup.status_changes]]` `{ payee, effective, decision, reason_kind?, - reason? }`: a payee's 1099 decision from `effective` (`YYYY-MM-DD`) on - (rules.md B-19, D-40: an LLC's corporate election, say): `decision` - `"1099-NEC"`, `"1099-MISC"`, `"undetermined"` or `"not_reportable"`, - the last with `reason_kind` (`corporation`, `tax_exempt`, - `united_states`, `state`, `foreign_government`, `foreign_person`, - `merchandise`, `employee`, `other`) and the facts in words (`reason`). - Each payment takes the decision in force on its date. -- `[[setup.instruments]]` `{ label, kind, account, last4?, owner? }`: a - payment method (B-19): `kind` `bank`, `business_card`, `member_card`, - `member_cash_or_check` or `platform`; `account` the code it pays - through; `owner` a party's name in `setup.parties` (informational); each label - once. A business card's - liability is a card account (B-35 item 5): while none is recorded, a - liability whose name says "card", not "gift card", is. -- `[[setup.projects]]` `{ name, kind?, status?, party? }`: a project - (rules.md B-37), added with its facts, each name once: `kind` - `internal`, `client`, `on_spec` or `other`; `status` `active` (the - default), `paused`, `finished` or `abandoned`; `party` a - `setup.parties` name. -- `project = ""` on a line of `lines`: the line is tagged with - that project (a `setup.projects` name), as the books' command tags it, - after the line is added. Not on a reversal, which carries its - original's tags. -- `payee` on an event: the payee (by name, one of `setup.payees`) the - entry paid. Only on an entry (`lines`): a reversal takes its - original's, a bill names its vendor, an invoice its customer, a close - and a command have none. -- `parties = [{ party, amount? }, { party, percent? }, ...]` on an event: - the parties (by name, in `setup.parties`) the entry is with, each with - its share: an `amount` (an exact decimal string), a `percent`, or - neither (derived from the entry: all of it for one party, the members' - capital lines or income shares for several: B-35). An entry naming a - party with a vendor record is tagged with that payee too; with - `payee` as well, the payee's party is one of the parties. -- `instrument` on an event: the payment method (a `setup.instruments` - label) the entry was paid with. Neither `parties` nor `instrument` on a - reversal, which takes its original's, nor on a command. -- `older = true` on an entry (`lines`): the entry as an earlier version - could write it, which this version's commands refuse: a line by hand - on a receivables or payables account, where only an invoice, a bill or - their payments post now (rules.md B-38, the control guard). Books that - hold one must still read right, so the books take it as replay does. - The same line written today is a `refused = "untagged_control_line"` - event instead. An event cannot be both. -- `close = { start, to, rounding? }` as an event: closes the period from - `start` through the event's `date` (rules.md B-11, B-13), `to` - `"members"` (split by share, in the order listed) or - `"retained_earnings"`. A close to the members splits cumulatively, each - close giving each member their split of everything the members' closes - have closed less what earlier closes gave them; `rounding = "per-close"` (members only) - splits that close's net on its own instead (books from an engine that - rounded each close by itself). A close must have something to close: it - posts one entry like any other event. -- `bill = { vendor, amount }` or `bill = { vendor, lines = [{ amount, - description?, project? }, ...] }` as an event: a bill from `vendor` (a - name; a payee's record if one has it) for `amount` (one line, described - as the event) or its `lines` (exactly one of them; a line's description - is the event's when left out), dated and due on the event's date. It - posts one entry: General Expenses (6999) debited and Accounts Payable - (2000) credited, each account created with that fixed name when first - needed, as the books create a control role's account (B-32). A line's - `project` (a `setup.projects` name) puts its cost on that project: the - entry's expense side is one line per project named, the untagged lines' - as one, the payable line never tagged (B-37 item 8). -- `invoice = { customer, amount }` or `invoice = { customer, lines = [...] - }` as an event: an invoice to `customer` (a name, its record added when - first named), as a bill is (the same `lines`), dated and due on the - event's date. It posts one entry: Accounts Receivable (1200) debited and - Revenue (4000) credited, each created with that fixed name when first - needed; a line's `project` puts its revenue on that project, the - receivable line never tagged (B-37 item 8). -- `run_depreciation = { asset, period }` as an event: the asset's run for - `period` (`"YYYY-MM"`), which posts what the schedule says is due - through the month's end and the books don't carry, dated then: a - skipped month caught up, nothing when nothing is due (rules.md B-15). -- `dispose_asset = { asset, proceeds, proceeds_account, gain_loss_account - }` as an event, dated: the asset's disposal for `proceeds` (an exact - decimal, zero allowed), received into `proceeds_account`, its gain or - loss to `gain_loss_account` (B-15). It posts the depreciation still due - through its day in an entry of its own when the month has no run, then - the disposal entry. -- `run_schedules = { through }` as an event: every schedule's months due - through `through` posted (B-16). -- `write_off = { invoice, amount?, reason }` as an event, dated: writes - off the invoice event `invoice` (1-based; an `invoice` event before it) - posted, as a bad debt (rules.md B-41): Bad Debt Expense (6990) is - debited and Accounts Receivable credited, the receivable line tagged - with the invoice, and the evidence of worthlessness filed in the same - call. `amount` (an exact decimal above zero; left out, all the invoice - owes on the day) makes it partial. Refused on cash-basis books and - above what is owed. -- `refused = ""` on a reversal, an entry or a command: the books - refuse it, for that reason (below); it changes nothing, posts nothing, - and the example goes on without it. An entry posts whole or not at all, - so a refused one leaves no draft behind. -- `[[changes]]` `{ after, rename?, require_tax_category?, correct_asset? - }`: a change that posts no entry, exactly one of `rename = { code, name - }` (rename the account `code`, B-31), `require_tax_category = "warn"` - or `"refuse"` (the policy, B-32; the books start at `warn`) and - `correct_asset = { asset, cost?, salvage?, life_months?, in_service?, - reason }` (a registration mistake corrected: each figure given replaces - the asset's, the rest stay; refused while the books carry depreciation - for it). It is made - right after event `after` (1 to the number of events), once the - expectations asked after that event are checked, in the order listed. -- `after = N` on an expectation: asked of the books after event N (before - the changes made after it), not after the last event and every change - (1 to the number of events; not on a format query). - -Party totals and the 1099 worksheet answer different questions (B-35 -item 9): a member paying a contractor through the members' financing -account is, in party totals, paid to the contractor (money, the -members' financing being money) and nothing to the member until the -company reimburses them; on the 1099 worksheet it is the company's -payment, counted toward the contractor's 1099 unless it was made by card -(B-19), and undetermined until its payment method is recorded. - -### Queries and their values - -A query over a period takes `from` and `to`; a query as of a day takes -`as_of`; the other is refused. A date left out is resolved by the one -rule (rules.md B-30) on `setup.today`: an omitted `to` is today, an -omitted `from` the first day of the fiscal year that holds `to`, an -omitted `as_of` today (`vendor_1099_paid`, `vendor_1099`, -`party_totals` and the format queries give theirs). A query no crate -answers is refused, never skipped. - -| query | dates | values | checked by | -|---|---|---|---| -| `cash_flow` | `from`, `to` | figures (below) | `ledger-core` `tests/bookspec.rs` | -| `trial_balance` | `as_of` | each account's balance by code, debits less credits (a credit balance negative), every account of `setup.accounts` listed (not those the books create) | `ledger-core` `tests/bookspec.rs` | -| `profit_and_loss` | `from`, `to` | each revenue and expense account's activity by code, on its normal side, closing entries left out, every one of `setup.accounts` listed; `net_income`, revenue less expenses | `ledger-core` `tests/bookspec.rs` | -| `entry` | `entry = N`, no dates | the lines of event N's entry, `".debit"` and `".credit"` each the sum of that account's lines on that side, every line of the entry named | `ledger-core` `tests/bookspec.rs` | -| `balances` | `as_of` | each account named by code, its balance on its normal side (a member's capital after a loss is negative); the example names the accounts it shows | `ledger-core` `tests/bookspec.rs` | -| `members_equity` | `from`, `to` | the statement of changes in members' equity (rules.md B-13): `".beginning"`, `.contributions`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | -| `k1` | `from`, `to` | the K-1 worksheet (B-13; Schedule K-1 Item L): `".beginning"`, `.contributions_cash`, `.contributions_property`, `.net_income`, `.distributions`, `.other`, `.ending`; the example names the figures it shows (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | -| `k1_tax_basis` | `from`, `to` | the K-1 worksheet on the tax basis (B-36; Schedule K-1 Item L, tax-basis method), beside `k1`'s book figures: `".beginning"`, `.contributions_cash`, `.contributions_property`, `.net_income`, `.other`, `.distributions`, `.ending`; a line the books can't know is `"open"`, with what they know so far as `".known_"`; the example names the figures it shows (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | -| `k1_reconciliation` | `from`, `to` | each member's book capital less tax-basis capital at `to`, by cause (B-36): `".beginning"`, `.built_in_gain_704c`, `.property_without_basis`, `.property_distributed`, `.total`; the example names the members it shows, each with every cause (`cash_accounts` required) | `ledger-core` `tests/bookspec.rs` | -| `vendor_1099_paid` | `from` (January 1 of `to`'s year), `to` | each payee's payments that count toward its 1099, by name: out of the cash and financing accounts (`cash_accounts` required), a card's or payment network's left out (B-19), every payee listed | `ledger-core` `tests/bookspec.rs` | -| `vendor_1099` | `payee`; `from` (January 1 of `to`'s year), `to` | one payee's line of the 1099 worksheet (B-19): `status` (`reportable`, `under_threshold`, `not_reportable` or `undetermined`), `threshold`, `paid` (counts toward its 1099), `excluded_card` (a card's or payment network's: Form 1099-K), `undetermined` (through a financing account, its payment method not recorded), `not_reportable` (paid while decided not reportable), every one given | `ledger-core` `tests/bookspec.rs` | -| `party_part` | `entry = N`, `party`, no dates | entry N's part for the party (B-35): `resolved`, signed as a line on the party's own account would be (paid or given positive, received negative), the entry's `direction` (`paid`, `received` or `neither`), and what of it moved as money (`cash`) and as property (`in_kind`), every one given | `ledger-core` `tests/bookspec.rs` | -| `party_totals` | `party`, `basis` (`cash` or `all`), `from`, `to` | what was `paid` to the party and `received` from it over the period (B-35 item 4: basis `cash` money, `all` money and property in kind; each entry on its first original's side), both given | `ledger-core` `tests/bookspec.rs` | -| `party_tin_last4` | `party`, no dates | `tin_last4`: the party's TIN facts' last four, else its W-9's, `""` for none (B-26) | `ledger-core` `tests/bookspec.rs` | -| `period` | `from`, `to`, either or both left out | the dates a period report asked with those dates covers, by the one rule (B-30): `from`, `to` (`YYYY-MM-DD`); or `refused` | `ledger-core` `tests/bookspec.rs` | -| `day` | `as_of`, or left out | the day a report as of a day is made as of: `as_of` | `ledger-core` `tests/bookspec.rs` | -| `calendar_year` | none | the year a calendar-year count (1099s, party totals) asked without one counts: `year` | `ledger-core` `tests/bookspec.rs` | -| `account_names` | none | each account's name by code, as the chart holds it and every report prints it, past periods included (B-31); every account listed, those the books created included | `ledger-core` `tests/bookspec.rs` | -| `uncategorized_accounts` | none | a list: every account that needs a tax category (revenue or expense, not a header) and has none, with activity or not, `" "`, by code (B-32) | `ledger-core` `tests/bookspec.rs` | -| `run` | `event = N`, no dates | what the `run_depreciation` event N posted: `amount` (zero when nothing was due, or the month had its run already), and `caught_up`, what of it is beyond the month's own depreciation (B-15); both listed | `ledger-core` `tests/bookspec.rs` | -| `disposal` | `asset`, no dates | what the asset's disposal took (B-15), every figure listed: `final_depreciation` (posted in its own entry), `catch_up_depreciation` and `excess_depreciation` (charged, and credited back, in the disposal entry), `accumulated` (cleared), `book_value`, `gain_loss` (a gain positive) | `ledger-core` `tests/bookspec.rs` | -| `depreciation_schedule` | `asset`, `as_of` | the asset's schedule through the day (B-15): each month named (`"YYYY-MM"`) its depreciation, `"0.00"` for a month it doesn't reach; `accumulated` and `book_value` through the day; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | -| `register_row` | `asset`, `as_of` | the asset's row of the fixed asset register (B-15 rules 7 and 8, B-24): `acquired`, `in_service` (`YYYY-MM-DD`), `cost`, `accumulated_scheduled` (the schedule's), `accumulated_posted` (what the books carry), `unposted` (the one less the other), `book_value` (cost less posted), each `"0.00"` once disposed; the example names the figures it shows; an asset acquired after the day is refused | `ledger-core` `tests/bookspec.rs` | -| `register_totals` | `as_of` | the register's totals over the assets held (B-24, B-25): `total_cost`, `total_accumulated_scheduled`, `total_accumulated_posted`, `total_book_value`, `total_depreciable_basis`, `total_tax_basis_723`, `missing_basis` (a count), and how it ties to the books: `unregistered_cost`, `unposted_depreciation`, `unattributed_accumulated`; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | -| `asset_basis` | `asset`, no dates | the asset's basis facts and the tax figures they give (B-25): `rule`, `contributor_adjusted_basis`, `fmv_at_contribution`, `tax_basis_723`, `depreciable_basis`, `built_in_gain_704c` (a loss negative), each `"none"` where it has none, and `sec704c_1c` (`"true"` for a built-in loss, which counts only for the contributor); the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | -| `project` | `project`, `from`, `to` | the project's figures (B-37), every one given: `revenue`, `expenses` and `net` for the period (the revenue and expense lines tagged to it, closes left out), `to_date.revenue`, `to_date.expenses` and `to_date.net` (every tagged line through `to`), and `unrecovered_cost` (expenses less revenue to date, never below zero; `"none"` unless it is on spec) | `ledger-core` `tests/bookspec.rs`; `ledger-cli` `tests/spec_projects.rs` `project_figures_match_profit_and_loss_by_class` | -| `schedule_plan` | `schedule`, no dates | the schedule's plan (B-16): what it recognizes at each month end named (`"YYYY-MM-DD"`), `"0.00"` at one it doesn't post; `total`, all it recognizes; the example names the figures it shows | `ledger-core` `tests/bookspec.rs` | -| `iif_lines` | `as_of` | formats appendix: the file's lines in order, each its fields joined by tabs, without the CR LF that ends each | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | -| `iif_changes` | `as_of` | formats appendix: each text the file writes differently from the books, in file order, as `[what, books' text, file's text]` (the example's header gives `what`) | `ledger-package` `the_bookspec_iif_example_matches_the_engine` | - -A figure query is a table (`[expect.values]`, or `values = { ... }`); a -format query, and `uncategorized_accounts`, an array (`values = [ ... -]`). `period`, `day` and `calendar_year` give dates and a year, -`account_names` names, `party_part`'s `direction`, `vendor_1099`'s -`status` and `party_tin_last4` words, not figures; `register_row` gives -dates beside its figures, `project` gives `"none"` as the unrecovered cost of a -project that isn't on spec, `asset_basis` a rule's name, `"none"` and -`"true"` or `"false"`, and a `k1_tax_basis` line `"open"`, beside them. - -A command is tried on a copy of the books, as the books' own command -does it, and changes nothing checked after it. It shows no values: it is -`accepted = true`, or `refused = ""`. - -| command | keys | | -|---|---|---| -| `rename_account` | `code`, `name` | rename the account (B-31); accepted, it must move no amount and keep the name trimmed | -| `add_account` | `account = { code, name, type, parent?, header?, tax_category? }` | add the account, under the policy in force (B-31, B-32) | -| `set_tax_category` | `code`, `category` (left out: clear it) | set or clear the account's tax category, under the policy in force (B-32) | - -A refusal (a command's, or a `period`'s) is named for what was refused, -never by an engine's message: `end_before_start` (both dates given, the -start after the end), `start_after_today` (only a start, after today, the -end an omitted end means), `blank`, `unchanged` (the name the account has, -trimmed), `sibling_has_it` (another account under the same parent has the -name, ignoring capitals and surrounding space), `holds_a_tin` (a run of -nine digits), `not_one_line` (a line break, tab or other control -character, or past 200 characters), `code_taken`, and -`tax_category_required` (an account that needs a category left without -one under `refuse`). A refused event's: `depreciation_past_disposal` (a -disposal dated before depreciation a run carries: a later month's run -stands, or a run's change dated after the disposal's month isn't undone), -`entry_irreversible` (an asset's entry that can't be reversed: its -disposal, a disposed asset's depreciation, a run re-instated twice or past -its schedule, or a reversal dated before its run), `already_disposed`, -`invalid_depreciation_period` (a month that ends before the asset is in -service), `invalid_schedule` (an equal-months schedule not starting on -the 1st, among others) and `untagged_control_line` (a hand line on a -receivables or payables control account, which only a document may post -to: rules.md B-38's control guard). - -The refusals of the ledger's own laws (BS-entry, BS-ledger, BS-period) -are spelled `unbalanced` (debits differ from credits), `too_few_lines`, -`negative_amount` and `zero_amount` (BS-entry-2), `finer_than_minor_unit` -(an amount finer than the currency's smallest unit, BS-ledger-1), -`parent_type_mismatch` (a child account of another type than its parent, -BS-ledger-2), `already_reversed` (BS-ledger-6) and `period_closed` (an -entry dated inside a closed period, BS-period-2). A refused entry may -carry the amount the books refuse (zero or negative, in the amount's -exact decimal spelling); an accepted one carries a positive amount. - -`cash_flow` (rules.md B-14): the statement of cash flows for `[from, to]`. - -| key | the value | -|---|---| -| `operating`, `investing`, `financing` | each section's net cash, + in | -| `net_change`, `cash_beginning`, `cash_ending` | the cash accounts' change and balances | -| `difference` | sections less the change in cash: always `"0.00"` | -| `net_income` | revenue less expenses, closing entries excluded | -| `
.` | event N's entry's cash in that section (`financing.4`) | -| `
..on_behalf` | of it, what a member or a lender paid or received for the company, no cash account moving (constructive receipt and disbursement) | -| `
.count` | how many entries the section lists | -| `class.` | an operating class's total, by ASC 230-10-45-25's letter: (a) from customers, (b) interest and dividends received, (c) other receipts, (d) paid to suppliers and employees, (e) interest paid, (f) income taxes paid, (g) other payments; given one, every class the statement has must be given | -| `noncash.` | event N's entry's noncash investing and financing disclosure | -| `noncash.count` | how many entries are disclosed | -| `reconciliation.depreciation` | depreciation added back | -| `reconciliation.investing_gain_loss` | gains and losses whose cash is investing, taken out | -| `reconciliation.noncash_through.` | operating movements no cash settled, through that account | -| `reconciliation.other_noncash` | other operating movements no cash settled | -| `reconciliation.balance_change.` | minus the change in an operating asset or liability | - -An entry named in a `
.` (or `.on_behalf`) or `noncash.` -key must be the only such entries when `
.count` or -`noncash.count` is given too; a -reconciliation key not given must be zero or absent. - -Every example, in any appendix, is loaded by -`crates/ledger-core/tests/bookspec.rs` (`every_bookspec_example_holds`): -its shape (a key the shape doesn't have is refused), its setup, and every -event taken or refused as it says, on the books' own store. Each query is -then checked by the crate that answers it (the table above), so the shape -never rots either. - -## The specification, the laws, the checks (P13 steps 4 and 5) - -- `spec/` is the specification text: `00-model.md` (definitions, the - model, the five laws, how an example is checked) and one file per area - (`entry.md`, `ledger.md`, ... `format.md`), each rule a section with its - status, its MUST/MUST NOT/MAY statement, its sources (a vendored source - with a verbatim pin, or an exact external reference), its symbolic form, - its examples by id and its applicability. `spec/rules-index.md` is the - table from `docs/spec/rules.md`'s B-1 to B-45. The checker's - `tests/spec.rs` fails the build when a rule lacks its section, a - statement lacks MUST/SHOULD/MAY, a pin is not verbatim in its vendored - source, a rule's examples differ from the catalogue's, or the edition - passes 60 pages. -- `lean/` is the five laws as Lean theorems (`Bookspec.law_*`), ported - from `verify/lean`; `lake build` in that directory builds them and - `lake env lean Audit.lean` prints their axioms, which the checker's - `tests/lean.rs` requires to be only `propext`, `Quot.sound` and - `Classical.choice`. -- The checker derives expected figures itself (`checker/src/derive.rs`): - an engine-free reference model with exact decimals replays an example's - entries, reversals, close, bills and invoices and computes its - `trial_balance`, `balances`, `profit_and_loss` and `entry` figures from - the rules' symbolic forms, and checks that each refusal the example - expects is justified by its rule. An example whose figure disagrees - fails `checker/tests/derive.rs`, before any engine is consulted. What - the model does not cover is reported as skipped, never passed. -- `gnucash/` is the second engine: GnuCash 5.10 in the oracle container - (`just oracle` builds it), driven through `oracle/gnucash/books.py`. - `cargo test -p bookspec-gnucash -- --ignored` runs it (it needs - Docker). It claims the rules whose examples ask only for balances and - passes `BS-entry-3`, `BS-ledger-3`, `BS-statement-1` and - `BS-statement-2`. - -### GnuCash, the second engine: where it differs - -GnuCash is a cross-check, never the authority (`docs/oracle.md`, "Who is -right"). It differs from three examples, each settled from the sources -and pinned by `gnucash/tests/gnucash.rs`: - -- **`entry-an-unbalanced-entry-is-refused`.** GnuCash accepts debits of - 100.00 against credits of 90.00 and parks the ten dollars in an - `Imbalance-USD` account of its own making; the accounts it lists no - longer net to zero. BS-entry-1 (Pacioli ch. 36; IRS Pub. 583: "the - total debits must equal the total credits") says such an entry is - refused. The example stands; GnuCash records a difference, flagged for - the user to repair, instead of refusing it. -- **`entry-a-line-amount-is-positive`.** GnuCash takes a credit of - minus 100.00 as a debit of 100.00, both lines debits, and parks 200.00 - in the Imbalance account. BS-entry-2 is a CONVENTION (no authority - forbids a negative amount); the example stands as the convention the - specification chose, and GnuCash's reading is the very ambiguity the - convention removes. -- **`ledger-a-fraction-of-a-cent-is-refused`.** GnuCash rounds 10.005 to - 10.01 silently. BS-ledger-1 is a CHOICE (the owner, 2026-10-13, found - by the first GnuCash cross-check): the books never round silently. An - engine's pick is its own; GnuCash's is the other option, recorded. - -The example `ledger-a-mistake-is-reversed-never-erased` and the others -that need an engine's own reversal link or entry query are not claimed by -the GnuCash adapter: GnuCash has no reversal link, so it cannot answer -`already_reversed`. - -## Licence and sources - -The examples state rules in their own words with the paragraph cited -(FASB's Codification is copyrighted: cited, never copied; see -`docs/spec/sources/fasb-asc.md`). The examples are MIT OR Apache-2.0, as -the rest of this repository, until bookspec moves to its own repository -(planned: the spec under CC BY 4.0). diff --git a/bookspec/checker/Cargo.toml b/bookspec/checker/Cargo.toml deleted file mode 100644 index cc52fa9..0000000 --- a/bookspec/checker/Cargo.toml +++ /dev/null @@ -1,17 +0,0 @@ -[package] -name = "bookspec-checker" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "bookspec's example schema, its rule identifiers and the Engine trait an accounting engine implements." - -[dependencies] -serde = { version = "1", features = ["derive"] } -serde_json = "1" -toml = "1" -rust_decimal = "1" -chrono = { version = "0.4", features = ["serde"] } - -[lints] -workspace = true diff --git a/bookspec/checker/src/derive.rs b/bookspec/checker/src/derive.rs deleted file mode 100644 index ade3974..0000000 --- a/bookspec/checker/src/derive.rs +++ /dev/null @@ -1,680 +0,0 @@ -//! The specification's own checker (`plan/served.md` P13 step 5): -//! every example's expected figures derived from its rule's symbolic -//! form, by an engine-free reference model with exact decimals. -//! -//! The runner asks an *engine* whether it agrees with an example. This -//! module asks whether the *example* agrees with the specification: it -//! replays the example's events on the smallest ledger the rules of -//! `bookspec/spec/*.md` describe (entries, reversals, a close, a bill, an -//! invoice) and computes each expectation it can from the symbolic forms -//! (`sum` of signed values, normal balances, the accounting equation, -//! net income). An expectation whose figure disagrees with the -//! derivation, or an event whose refusal the rules do not justify, fails -//! the specification's build, before any engine is consulted. -//! -//! What the model does not cover (depreciation, schedules, members, -//! 1099s, formats, the cash-flow sections) is not derived here: an -//! expectation is [`Derived::Skipped`] with the reason, never silently -//! passed, and the example is still checked by the engines. - -use std::collections::BTreeMap; -use std::str::FromStr; - -use chrono::NaiveDate; -use rust_decimal::Decimal; - -use crate::engine::RuleId; -use crate::rules::Area; -use crate::schema::{Event, Example, Expect, Values}; - -/// What the derivation found of one expectation or event. -#[derive(Clone, Debug, PartialEq, Eq)] -pub enum Derived { - /// The specification derives the example's figures, and they agree. - Agrees { - /// The rule whose symbolic form derived them. - rule: RuleId, - }, - /// The specification derives different figures, or does not justify - /// a refusal: the example is wrong. - Disagrees { - /// The rule whose symbolic form was applied. - rule: RuleId, - /// What differs, in words. - why: String, - }, - /// The reference model does not cover it. - Skipped { - /// Why not. - why: String, - }, -} - -/// The derivation of one example: one result per event that was refused -/// and per expectation. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Derivation { - /// The example's id. - pub example: String, - /// What was derived, in the order met. - pub results: Vec<(String, Derived)>, -} - -impl Derivation { - /// The disagreements, each as a sentence. - pub fn disagreements(&self) -> Vec { - self.results - .iter() - .filter_map(|(what, d)| match d { - Derived::Disagrees { rule, why } => { - Some(format!("{}: {what}: {why} ({rule})", self.example)) - } - _ => None, - }) - .collect() - } - - /// How many results agree. - pub fn agreed(&self) -> usize { - self.results - .iter() - .filter(|(_, d)| matches!(d, Derived::Agrees { .. })) - .count() - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -enum Side { - Debit, - Credit, -} - -#[derive(Clone, Debug)] -struct Line { - account: String, - side: Side, - amount: Decimal, -} - -#[derive(Clone, Debug)] -struct Posted { - date: NaiveDate, - lines: Vec, - closing: bool, - reversed: bool, -} - -/// The reference model: posted entries, the closed periods, the types. -struct Model { - types: BTreeMap, - entries: Vec, - /// Event number (from 1) to the entry it posted. - event_entry: BTreeMap, - closed_through: Vec, - minor_digits: u32, -} - -fn sign(side: Side) -> Decimal { - match side { - Side::Debit => Decimal::ONE, - Side::Credit => -Decimal::ONE, - } -} - -fn dec(s: &str) -> Option { - Decimal::from_str(s).ok() -} - -fn date(s: &str) -> Option { - NaiveDate::parse_from_str(s, "%Y-%m-%d").ok() -} - -fn rule(area: Area, n: u32) -> RuleId { - RuleId::new(area, n) -} - -/// The accounts the books create for a document or a close, with their -/// types (rules.md B-23's default roles). -fn role_type(code: &str) -> Option<&'static str> { - match code { - "1200" => Some("asset"), - "2000" => Some("liability"), - "3999" => Some("equity"), - "4000" => Some("revenue"), - "6999" => Some("expense"), - _ => None, - } -} - -impl Model { - fn new(ex: &Example) -> Self { - let mut types: BTreeMap = ex - .setup - .accounts - .iter() - .map(|a| (a.code.clone(), a.kind.clone())) - .collect(); - for code in ["1200", "2000", "3999", "4000", "6999"] { - types - .entry(code.to_string()) - .or_insert_with(|| role_type(code).unwrap().to_string()); - } - // ISO 4217: the currency's minor-unit digits (two unless listed). - let minor_digits = match ex.setup.currency.as_str() { - "JPY" | "KRW" | "VND" | "CLP" | "ISK" => 0, - "BHD" | "KWD" | "OMR" | "JOD" => 3, - _ => 2, - }; - Self { - types, - entries: Vec::new(), - event_entry: BTreeMap::new(), - closed_through: Vec::new(), - minor_digits, - } - } - - /// Signed sum of an account's lines dated on or before `through` - /// (debit positive), counting entries that stand as written: the - /// reversal is its own entry, so the original is always counted. - fn signed( - &self, - code: &str, - from: Option, - through: NaiveDate, - closings: bool, - ) -> Decimal { - self.entries - .iter() - .filter(|e| { - e.date <= through && from.is_none_or(|f| e.date >= f) && (closings || !e.closing) - }) - .flat_map(|e| e.lines.iter()) - .filter(|l| l.account == code) - .map(|l| sign(l.side) * l.amount) - .sum() - } - - fn is_debit_normal(&self, code: &str) -> bool { - matches!( - self.types.get(code).map(String::as_str), - Some("asset") | Some("expense") - ) - } - - fn on_normal_side(&self, code: &str, signed: Decimal) -> Decimal { - if self.is_debit_normal(code) { - signed - } else { - -signed - } - } - - /// Why the rules refuse this entry, if they do: (reason, rule). - fn refusal_justified(&self, e: &Event, reason: &str) -> Result { - let on = |side: &str| -> Vec { - e.lines - .iter() - .filter(|l| l.side == side) - .filter_map(|l| dec(&l.amount)) - .collect() - }; - let d: Decimal = on("debit").into_iter().sum(); - let c: Decimal = on("credit").into_iter().sum(); - let amounts: Vec = e.lines.iter().filter_map(|l| dec(&l.amount)).collect(); - match reason { - "unbalanced" if d != c => Ok(rule(Area::Entry, 1)), - "unbalanced" => Err(format!("debits {d} equal credits {c}")), - "too_few_lines" if e.lines.len() < 2 => Ok(rule(Area::Entry, 1)), - "negative_amount" if amounts.iter().any(|a| *a < Decimal::ZERO) => { - Ok(rule(Area::Entry, 2)) - } - "zero_amount" if amounts.iter().any(|a| a.is_zero()) => Ok(rule(Area::Entry, 2)), - "finer_than_minor_unit" - if amounts - .iter() - .any(|a| a.normalize().scale() > self.minor_digits) => - { - Ok(rule(Area::Ledger, 1)) - } - "period_closed" => { - let day = e.date.as_deref().and_then(date).ok_or("no date")?; - if self.closed_through.iter().any(|end| day <= *end) { - Ok(rule(Area::Period, 2)) - } else { - Err(format!("{day} is in no closed period")) - } - } - "already_reversed" => { - let of = e.reverses.ok_or("no reversal named")?; - let idx = self.event_entry.get(&of).ok_or("names no entry")?; - if self.entries[*idx].reversed { - Ok(rule(Area::Ledger, 6)) - } else { - Err(format!("event {of} was not yet reversed")) - } - } - other => Err(format!("{other:?} is not derived by the reference model")), - } - } - - /// Post an accepted event; `Err` says what the model cannot post. - fn post(&mut self, n: usize, e: &Event) -> Result<(), String> { - let day = e.date.as_deref().and_then(date); - let lines = |ls: &[crate::schema::LineSpec]| -> Option> { - ls.iter() - .map(|l| { - Some(Line { - account: l.account.clone(), - side: if l.side == "debit" { - Side::Debit - } else { - Side::Credit - }, - amount: dec(&l.amount)?, - }) - }) - .collect() - }; - let posted = if !e.lines.is_empty() { - Posted { - date: day.ok_or("undated")?, - lines: lines(&e.lines).ok_or("an unreadable amount")?, - closing: false, - reversed: false, - } - } else if let Some(of) = e.reverses { - let idx = *self - .event_entry - .get(&of) - .ok_or("reverses an event that posted nothing")?; - let original = self.entries[idx].clone(); - self.entries[idx].reversed = true; - Posted { - date: day.ok_or("undated")?, - lines: original - .lines - .iter() - .map(|l| Line { - account: l.account.clone(), - side: match l.side { - Side::Debit => Side::Credit, - Side::Credit => Side::Debit, - }, - amount: l.amount, - }) - .collect(), - closing: false, - reversed: false, - } - } else if let Some(b) = &e.bill { - let total = document_total( - b.amount.as_deref(), - &b.lines.iter().map(|l| l.amount.clone()).collect::>(), - )?; - if b.lines.iter().any(|l| l.project.is_some()) { - return Err("project tags are not modelled".into()); - } - Posted { - date: day.ok_or("undated")?, - lines: vec![ - Line { - account: "6999".into(), - side: Side::Debit, - amount: total, - }, - Line { - account: "2000".into(), - side: Side::Credit, - amount: total, - }, - ], - closing: false, - reversed: false, - } - } else if let Some(i) = &e.invoice { - let total = document_total( - i.amount.as_deref(), - &i.lines.iter().map(|l| l.amount.clone()).collect::>(), - )?; - if i.lines.iter().any(|l| l.project.is_some()) { - return Err("project tags are not modelled".into()); - } - Posted { - date: day.ok_or("undated")?, - lines: vec![ - Line { - account: "1200".into(), - side: Side::Debit, - amount: total, - }, - Line { - account: "4000".into(), - side: Side::Credit, - amount: total, - }, - ], - closing: false, - reversed: false, - } - } else if let Some(c) = &e.close { - if c.to != "retained_earnings" { - return Err("a close to the members is not modelled".into()); - } - let end = day.ok_or("undated")?; - let mut out = Vec::new(); - let mut net = Decimal::ZERO; - let codes: Vec = self.types.keys().cloned().collect(); - for code in codes { - let ty = self.types[&code].as_str(); - if ty != "revenue" && ty != "expense" { - continue; - } - let s = self.signed(&code, None, end, true); - if s.is_zero() { - continue; - } - net -= s; // net income is credit-normal: revenue - expense - out.push(Line { - account: code, - side: if s > Decimal::ZERO { - Side::Credit - } else { - Side::Debit - }, - amount: s.abs(), - }); - } - if !net.is_zero() { - out.push(Line { - account: "3999".into(), - side: if net > Decimal::ZERO { - Side::Credit - } else { - Side::Debit - }, - amount: net.abs(), - }); - } - self.closed_through.push(end); - Posted { - date: end, - lines: out, - closing: true, - reversed: false, - } - } else { - return Err("an event the reference model does not post".into()); - }; - // The symbolic form of BS-entry-1: what is posted balances. - let (d, c): (Decimal, Decimal) = - posted - .lines - .iter() - .fold((Decimal::ZERO, Decimal::ZERO), |(d, c), l| match l.side { - Side::Debit => (d + l.amount, c), - Side::Credit => (d, c + l.amount), - }); - if d != c { - return Err(format!( - "the example posts an unbalanced entry (debits {d}, credits {c})" - )); - } - self.entries.push(posted); - self.event_entry.insert(n, self.entries.len() - 1); - Ok(()) - } -} - -fn document_total(amount: Option<&str>, lines: &[String]) -> Result { - match amount { - Some(a) => dec(a).ok_or_else(|| "an unreadable amount".into()), - None => lines - .iter() - .map(|l| dec(l).ok_or_else(|| "an unreadable amount".to_string())) - .sum(), - } -} - -/// Derive what the specification says about `ex`. -pub fn derive(ex: &Example) -> Derivation { - let mut results = Vec::new(); - let mut model = Model::new(ex); - // The expectations asked after event N are checked right after it; the - // rest at the end. The model keeps one state per point, so replay. - let mut usable = true; - let mut why_not = String::new(); - let unsupported_setup = !ex.setup.assets.is_empty() - || !ex.setup.schedules.is_empty() - || !ex.setup.fixed_assets.is_empty() - || !ex.setup.members.is_empty(); - if unsupported_setup { - usable = false; - why_not = "the setup holds assets, schedules or members, which the reference model does not carry".into(); - } - for (i, e) in ex.events.iter().enumerate() { - let n = i + 1; - if usable { - match &e.refused { - Some(reason) => match model.refusal_justified(e, reason) { - Ok(rule) => results.push(( - format!("event {n} refused {reason}"), - Derived::Agrees { rule }, - )), - Err(why) => results.push(( - format!("event {n} refused {reason}"), - if why.contains("not derived") { - Derived::Skipped { why } - } else { - Derived::Disagrees { - rule: rule(Area::Entry, 1), - why, - } - }, - )), - }, - None => { - if let Err(why) = model.post(n, e) { - usable = false; - why_not = format!("event {n}: {why}"); - } - } - } - } - for x in ex.expect.iter().filter(|x| x.after == Some(n)) { - results.push(expectation(&model, x, usable, &why_not)); - } - } - for x in ex.expect.iter().filter(|x| x.after.is_none()) { - results.push(expectation(&model, x, usable, &why_not)); - } - Derivation { - example: ex.id.clone(), - results, - } -} - -fn expectation(model: &Model, x: &Expect, usable: bool, why_not: &str) -> (String, Derived) { - let what = format!( - "{} {}", - x.query, - x.as_of.as_deref().or(x.to.as_deref()).unwrap_or("") - ); - if !usable { - return ( - what, - Derived::Skipped { - why: why_not.to_string(), - }, - ); - } - let Some(Values::Figures(want)) = &x.values else { - return ( - what, - Derived::Skipped { - why: "no figures to derive".into(), - }, - ); - }; - let derived = match x.query.as_str() { - "trial_balance" => x.as_of.as_deref().and_then(date).map(|d| { - ( - rule(Area::Statement, 1), - want.keys() - .map(|k| (k.clone(), model.signed(k, None, d, true))) - .collect::>(), - ) - }), - "balances" => x.as_of.as_deref().and_then(date).map(|d| { - ( - rule(Area::Ledger, 3), - want.keys() - .map(|k| { - ( - k.clone(), - model.on_normal_side(k, model.signed(k, None, d, true)), - ) - }) - .collect(), - ) - }), - "profit_and_loss" => { - let from = x.from.as_deref().and_then(date); - let to = x.to.as_deref().and_then(date); - from.zip(to).map(|(f, t)| { - let mut figs = Vec::new(); - for k in want.keys() { - if k == "net_income" { - let mut net = Decimal::ZERO; - for (code, ty) in &model.types { - if ty == "revenue" || ty == "expense" { - net += model - .on_normal_side(code, model.signed(code, Some(f), t, false)) - * if ty == "revenue" { - Decimal::ONE - } else { - -Decimal::ONE - }; - } - } - figs.push((k.clone(), net)); - } else { - figs.push(( - k.clone(), - model.on_normal_side(k, model.signed(k, Some(f), t, false)), - )); - } - } - (rule(Area::Statement, 3), figs) - }) - } - "entry" => x.entry.and_then(|n| model.event_entry.get(&n)).map(|idx| { - let e = &model.entries[*idx]; - let figs = want - .keys() - .map(|k| { - let (code, side) = k.split_once('.').unwrap_or((k, "")); - let v: Decimal = e - .lines - .iter() - .filter(|l| { - l.account == code - && matches!( - (side, l.side), - ("debit", Side::Debit) | ("credit", Side::Credit) - ) - }) - .map(|l| l.amount) - .sum(); - (k.clone(), v) - }) - .collect(); - (rule(Area::Ledger, 4), figs) - }), - other => { - return ( - what, - Derived::Skipped { - why: format!("{other} is not derived by the reference model"), - }, - ); - } - }; - let Some((rule, figs)) = derived else { - return ( - what, - Derived::Skipped { - why: "an undated or unresolved query".into(), - }, - ); - }; - for (k, got) in figs { - match want.get(&k).and_then(|w| dec(w)) { - Some(w) if w == got => {} - Some(w) => { - return ( - what, - Derived::Disagrees { - rule, - why: format!("{k}: the example says {w}, the rule derives {got}"), - }, - ); - } - None => { - return ( - what, - Derived::Disagrees { - rule, - why: format!("{k}: no exact figure"), - }, - ); - } - } - } - // The laws on the whole book, at the day asked: BS-statement-2. - if let (true, Some(d)) = ( - matches!(x.query.as_str(), "trial_balance" | "balances"), - x.as_of.as_deref().and_then(date), - ) { - let (mut a, mut l, mut e, mut r, mut xp) = ( - Decimal::ZERO, - Decimal::ZERO, - Decimal::ZERO, - Decimal::ZERO, - Decimal::ZERO, - ); - let mut net = Decimal::ZERO; - for (code, ty) in &model.types { - let s = model.signed(code, None, d, true); - net += s; - let n = model.on_normal_side(code, s); - match ty.as_str() { - "asset" => a += n, - "liability" => l += n, - "equity" => e += n, - "revenue" => r += n, - "expense" => xp += n, - _ => {} - } - } - if !net.is_zero() { - return ( - what, - Derived::Disagrees { - rule: self::rule(Area::Statement, 1), - why: format!("the books do not net to zero ({net})"), - }, - ); - } - if a != l + e + (r - xp) { - return ( - what, - Derived::Disagrees { - rule: self::rule(Area::Statement, 2), - why: format!("A {a} != L {l} + E {e} + (R {r} - X {xp})"), - }, - ); - } - } - (what, Derived::Agrees { rule }) -} diff --git a/bookspec/checker/src/engine.rs b/bookspec/checker/src/engine.rs deleted file mode 100644 index 372d3eb..0000000 --- a/bookspec/checker/src/engine.rs +++ /dev/null @@ -1,224 +0,0 @@ -//! The interface an accounting engine implements to be checked against -//! bookspec (`plan/served.md` P13, D-34). -//! -//! bookspec is a specification first: its examples are the concrete cases -//! (`bookspec/examples/*.toml`), its rule identifiers the normative rules -//! (`BS--`), and this trait the only thing an engine has to -//! provide for the runner to check it. The engine's answers are compared -//! with the examples' expected values by the runner; an engine is never -//! asked whether it agrees with itself. - -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; -use std::str::FromStr; - -use crate::rules::{Area, Rule, rule_for_id, rule_for_refusal}; -use crate::schema::{ChangeSpec, Event, Query, Setup}; - -/// A bookspec rule identifier: `BS--` (e.g. `BS-entry-1`). -/// -/// The areas are the specification's subjects (`Area`), and `n` counts -/// from 1 within an area. The catalogue in [`mod@crate::rules`] is the list of -/// every identifier the specification states; an identifier that is not in -/// it is a typo, not a rule. -#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct RuleId(String); - -/// Why a string is not a bookspec rule identifier. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct RuleIdError { - what: String, -} - -impl fmt::Display for RuleIdError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!(f, "{}", self.what) - } -} - -impl std::error::Error for RuleIdError {} - -impl RuleId { - /// The identifier of area `area`, rule number `n` (counting from 1). - pub fn new(area: Area, n: u32) -> Self { - assert!(n >= 1, "bookspec rule numbers count from 1"); - Self(format!("BS-{}-{n}", area.key())) - } - - /// Parse `BS--`, refusing an unknown area, a missing or - /// non-numeric number, and a number that is zero or written with a - /// leading zero (one spelling per rule). - pub fn parse(s: &str) -> Result { - let rest = s.strip_prefix("BS-").ok_or_else(|| RuleIdError { - what: format!("{s:?} is no bookspec rule id (BS--)"), - })?; - let (area, n) = rest.rsplit_once('-').ok_or_else(|| RuleIdError { - what: format!("{s:?} is no bookspec rule id (BS--)"), - })?; - let area = Area::parse(area).ok_or_else(|| RuleIdError { - what: format!("{s:?}: {:?} is no bookspec area", area), - })?; - if n.is_empty() || !n.bytes().all(|b| b.is_ascii_digit()) { - return Err(RuleIdError { - what: format!("{s:?}: {n:?} is no rule number"), - }); - } - if n.starts_with('0') { - return Err(RuleIdError { - what: format!("{s:?}: rule numbers count from 1, without a leading zero"), - }); - } - let n: u32 = n.parse().map_err(|_| RuleIdError { - what: format!("{s:?}: {n:?} is no rule number"), - })?; - if n == 0 { - return Err(RuleIdError { - what: format!("{s:?}: rule numbers count from 1"), - }); - } - Ok(Self::new(area, n)) - } - - /// The identifier as the specification writes it. - pub fn as_str(&self) -> &str { - &self.0 - } - - /// The area it belongs to. - pub fn area(&self) -> Area { - // Constructed only by `new`/`parse`, both of which check the area. - Area::parse(self.0.split('-').nth(1).expect("a checked rule id")) - .expect("a checked rule id's area") - } - - /// The rule's entry in the catalogue, if the specification states it. - pub fn rule(&self) -> Option<&'static Rule> { - rule_for_id(self) - } -} - -impl fmt::Display for RuleId { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - f.write_str(&self.0) - } -} - -impl FromStr for RuleId { - type Err = RuleIdError; - - fn from_str(s: &str) -> Result { - Self::parse(s) - } -} - -/// An engine's refusal: the rule that refuses, and why in the example's -/// words (bookspec/README.md's refusal vocabulary, never an engine's own -/// message). -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Refusal { - /// The bookspec rule the refusal belongs to. - pub rule: RuleId, - /// What was refused, in the specification's words. - pub reason: String, -} - -impl Refusal { - /// A refusal of `reason` under `rule`. - pub fn new(rule: RuleId, reason: impl Into) -> Self { - Self { - rule, - reason: reason.into(), - } - } - - /// A refusal of `reason`, under the rule the specification assigns - /// that refusal (see [`rule_for_refusal`]); an unknown reason is a - /// specification defect, and the first rule of the ledger area is - /// named so the report still has an identifier to show. - pub fn of_reason(reason: impl Into) -> Self { - let reason = reason.into(); - let rule = rule_for_refusal(&reason) - .map(|r| r.id.clone()) - .unwrap_or_else(|| RuleId::new(Area::Ledger, 1)); - Self { rule, reason } - } -} - -/// What an engine declares about itself: the rules it takes the -/// conformance test on, and the option it picks for each of the -/// specification's choices (`CHOICE`: the specification names the options, -/// an engine declares its pick; `plan/served.md` P13 step 3). -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Claims { - /// The rule identifiers the engine claims. - pub rules: BTreeSet, - /// The choice rules whose option the engine picks, and the option. - pub choices: BTreeMap, -} - -impl Claims { - /// The claims of an engine that claims `rules`, with no choices. - pub fn new(rules: impl IntoIterator) -> Self { - Self { - rules: rules.into_iter().collect(), - choices: BTreeMap::new(), - } - } - - /// True when `rule` is claimed. - pub fn claims(&self, rule: &RuleId) -> bool { - self.rules.contains(rule) - } - - /// Declare the option this engine picks for `rule`. - pub fn with_choice(mut self, rule: RuleId, option: impl Into) -> Self { - self.choices.insert(rule, option.into()); - self - } - - /// The option this engine picks for `rule`, if it declares one. - pub fn choice(&self, rule: &RuleId) -> Option<&str> { - self.choices.get(rule).map(String::as_str) - } -} - -/// An accounting engine, as bookspec checks it. -/// -/// The four methods are the plan's (`plan/served.md` 8.2 P13 step 2); the -/// fifth, [`Engine::change`], is the one departure: an example's -/// `[[changes]]` (a rename, the tax-category policy, an asset correction) -/// posts no entry, so it is neither a setup nor an event, and the plan's -/// four methods have no path for it. It is required, never defaulted, so -/// an engine cannot silently skip the examples that use one. -pub trait Engine { - /// Set the books up: the chart, the fixed assets, the schedules, the - /// parties, the members, the payees and their dated decisions, the - /// payment methods and the projects. - fn load(&mut self, setup: &Setup) -> Result<(), Refusal>; - - /// Apply one event: an entry, a reversal, a close, a bill, an invoice, - /// a depreciation run, a disposal, a schedules run or a schedule's - /// registration. An event the engine refuses leaves the books as they - /// were. - fn apply(&mut self, event: &Event) -> Result<(), Refusal>; - - /// Answer one query with the engine's own figures. A figure is an - /// exact decimal string (never a JSON number: no engine may round a - /// cent through a float), a date is `YYYY-MM-DD`, and a format - /// query's value is its lines or rows. A query the engine refuses - /// (a period the rule refuses, a command it refuses) is an - /// [`Err`]. - fn query(&self, q: &Query) -> Result; - - /// The rules and choices this engine declares. - fn claims(&self) -> Claims; - - /// Apply one change that posts no entry (a `[[changes]]` entry). - fn change(&mut self, change: &ChangeSpec) -> Result<(), Refusal>; -} - -/// The refusal an engine reports for an error it has no example-word -/// reason for: the reason as given, under `fallback`. -pub fn fallback_refusal(fallback: RuleId, reason: impl Into) -> Refusal { - Refusal::new(fallback, reason) -} diff --git a/bookspec/checker/src/lib.rs b/bookspec/checker/src/lib.rs deleted file mode 100644 index 81bbb56..0000000 --- a/bookspec/checker/src/lib.rs +++ /dev/null @@ -1,25 +0,0 @@ -//! bookspec's checker: the example schema, the rule identifiers, and the -//! [`Engine`] trait an accounting engine implements (`plan/served.md` P13, -//! D-34). -//! -//! bookspec is a specification first: every rule is stated normatively, -//! sourced, and demonstrated by concrete examples (`bookspec/examples/`). -//! This crate is the machine-readable part: [`Example`] is the schema and -//! its parser, [`mod@rules`] the catalogue (`BS--`), and [`Engine`] -//! the only interface a conformance runner needs. The runner lives in -//! `bookspec-runner`; wanderbooks' adapter is -//! `crates/ledger-core/tests/bookspec.rs`. -//! -//! The crate knows nothing of any engine: no money type, no ledger, no -//! store. That is what lets a second engine (the GnuCash oracle, later) be -//! checked by the same examples. - -pub mod derive; -pub mod engine; -pub mod rules; -pub mod schema; - -pub use derive::{Derivation, Derived, derive}; -pub use engine::{Claims, Engine, Refusal, RuleId, RuleIdError, fallback_refusal}; -pub use rules::{Area, Rule, Status, covered_rules, rule, rule_for_id, rule_for_refusal, rules}; -pub use schema::*; diff --git a/bookspec/checker/src/rules.rs b/bookspec/checker/src/rules.rs deleted file mode 100644 index 94087c4..0000000 --- a/bookspec/checker/src/rules.rs +++ /dev/null @@ -1,594 +0,0 @@ -//! bookspec's rule identifiers and their catalogue. -//! -//! Every rule of `docs/spec/rules.md` (B-1 to B-45) has one bookspec -//! identifier, `BS--`, allocated by [`Area`] and numbered in -//! B-rule order within its area. `bookspec/spec/rules-index.md` is the -//! table, and a test keeps the two in step. The rules from B-39 on are -//! allocated in `plan/served.md` 8.0.3 but not yet stated; they carry an -//! identifier and an area and no status, so a later unit fills them in -//! without moving anyone else's number. - -use std::sync::LazyLock; - -use crate::engine::RuleId; - -/// One of bookspec's areas: the subject a rule belongs to. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Area { - /// The mechanics of an entry: lines, sides, amounts, dates. - Entry, - /// The ledger: the chart, accounts, the journal, the format. - Ledger, - /// Reports: the trial balance, the accounting equation, statements. - Statement, - /// Periods: closes, closed periods, what an omitted date means. - Period, - /// Partners and parties: shares, members' capital, the directory. - Partner, - /// Fixed assets and capitalization. - Asset, - /// Prepaid expenses and deferred revenue. - Prepaid, - /// Receivables and payables: documents, aging, settlements. - Arap, - /// Revenue: when it is recognized and how it is presented. - Revenue, - /// Information returns: 1099s and their thresholds. - Tax1099, - /// Interchange formats: the journal format gate and the IIF. - Format, -} - -impl Area { - /// Every area, in the order the specification lists them. - pub const ALL: [Area; 11] = [ - Area::Entry, - Area::Ledger, - Area::Statement, - Area::Period, - Area::Partner, - Area::Asset, - Area::Prepaid, - Area::Arap, - Area::Revenue, - Area::Tax1099, - Area::Format, - ]; - - /// The area's key in a rule identifier. - pub fn key(self) -> &'static str { - match self { - Area::Entry => "entry", - Area::Ledger => "ledger", - Area::Statement => "statement", - Area::Period => "period", - Area::Partner => "partner", - Area::Asset => "asset", - Area::Prepaid => "prepaid", - Area::Arap => "arap", - Area::Revenue => "revenue", - Area::Tax1099 => "1099", - Area::Format => "format", - } - } - - /// The area `key` names. - pub fn parse(key: &str) -> Option { - Self::ALL.into_iter().find(|a| a.key() == key) - } -} - -/// The status of a rule, in bookspec's vocabulary: what its statement -/// rests on. `docs/spec/rules.md`'s "DECISION" is bookspec's [`Status::Choice`] -/// (`plan/served.md` P13 step 3): the specification names the options, and -/// an engine declares its pick through [`crate::Claims`]. -#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub enum Status { - /// A cited authority states the rule. - Authority, - /// Common practice, where no authority states the rule. - Convention, - /// A human's or the house's choice among options the specification - /// names. - Choice, -} - -impl Status { - /// The status as the specification writes it. - pub fn key(self) -> &'static str { - match self { - Status::Authority => "AUTHORITY", - Status::Convention => "CONVENTION", - Status::Choice => "CHOICE", - } - } - - /// The status `key` names. - pub fn parse(key: &str) -> Option { - [Status::Authority, Status::Convention, Status::Choice] - .into_iter() - .find(|s| s.key() == key) - } -} - -/// One rule of the specification. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct Rule { - /// Its number in `docs/spec/rules.md`, 1 to 45. - pub b: u8, - /// Its bookspec identifier. - pub id: RuleId, - /// Its area. - pub area: Area, - /// Its normative title, as `docs/spec/rules.md` states it. - pub title: &'static str, - /// What its statement rests on; `None` for a rule allocated but not - /// yet stated (B-39 to B-45, `plan/served.md` 8.0.3). - pub status: Option, - /// The example ids that demonstrate it, by `Example::id`. - pub examples: &'static [&'static str], -} - -impl Rule { - /// True when the rule has at least one example to check it. - pub fn has_examples(&self) -> bool { - !self.examples.is_empty() - } -} - -/// The catalogue: every rule, in B-rule order. -pub fn rules() -> &'static [Rule] { - static RULES: LazyLock> = LazyLock::new(build); - &RULES -} - -/// The rule `b` (1 to 45). -pub fn rule(b: u8) -> Option<&'static Rule> { - rules().iter().find(|r| r.b == b) -} - -/// The rule `id` names, if the specification states it. -pub fn rule_for_id(id: &RuleId) -> Option<&'static Rule> { - rules().iter().find(|r| &r.id == id) -} - -/// The rule a refusal belongs to, by its reason in the examples' words -/// (bookspec/README.md). -pub fn rule_for_refusal(reason: &str) -> Option<&'static Rule> { - let b = match reason { - "unbalanced" | "too_few_lines" => 1, - "negative_amount" | "zero_amount" => 2, - "finer_than_minor_unit" => 3, - "parent_type_mismatch" => 4, - "already_reversed" => 10, - "period_closed" => 12, - "end_before_start" | "start_after_today" => 30, - "blank" | "unchanged" | "sibling_has_it" | "holds_a_tin" | "not_one_line" - | "code_taken" => 31, - "tax_category_required" => 32, - "depreciation_past_disposal" - | "entry_irreversible" - | "already_disposed" - | "invalid_depreciation_period" => 15, - "invalid_schedule" => 16, - "untagged_control_line" => 38, - _ => return None, - }; - rule(b) -} - -/// The rules an engine can claim today: those with at least one example. -/// A rule with no example has no evidence, so an engine cannot claim it -/// (`plan/served.md` P13 step 4 fills the rest in). -pub fn covered_rules() -> Vec<&'static Rule> { - rules().iter().filter(|r| r.has_examples()).collect() -} - -fn build() -> Vec { - // The areas' ordinals are assigned here in B-rule order, one line per - // rule: an area's n is its place in `Area::ALL`'s set of rules. - let mut next = std::collections::BTreeMap::new(); - let mut out = Vec::with_capacity(45); - for (b, area, title, status, examples) in SPEC { - let n: u32 = *next.entry(*area).and_modify(|n| *n += 1).or_insert(1); - out.push(Rule { - b: *b, - id: RuleId::new(*area, n), - area: *area, - title, - status: *status, - examples, - }); - } - out -} - -type Spec = &'static [( - u8, - Area, - &'static str, - Option, - &'static [&'static str], -)]; - -const SPEC: Spec = &[ - ( - 1, - Area::Entry, - "Every entry is a double entry: debits equal credits", - Some(Status::Authority), - &[ - "entry-an-unbalanced-entry-is-refused", - "statement-the-books-balance-and-the-equation-holds", - ], - ), - ( - 2, - Area::Entry, - "A line's amount is positive; its side gives the direction", - Some(Status::Convention), - &["entry-a-line-amount-is-positive"], - ), - ( - 3, - Area::Ledger, - "One money through the ledger; foreign amounts at the transaction-date rate", - Some(Status::Authority), - &["ledger-a-fraction-of-a-cent-is-refused"], - ), - ( - 4, - Area::Ledger, - "Five elements, closed list", - Some(Status::Authority), - &["ledger-a-child-account-has-its-parents-type"], - ), - ( - 5, - Area::Ledger, - "Normal balances", - Some(Status::Convention), - &["statement-the-books-balance-and-the-equation-holds"], - ), - ( - 6, - Area::Statement, - "The trial balance balances", - Some(Status::Authority), - &["statement-the-books-balance-and-the-equation-holds"], - ), - ( - 7, - Area::Statement, - "The accounting equation", - Some(Status::Authority), - &["statement-the-books-balance-and-the-equation-holds"], - ), - ( - 8, - Area::Ledger, - "Posted is forever: the book is written only forward", - Some(Status::Authority), - &["ledger-a-mistake-is-reversed-never-erased"], - ), - ( - 9, - Area::Ledger, - "A mistake is corrected by an opposite entry, never erased", - Some(Status::Authority), - &["ledger-a-mistake-is-reversed-never-erased"], - ), - ( - 10, - Area::Ledger, - "An entry is reversed at most once; a closing entry never", - Some(Status::Choice), - &["ledger-a-mistake-is-reversed-never-erased"], - ), - ( - 11, - Area::Period, - "Revenue and expenses close into profit and loss, and that into capital", - Some(Status::Authority), - &["period-a-close-sweeps-the-year-into-capital"], - ), - ( - 12, - Area::Period, - "A closed period takes no new postings; periods close in order", - Some(Status::Convention), - &["period-a-close-sweeps-the-year-into-capital"], - ), - ( - 13, - Area::Partner, - "Partners share income by the partnership agreement; capital accounts carry it", - Some(Status::Authority), - &[ - "members-close.odd-cent-across-closes", - "members-close.statement-before-and-after-a-close", - "members-close.thirds-over-twelve-months", - "members-close.true-up-of-a-per-close-split", - "p3-party-members-equity-and-k1", - ], - ), - ( - 14, - Area::Statement, - "Statements: P&L for a period; cash flows by the direct method", - Some(Status::Authority), - &[ - "cash-flow.debt-to-equity", - "cash-flow.government-fees", - "cash-flow.paid-on-the-companys-behalf", - "cash-flow.part-cash-purchase", - "cash-flow.reversal-of-a-reversal", - "cash-flow.reversed-property-contribution", - "cash-flow.two-member-llc-first-year", - "period-a-close-sweeps-the-year-into-capital", - ], - ), - ( - 15, - Area::Asset, - "Depreciation: straight line, salvage never depreciated, partial months by days", - Some(Status::Convention), - &[ - "assets-acquired-before-in-service", - "assets-a-reversed-run-is-not-posted", - "assets-book-value-is-what-the-books-carry", - "depreciation-catch-up-month", - "depreciation-disposal-after-a-late-change-undone", - "depreciation-disposal-before-a-later-run", - "depreciation-disposal-in-final-month", - "depreciation-disposal-inside-a-posted-month", - "depreciation-disposal-mid-month", - "depreciation-final-month-by-days", - "depreciation-final-month-drift", - "depreciation-reversal-before-its-run-is-refused", - "depreciation-run-reversed-after-its-month", - ], - ), - ( - 16, - Area::Prepaid, - "Prepaid expenses and deferred revenue are recognized over the period benefited or earned", - Some(Status::Authority), - &[ - "schedule-daily-deferred-revenue", - "schedule-daily-small-first-and-last-month", - "schedule-equal-months-start-on-the-first", - ], - ), - ( - 17, - Area::Revenue, - "Platform payouts are recorded gross when the business is the principal", - Some(Status::Authority), - &[], - ), - ( - 18, - Area::Arap, - "Aging buckets: Current, 1-30, 31-60, 61-90, 90+ days past due", - Some(Status::Convention), - &[], - ), - ( - 19, - Area::Tax1099, - "1099 reporting thresholds, payment methods, and decisions", - Some(Status::Authority), - &[ - "1099-a-dated-status-applies-by-payment-date", - "1099-a-members-check-through-financing-counts", - "1099-and-party-totals-for-a-members-payment", - "1099-card-payments-are-excluded", - "1099-money-between-own-accounts-pays-no-one", - "1099-money-from-a-payee-is-netted-only-as-a-refund", - "1099-the-threshold-is-the-years", - "1099-worksheet-through-as-of", - ], - ), - ( - 20, - Area::Ledger, - "Every transaction has supporting documents; records are kept", - Some(Status::Authority), - &[], - ), - ( - 21, - Area::Entry, - "Exact amounts in the books; rounding only on returns", - Some(Status::Authority), - &["entry-cents-add-exactly"], - ), - ( - 22, - Area::Asset, - "Expense or capitalize (book policy, mirrors tax)", - Some(Status::Authority), - &[], - ), - ( - 23, - Area::Ledger, - "Control roles: one account each, of the right kind, moved only when empty", - Some(Status::Convention), - &["arap-hand-control-posting-refused"], - ), - ( - 24, - Area::Asset, - "Asset tags: one label per asset, never reused", - Some(Status::Choice), - &[], - ), - ( - 25, - Area::Asset, - "Tax figures beside book value: the section 723 basis, the depreciable basis, the section 704(c) built-in gain", - Some(Status::Authority), - &[ - "assets-723-basis-apart-from-depreciable-basis", - "assets-built-in-gain-fixed-at-contribution", - "assets-four-contributed-devices", - ], - ), - ( - 26, - Area::Partner, - "Parties: one directory, no full TIN in the books", - Some(Status::Authority), - &["p3-party-tin-last4"], - ), - ( - 27, - Area::Format, - "The journal format gate: newer is not corrupt", - Some(Status::Choice), - &[], - ), - ( - 28, - Area::Entry, - "What new commands take: four-digit years, amounts a line can carry", - Some(Status::Convention), - &[], - ), - ( - 29, - Area::Ledger, - "The books keep how they are kept", - Some(Status::Choice), - &[], - ), - ( - 30, - Area::Period, - "A report asked for without dates covers this fiscal year so far", - Some(Status::Authority), - &["default-period-fiscal-year-to-date"], - ), - ( - 31, - Area::Ledger, - "Renaming an account changes its label, nothing else", - Some(Status::Choice), - &["rename-changes-no-amount"], - ), - ( - 32, - Area::Ledger, - "Every revenue and expense account maps to a line of the return", - Some(Status::Authority), - &["tax-category-required"], - ), - ( - 33, - Area::Statement, - "A package as of a day shows the books through that day", - Some(Status::Convention), - &["as-of-package-stops-at-as-of"], - ), - ( - 34, - Area::Format, - "The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out, classes carried", - Some(Status::Authority), - &["iif-quickbooks-journal-file"], - ), - ( - 35, - Area::Partner, - "A party's part of an entry, and a party's totals", - Some(Status::Convention), - &[ - "p3-party-accrual-counts-once", - "p3-party-distribution-still-owed", - "p3-party-gift-card-is-a-promise", - "p3-party-members-equity-and-k1", - "p3-party-own-liability-never-pays", - "p3-party-reversed-card-payment", - "p3-party-signs-and-reversals", - ], - ), - ( - 36, - Area::Partner, - "The K-1 worksheet reports tax-basis capital beside book capital", - Some(Status::Authority), - &[ - "partnership-k1-tax-basis-capital", - "partnership-k1-tax-basis-odd-cents", - ], - ), - ( - 37, - Area::Statement, - "A project is a class with its facts; its figures are its tagged lines", - Some(Status::Choice), - &[ - "projects-invoice-and-bill-lines-count-by-project", - "projects-net-is-its-tagged-lines", - "projects-on-spec-costs-are-expenses", - ], - ), - ( - 38, - Area::Arap, - "A document's figures are its ledger lines that stand; a control account is its documents", - Some(Status::Choice), - &["arap-hand-control-posting-refused"], - ), - ( - 39, - Area::Arap, - "Terms: when a document is due (allocated, not yet stated)", - None, - &[], - ), - ( - 40, - Area::Arap, - "Numbers and the issued copy (allocated, not yet stated)", - None, - &[], - ), - ( - 41, - Area::Arap, - "A debt that will not be collected is written off, and a recovery is income", - Some(Status::Choice), - &["bad-debt-direct-write-off"], - ), - ( - 42, - Area::Arap, - "A settlement is exact, one party, and names its lines; the gross discount and the gross fee", - Some(Status::Convention), - &[], - ), - ( - 43, - Area::Entry, - "Drafts and evidence (allocated, not yet stated)", - None, - &[], - ), - ( - 44, - Area::Ledger, - "Evidence is read from the bytes, never trusted over them; identical files are kept once", - Some(Status::Convention), - &[], - ), - ( - 45, - Area::Ledger, - "Audits and attestations (allocated, not yet stated)", - None, - &[], - ), -]; diff --git a/bookspec/checker/src/schema.rs b/bookspec/checker/src/schema.rs deleted file mode 100644 index aa78ab7..0000000 --- a/bookspec/checker/src/schema.rs +++ /dev/null @@ -1,1941 +0,0 @@ -//! The shape of a bookspec example: the TOML schema, the parser, and the -//! rules a well-formed example follows whatever engine runs it. -//! -//! One file per example, named for its `id` (`bookspec/README.md` gives the -//! shape in prose; this module is its executable form). Every structure -//! refuses a key it does not know (`deny_unknown_fields`): a typo is an -//! error, never silently ignored. [`Example::from_toml`] parses and then -//! checks the shape; an engine's own adapter only has to execute it. - -use std::collections::{BTreeMap, BTreeSet}; -use std::fmt; - -use chrono::NaiveDate; -use rust_decimal::Decimal; -use serde::Deserialize; - -use crate::rules::rule_for_refusal; - -/// One bookspec example: a setup, its events and changes, and what it -/// expects of the books. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Example { - /// Stable, never reused; the file is named for it. - pub id: String, - /// What the example shows, in words. - pub title: String, - /// The normative sentence the example demonstrates: what MUST (or MUST - /// NOT) hold. - pub rule: String, - /// What the expected values rest on: `AUTHORITY`, `CONVENTION` or - /// `CHOICE`. - pub status: String, - /// Exact citations, non-empty. - pub sources: Vec, - /// Applicability tags, non-empty. - pub tags: Vec, - /// `"formats"`: an interchange format's example rather than an - /// accounting rule's (P13's appendix of formats); absent otherwise. - #[serde(default)] - pub appendix: Option, - /// The books before the first event. - pub setup: Setup, - /// The events, in order, counting from 1. - pub events: Vec, - /// Changes that post no entry, each made after the event it names. - #[serde(default)] - pub changes: Vec, - /// What is expected of the books, at least one. - pub expect: Vec, -} - -/// The books an example sets up, before any event. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Setup { - /// ISO 4217 currency code. - pub currency: String, - /// `MM-DD`. - pub fiscal_year_start: String, - /// `accrual` or `cash`. - pub basis: String, - /// How a figure is rounded to the currency's minor unit: `half-even`, - /// `half-away-from-zero` or `largest-remainder`. - pub rounding: String, - /// The chart, parents before their subaccounts. - pub accounts: Vec, - /// The cash accounts (ASC 230-10-45-10 to 45-15). - #[serde(default)] - pub cash_accounts: Vec, - /// The financing accounts. - #[serde(default)] - pub financing_accounts: Vec, - /// The investing accounts. - #[serde(default)] - pub investing_accounts: Vec, - /// Accounts that hold property and equipment, for an example whose - /// individual assets do not matter. - #[serde(default)] - pub fixed_assets: Vec, - /// Fixed assets registered, with their figures (B-15). - #[serde(default)] - pub assets: Vec, - /// Prepaid-expense and deferred-revenue schedules (B-16). - #[serde(default)] - pub schedules: Vec, - /// The company's directory (B-26). - #[serde(default)] - pub parties: Vec, - /// The members of a multi-member LLC (B-13). - #[serde(default)] - pub members: Vec, - /// Payees marked for an information return (B-19). - #[serde(default)] - pub payees: Vec, - /// A payee's 1099 decision from a date on (B-19, D-40). - #[serde(default)] - pub status_changes: Vec, - /// The ways the company (or a member, for it) pays (B-19). - #[serde(default)] - pub instruments: Vec, - /// The projects (B-37). - #[serde(default)] - pub projects: Vec, - /// The day the books are asked on: what an omitted date means (B-30). - #[serde(default)] - pub today: Option, -} - -/// A project (B-37): its name, and its kind, status and party if given. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ProjectSpec { - /// Its name, once each. - pub name: String, - /// `internal`, `client`, `on_spec` or `other`. - #[serde(default)] - pub kind: Option, - /// `active`, `paused`, `finished` or `abandoned`. - #[serde(default)] - pub status: Option, - /// A `setup.parties` name. - #[serde(default)] - pub party: Option, -} - -/// One account of the chart. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct AccountSpec { - /// Its code. - pub code: String, - /// Its name. - pub name: String, - /// `asset`, `liability`, `equity`, `revenue` or `expense`. - #[serde(rename = "type")] - pub kind: String, - /// Its parent's code, declared before it. - #[serde(default)] - pub parent: Option, - /// A header groups other accounts and takes no postings. - #[serde(default)] - pub header: bool, - /// Its tax category, a key of the catalog (B-32). - #[serde(default)] - pub tax_category: Option, -} - -/// Accounts that hold property and equipment, for an example no asset's -/// own figures matter to. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct FixedAsset { - /// The cost account. - pub cost_account: String, - /// The accumulated depreciation account. - pub accumulated_account: String, - /// The depreciation expense account. - pub expense_account: String, -} - -/// A fixed asset registered before the first event (B-15). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct AssetSpec { - /// Its name in this example. - pub id: String, - /// What it is. - pub description: String, - /// Its cost, an exact decimal string. - pub cost: String, - /// Its salvage value. - pub salvage: String, - /// Its useful life in months, above zero. - pub life_months: u32, - /// The day depreciation starts, `YYYY-MM-DD`. - pub in_service: String, - /// `straight_line`, the one method the books have. - pub method: String, - /// The cost account. - pub cost_account: String, - /// The accumulated depreciation account. - pub accumulated_account: String, - /// The depreciation expense account. - pub expense_account: String, - /// The event whose entry put it on the books (B-15 rule 7). - #[serde(default)] - pub acquired_by: Option, - /// How the company got it and the facts its tax figures follow from - /// (B-25). - #[serde(default)] - pub basis: Option, -} - -/// An asset's basis facts (B-25). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BasisSpec { - /// `purchase`, `carryover_723` or `personal_use_conversion`. - pub rule: String, - /// A contribution's contributor's adjusted basis. - #[serde(default)] - pub contributor_adjusted_basis: Option, - /// A contribution's fair market value. - #[serde(default)] - pub fmv_at_contribution: Option, - /// The section 704(c) method, if chosen. - #[serde(default)] - pub sec704c_method: Option, -} - -/// A prepaid expense or deferred revenue recognized over its months -/// (B-16), in `setup.schedules` or registered by an event. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ScheduleSpec { - /// Its name in this example. - pub id: String, - /// `prepaid_expense` or `deferred_revenue`. - pub kind: String, - /// What it is. - pub description: String, - /// The whole amount, an exact decimal string. - pub total: String, - /// The day it starts, `YYYY-MM-DD`. - pub start: String, - /// Its months, above zero. - pub months: u32, - /// `daily` or `monthly_even`. - pub convention: String, - /// The balance-sheet account it sits in. - pub balance_account: String, - /// The profit-and-loss account it recognizes to. - pub pl_account: String, -} - -/// One member of a multi-member LLC (B-13). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct MemberSpec { - /// The member's name; a party's, with the member role, when the - /// example asks about the member as a party. - pub name: String, - /// Their capital account. - pub capital_account: String, - /// Their share of income, an exact decimal fraction above zero; every - /// member has one or none does, and the shares sum to 1. - #[serde(default)] - pub share: Option, - /// Their distributions account (B-13). - #[serde(default)] - pub distributions_account: Option, -} - -/// A payee marked for an information return from the beginning (B-19). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PayeeSpec { - /// Its name, once each. - pub name: String, - /// `1099-NEC` or `1099-MISC`. - pub form: String, -} - -/// One party in the directory (B-26, B-35). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct PartySpec { - /// Its name, once each. - pub name: String, - /// Role keys; `vendor` and `customer` give the party its record (one - /// of them at most). - pub roles: Vec, - /// The liabilities, by code, the company owes the party itself - /// (B-35 item 6). - #[serde(default)] - pub creditor_of: Vec, - /// Its TIN facts' last four (an SSN's). - #[serde(default)] - pub tin_last4: Option, - /// Its W-9 facts' last four (a 1099 role's). - #[serde(default)] - pub w9_tin_last4: Option, - /// The vault's copy of the TIN purged, the facts kept (needs - /// `tin_last4`). - #[serde(default)] - pub tin_purged: bool, -} - -/// A payee's 1099 decision from `effective` on (B-19, D-40). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct StatusChangeSpec { - /// A `setup.payees` name. - pub payee: String, - /// `YYYY-MM-DD`. - pub effective: String, - /// `1099-NEC`, `1099-MISC`, `undetermined`, or `not_reportable` (with - /// `reason_kind` and `reason`). - pub decision: String, - /// Why it is not reportable, from the closed list. - #[serde(default)] - pub reason_kind: Option, - /// The facts, in words. - #[serde(default)] - pub reason: Option, -} - -/// A payment method (B-19). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct InstrumentSpec { - /// Its label, once each. - pub label: String, - /// `bank`, `business_card`, `member_card`, `member_cash_or_check` or - /// `platform`. - pub kind: String, - /// The account it pays through, by code. - pub account: String, - /// The card's or account's last four, if known. - #[serde(default)] - pub last4: Option, - /// The party it belongs to, by name (informational). - #[serde(default)] - pub owner: Option, -} - -/// A party named on an entry, with its share (B-35). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ShareSpec { - /// A `setup.parties` name. - pub party: String, - /// Its share as an exact decimal string. - #[serde(default)] - pub amount: Option, - /// Its share as an exact decimal fraction. - #[serde(default)] - pub percent: Option, -} - -/// One event: an entry, a reversal, a close, a bill, an invoice, a -/// command that posts what is due, or a schedule's registration. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Event { - /// The day of an entry, a reversal, a close, a bill, an invoice or a - /// disposal; the other commands date their own entries. - #[serde(default)] - pub date: Option, - /// An entry's, a reversal's, a close's or a bill's; a command writes - /// its own. - #[serde(default)] - pub description: Option, - /// An entry's lines. - #[serde(default)] - pub lines: Vec, - /// The event (1-based) whose entry this one reverses. - #[serde(default)] - pub reverses: Option, - /// A close of a period. - #[serde(default)] - pub close: Option, - /// A bill. - #[serde(default)] - pub bill: Option, - /// An invoice. - #[serde(default)] - pub invoice: Option, - /// A bad-debt write-off of an earlier invoice. - #[serde(default)] - pub write_off: Option, - /// A depreciation run. - #[serde(default)] - pub run_depreciation: Option, - /// A disposal. - #[serde(default)] - pub dispose_asset: Option, - /// A run of every schedule due through a day. - #[serde(default)] - pub run_schedules: Option, - /// A schedule registered by an event. - #[serde(default)] - pub register_schedule: Option, - /// The engine refuses the event, for this reason in the examples' - /// words (`refusal_reasons`); it changes nothing. - #[serde(default)] - pub refused: Option, - /// The payee (a `setup.payees` name) the entry paid. - #[serde(default)] - pub payee: Option, - /// The parties (a `setup.parties` name) the entry is with, and their - /// shares. - #[serde(default)] - pub parties: Vec, - /// The payment method (a `setup.instruments` label) it was paid with. - #[serde(default)] - pub instrument: Option, - /// An entry as an earlier version could write it and this version's - /// commands refuse (the control guard, B-38): posted as replay posts - /// it, since books that hold it must still read right. - #[serde(default)] - pub older: bool, -} - -impl Event { - /// Which of the event's kinds it is, or why it is not exactly one. - pub fn kind(&self) -> Result { - let kinds = [ - (EventKind::Lines, !self.lines.is_empty()), - (EventKind::Reverses, self.reverses.is_some()), - (EventKind::Close, self.close.is_some()), - (EventKind::Bill, self.bill.is_some()), - (EventKind::Invoice, self.invoice.is_some()), - (EventKind::WriteOff, self.write_off.is_some()), - (EventKind::RunDepreciation, self.run_depreciation.is_some()), - (EventKind::DisposeAsset, self.dispose_asset.is_some()), - (EventKind::RunSchedules, self.run_schedules.is_some()), - ( - EventKind::RegisterSchedule, - self.register_schedule.is_some(), - ), - ]; - let given: Vec = kinds - .iter() - .filter_map(|(kind, given)| given.then_some(*kind)) - .collect(); - match given.as_slice() { - [one] => Ok(*one), - _ => Err( - "an event is one of lines, reverses, close, bill, invoice, run_depreciation, \ - dispose_asset, run_schedules or register_schedule" - .to_string(), - ), - } - } -} - -/// The kind of an event. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum EventKind { - /// An entry, by its `lines`. - Lines, - /// A reversal of an earlier event's entry. - Reverses, - /// A close of a period. - Close, - /// A bill. - Bill, - /// An invoice. - Invoice, - /// A bad-debt write-off of an earlier invoice (rules.md B-41). - WriteOff, - /// A depreciation run. - RunDepreciation, - /// A fixed asset's disposal. - DisposeAsset, - /// Every schedule's months due through a day. - RunSchedules, - /// A schedule's registration (posts nothing). - RegisterSchedule, -} - -impl EventKind { - /// The key the example spells it with. - pub fn key(self) -> &'static str { - match self { - EventKind::Lines => "lines", - EventKind::Reverses => "reverses", - EventKind::Close => "close", - EventKind::Bill => "bill", - EventKind::Invoice => "invoice", - EventKind::WriteOff => "write_off", - EventKind::RunDepreciation => "run_depreciation", - EventKind::DisposeAsset => "dispose_asset", - EventKind::RunSchedules => "run_schedules", - EventKind::RegisterSchedule => "register_schedule", - } - } - - /// True when the example gives the event's day (a command that dates - /// its own entries takes none). - pub fn is_dated(self) -> bool { - matches!( - self, - EventKind::Lines - | EventKind::Reverses - | EventKind::Close - | EventKind::Bill - | EventKind::Invoice - | EventKind::WriteOff - | EventKind::DisposeAsset - ) - } - - /// True when the example describes the event (a command writes its own - /// descriptions). - pub fn is_described(self) -> bool { - matches!( - self, - EventKind::Lines - | EventKind::Reverses - | EventKind::Close - | EventKind::Bill - | EventKind::Invoice - ) - } - - /// True when the books take it whole or not at all, so `refused` may - /// be given: a reversal, an entry or a command, never a close, a bill - /// or an invoice (those are corrected by a reversal). An entry posts - /// whole or not at all, so a hand line the control guard refuses is - /// one too. - pub fn may_refuse(self) -> bool { - !matches!( - self, - EventKind::Close | EventKind::Bill | EventKind::Invoice - ) - } - - /// True when the example may tag it with parties and a payment method. - pub fn may_tag(self) -> bool { - !matches!( - self, - EventKind::Reverses - | EventKind::WriteOff - | EventKind::RunDepreciation - | EventKind::DisposeAsset - | EventKind::RunSchedules - | EventKind::RegisterSchedule - ) - } -} - -/// A depreciation run of `asset` for `period` (`YYYY-MM`), dated at the -/// month's end (B-15). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RunSpec { - /// A `setup.assets` id. - pub asset: String, - /// `YYYY-MM`. - pub period: String, -} - -/// A fixed asset's disposal (B-15). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DisposeSpec { - /// A `setup.assets` id. - pub asset: String, - /// What it sold for, zero allowed. - pub proceeds: String, - /// The account the proceeds were received into. - pub proceeds_account: String, - /// The gain-or-loss account. - pub gain_loss_account: String, -} - -/// Every schedule's months due through `through` (B-16). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RunSchedulesSpec { - /// `YYYY-MM-DD`. - pub through: String, -} - -/// A bill from `vendor` (B-38). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct BillSpec { - /// The vendor's name. - pub vendor: String, - /// One line's amount, exactly one of `amount` and `lines`. - #[serde(default)] - pub amount: Option, - /// Its lines. - #[serde(default)] - pub lines: Vec, -} - -/// An invoice to `customer` (B-38). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct InvoiceSpec { - /// The customer's name. - pub customer: String, - /// One line's amount, exactly one of `amount` and `lines`. - #[serde(default)] - pub amount: Option, - /// Its lines. - #[serde(default)] - pub lines: Vec, -} - -/// A write-off of the invoice an earlier event posted (rules.md B-41; -/// plan P4b S7): the invoice's event, what to write off and why. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct WriteOffSpec { - /// The event (1-based) whose invoice this writes off: an `invoice` - /// event before it. - pub invoice: usize, - /// How much to write off, an exact decimal string above zero; left - /// out, all the invoice owes on the day. - #[serde(default)] - pub amount: Option, - /// Why it will not be collected (collection correspondence, a - /// bankruptcy), one line. - pub reason: String, -} - -/// One line of an invoice or a bill. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct DocumentLineSpec { - /// Its amount, an exact decimal string above zero. - pub amount: String, - /// What it is (the event's description when left out). - #[serde(default)] - pub description: Option, - /// The project it belongs to. - #[serde(default)] - pub project: Option, -} - -/// A change to the books that posts no entry, made right after event -/// `after` once that event's expectations are checked: exactly one of -/// `rename`, `require_tax_category` and `correct_asset`. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct ChangeSpec { - /// The event after which it is made (1 to the number of events). - pub after: usize, - /// A rename. - #[serde(default)] - pub rename: Option, - /// The tax-category policy: `warn` (the books' default) or `refuse` - /// (B-32). - #[serde(default)] - pub require_tax_category: Option, - /// An asset's correction. - #[serde(default)] - pub correct_asset: Option, -} - -/// A registered asset's economics corrected for `reason`: each figure -/// given replaces the asset's; the rest stay. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CorrectSpec { - /// A `setup.assets` id. - pub asset: String, - /// Its new cost. - #[serde(default)] - pub cost: Option, - /// Its new salvage value. - #[serde(default)] - pub salvage: Option, - /// Its new useful life, above zero. - #[serde(default)] - pub life_months: Option, - /// Its new in-service day. - #[serde(default)] - pub in_service: Option, - /// Why it is corrected. - pub reason: String, -} - -/// Rename the account `code` to `name` (B-31). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct RenameSpec { - /// The account's code. - pub code: String, - /// Its new name. - pub name: String, -} - -/// One line of an entry. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct LineSpec { - /// The account's code. - pub account: String, - /// `debit` or `credit`. - pub side: String, - /// The amount, an exact decimal string above zero. - pub amount: String, - /// The project the line is tagged with. - #[serde(default)] - pub project: Option, -} - -/// A close of the period from `start` through the event's date (B-11, -/// B-13). -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct CloseSpec { - /// `YYYY-MM-DD`. - pub start: String, - /// `members` or `retained_earnings`. - pub to: String, - /// `per-close`: a members close split on its own, not cumulatively. - #[serde(default)] - pub rounding: Option, -} - -/// One expectation: a query, the dates or things it asks about, and what -/// it expects. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(deny_unknown_fields)] -pub struct Expect { - /// The query's name (`QUERIES`). - pub query: String, - /// A period query's first day, `YYYY-MM-DD`. - #[serde(default)] - pub from: Option, - /// A period query's last day. - #[serde(default)] - pub to: Option, - /// An as-of query's day. - #[serde(default)] - pub as_of: Option, - /// The event whose entry an `entry` or `party_part` query asks about. - #[serde(default)] - pub entry: Option, - /// The `run_depreciation` event a `run` query asks about. - #[serde(default)] - pub event: Option, - /// The fixed asset a `disposal`, `depreciation_schedule`, - /// `register_row` or `asset_basis` query asks about. - #[serde(default)] - pub asset: Option, - /// The schedule a `schedule_plan` query asks about. - #[serde(default)] - pub schedule: Option, - /// The party a `party_part`, `party_totals` or `party_tin_last4` query - /// asks about. - #[serde(default)] - pub party: Option, - /// The project a `project` query asks about. - #[serde(default)] - pub project: Option, - /// `party_totals`' basis: `cash` or `all` (B-35 item 4). - #[serde(default)] - pub basis: Option, - /// The payee a `vendor_1099` query asks about. - #[serde(default)] - pub payee: Option, - /// The books after event N, not after the last (a format query may - /// not name it). - #[serde(default)] - pub after: Option, - /// The account a command names, by code. - #[serde(default)] - pub code: Option, - /// The new name a `rename_account` command gives. - #[serde(default)] - pub name: Option, - /// The category a `set_tax_category` command sets (absent: it clears - /// the account's). - #[serde(default)] - pub category: Option, - /// The account an `add_account` command adds. - #[serde(default)] - pub account: Option, - /// A command the engine accepts: `true` (never `false`). - #[serde(default)] - pub accepted: Option, - /// A command, or a `period`, the engine refuses: why, in the - /// examples' words. - #[serde(default)] - pub refused: Option, - /// What the query expects. - #[serde(default)] - pub values: Option, -} - -/// What a query expects: figures by key (every figure an exact decimal -/// string), or, for a file format, its lines or its rows of fields. -#[derive(Clone, Debug, PartialEq, Deserialize)] -#[serde(untagged)] -pub enum Values { - /// A table of named values, every one a string. - Figures(BTreeMap), - /// A list of lines. - Lines(Vec), - /// A list of rows, each a list of fields. - Rows(Vec>), -} - -impl Values { - /// True when it holds nothing. - pub fn is_empty(&self) -> bool { - match self { - Values::Figures(f) => f.is_empty(), - Values::Lines(l) => l.is_empty(), - Values::Rows(r) => r.is_empty(), - } - } -} - -impl Expect { - /// The values the query expects; panics when it gives none. - pub fn values(&self, id: &str) -> &Values { - self.values - .as_ref() - .unwrap_or_else(|| panic!("{id}: {} expects values", self.query)) - } - - /// The figures the query expects; panics on lines or rows. - pub fn figures(&self, id: &str) -> &BTreeMap { - match self.values(id) { - Values::Figures(f) => f, - _ => panic!("{id}: {} expects figures (a table)", self.query), - } - } - - /// The question this expectation asks, as the engine receives it. - pub fn query(&self) -> Query { - Query { - name: self.query.clone(), - from: self.from.clone(), - to: self.to.clone(), - as_of: self.as_of.clone(), - entry: self.entry, - event: self.event, - asset: self.asset.clone(), - schedule: self.schedule.clone(), - party: self.party.clone(), - project: self.project.clone(), - basis: self.basis.clone(), - payee: self.payee.clone(), - after: self.after, - code: self.code.clone(), - new_name: self.name.clone(), - category: self.category.clone(), - account: self.account.clone(), - } - } -} - -/// The question an expectation asks: everything but the answer. -#[derive(Clone, Debug, PartialEq)] -pub struct Query { - /// The query's name. - pub name: String, - /// A period's first day (raw, `YYYY-MM-DD`). - pub from: Option, - /// A period's last day. - pub to: Option, - /// An as-of query's day. - pub as_of: Option, - /// The event whose entry is asked about. - pub entry: Option, - /// The event a `run` query asks about. - pub event: Option, - /// The asset asked about. - pub asset: Option, - /// The schedule asked about. - pub schedule: Option, - /// The party asked about. - pub party: Option, - /// The project asked about. - pub project: Option, - /// `party_totals`' basis. - pub basis: Option, - /// The payee asked about. - pub payee: Option, - /// The books after event N (the runner has already applied them). - pub after: Option, - /// The account a command names. - pub code: Option, - /// The new name a rename command gives. - pub new_name: Option, - /// The category a set-tax-category command sets. - pub category: Option, - /// The account an add-account command adds. - pub account: Option, -} - -/// The queries every engine must answer: the figure queries first, then -/// the calendar ones, then the commands (tried on a copy of the books), -/// then the formats appendix's. -pub const QUERIES: &[&str] = &[ - "cash_flow", - "trial_balance", - "profit_and_loss", - "entry", - "balances", - "members_equity", - "k1", - "k1_tax_basis", - "k1_reconciliation", - "vendor_1099_paid", - "vendor_1099", - "party_part", - "party_totals", - "party_tin_last4", - "period", - "day", - "calendar_year", - "account_names", - "uncategorized_accounts", - "run", - "disposal", - "depreciation_schedule", - "schedule_plan", - "register_row", - "register_totals", - "asset_basis", - "project", - "rename_account", - "add_account", - "set_tax_category", - "iif_lines", - "iif_changes", -]; - -/// The commands an example may try: each on a copy of the books, so it -/// changes nothing checked after it. -pub const COMMANDS: &[&str] = &["rename_account", "add_account", "set_tax_category"]; - -/// The formats appendix's queries. -pub const FORMAT_QUERIES: &[&str] = &["iif_lines", "iif_changes"]; - -/// The queries whose figures may be text rather than an exact decimal -/// string: a status, a direction, a date, a year, a name, a rule name, -/// `"none"`, `"open"`, `"true"` or `"false"`, and the register's dates -/// beside its figures. -pub const TEXT_VALUED: &[&str] = &[ - "vendor_1099", - "party_part", - "party_tin_last4", - "period", - "day", - "calendar_year", - "register_row", - "project", - "asset_basis", - "k1_tax_basis", - "account_names", -]; - -/// The queries whose example lists every value it covers, so a value left -/// out of an example is an error. The others name the figures they show: -/// `cash_flow`, the statements, the register, a schedule's plan and an -/// asset's basis. -pub const COMPLETE_QUERIES: &[&str] = &[ - "trial_balance", - "profit_and_loss", - "entry", - "vendor_1099_paid", - "vendor_1099", - "party_part", - "party_totals", - "party_tin_last4", - "period", - "day", - "calendar_year", - "account_names", - "uncategorized_accounts", - "run", - "disposal", - "project", -]; - -impl Query { - /// True when it is a command, tried on a copy of the books. - pub fn is_command(&self) -> bool { - COMMANDS.contains(&self.name.as_str()) - } - - /// True when it is a formats appendix's query. - pub fn is_format(&self) -> bool { - FORMAT_QUERIES.contains(&self.name.as_str()) - } - - /// True when the example must list every value the query covers. - pub fn is_complete(&self) -> bool { - COMPLETE_QUERIES.contains(&self.name.as_str()) - } -} - -/// Every refusal reason the specification names (bookspec/README.md), each -/// the reason of exactly one rule (see [`crate::rules::rule_for_refusal`]). -pub fn refusal_reasons() -> impl Iterator { - [ - "end_before_start", - "start_after_today", - "blank", - "unchanged", - "sibling_has_it", - "holds_a_tin", - "not_one_line", - "code_taken", - "tax_category_required", - "depreciation_past_disposal", - "entry_irreversible", - "already_disposed", - "invalid_depreciation_period", - "invalid_schedule", - "untagged_control_line", - ] - .into_iter() -} - -/// Why an example is not well formed. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct SchemaError { - /// The example's id, when it has one. - pub id: String, - /// What is wrong. - pub message: String, -} - -impl fmt::Display for SchemaError { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - if self.id.is_empty() { - write!(f, "{}", self.message) - } else { - write!(f, "{}: {}", self.id, self.message) - } - } -} - -impl std::error::Error for SchemaError {} - -impl SchemaError { - fn new(id: &str, message: impl Into) -> Self { - Self { - id: id.to_string(), - message: message.into(), - } - } -} - -impl Example { - /// Parse an example from its TOML text and check its shape. - pub fn from_toml(text: &str) -> Result { - let ex: Self = toml::from_str(text).map_err(|e| SchemaError::new("", e.to_string()))?; - ex.validate()?; - Ok(ex) - } - - /// Check the example's shape: its closed lists, its dates and amounts, - /// its references and its events' and expectations' forms. - pub fn validate(&self) -> Result<(), SchemaError> { - let id = self.id.as_str(); - let e = |m: String| SchemaError::new(id, m); - if id.is_empty() { - return Err(SchemaError::new("", "an example has an id")); - } - if self.title.is_empty() || self.rule.is_empty() { - return Err(e("an example has a title and a rule".into())); - } - if self.sources.is_empty() || self.tags.is_empty() { - return Err(e("an example is sourced and tagged".into())); - } - if !matches!(self.status.as_str(), "AUTHORITY" | "CONVENTION" | "CHOICE") { - return Err(e(format!("status {:?}", self.status))); - } - if let Some(a) = &self.appendix - && a != "formats" - { - return Err(e(format!("appendix {a:?}"))); - } - if self.expect.is_empty() { - return Err(e("an example expects something".into())); - } - self.setup.validate(id)?; - - let mut codes = BTreeSet::new(); - for a in &self.setup.accounts { - codes.insert(a.code.as_str()); - } - for a in &self.setup.assets { - for c in [&a.cost_account, &a.accumulated_account, &a.expense_account] { - if !codes.contains(c.as_str()) { - return Err(e(format!("asset {}: no account {c}", a.id))); - } - } - if let Some(n) = a.acquired_by - && (n < 1 || n > self.events.len()) - { - return Err(e(format!( - "asset {}: acquired_by {n}: events count from 1, to {}", - a.id, - self.events.len() - ))); - } - } - for s in &self.setup.schedules { - s.validate(id, &codes)?; - } - let parties: BTreeSet<&str> = self.setup.parties.iter().map(|p| p.name.as_str()).collect(); - let projects: BTreeSet<&str> = self - .setup - .projects - .iter() - .map(|p| p.name.as_str()) - .collect(); - let payees: BTreeSet<&str> = self.setup.payees.iter().map(|p| p.name.as_str()).collect(); - let instruments: BTreeSet<&str> = self - .setup - .instruments - .iter() - .map(|i| i.label.as_str()) - .collect(); - for (n, ev) in self.events.iter().enumerate() { - let n = n + 1; - let what = |m: String| e(format!("event {n}: {m}")); - let kind = ev.kind().map_err(what)?; - if ev.date.is_some() != kind.is_dated() { - return Err(what(if kind.is_dated() { - format!("{} is dated (date)", kind.key()) - } else { - format!("{} dates its own entries: no date", kind.key()) - })); - } - if let Some(d) = &ev.date { - check_date(d, &what)?; - } - if ev.description.is_some() != kind.is_described() { - return Err(what(if kind.is_described() { - format!("{} is described (description)", kind.key()) - } else { - format!("{} describes its own entries: no description", kind.key()) - })); - } - if ev.older && kind != EventKind::Lines { - return Err(what( - "older is an entry's lines, as an earlier version wrote them".into(), - )); - } - if ev.older && ev.refused.is_some() { - return Err(what( - "an entry posted as an earlier version wrote it (older) can't also be \ - refused" - .into(), - )); - } - if ev.payee.is_some() && kind != EventKind::Lines { - return Err(what( - "a payee is an entry's: a reversal takes its original's, a bill names \ - its vendor, an invoice its customer, a close and a command have none" - .into(), - )); - } - if (!ev.parties.is_empty() || ev.instrument.is_some()) && !kind.may_tag() { - return Err(what(format!( - "{} is tagged with no parties or payment method", - kind.key() - ))); - } - if ev.refused.is_some() && !kind.may_refuse() { - return Err(what( - "refused is for a reversal or a command, which the books take whole or \ - not at all" - .into(), - )); - } - if let Some(r) = &ev.refused - && rule_for_refusal(r).is_none() - { - return Err(what(format!("{r:?} is no refusal the specification names"))); - } - if let Some(p) = &ev.payee - && !payees.contains(p.as_str()) - { - return Err(what(format!("payee {p:?} not in setup.payees"))); - } - if let Some(i) = &ev.instrument - && !instruments.contains(i.as_str()) - { - return Err(what(format!( - "payment method {i:?} not in setup.instruments" - ))); - } - if let Some(of) = ev.reverses { - if of < 1 { - return Err(what(format!("reverses = {of}: events count from 1"))); - } - if of >= n { - return Err(what(format!("reverses a later event ({of})"))); - } - } - for line in &ev.lines { - if !codes.contains(line.account.as_str()) { - return Err(what(format!("no account {}", line.account))); - } - if !matches!(line.side.as_str(), "debit" | "credit") { - return Err(what(format!("side {:?}", line.side))); - } - // A refused entry may carry the amount the books refuse (zero or - // negative, B-2); an accepted one carries a positive amount. - check_amount(&line.amount, ev.refused.is_none(), &what)?; - if let Some(p) = &line.project - && !projects.contains(p.as_str()) - { - return Err(what(format!("project {p:?} not in setup.projects"))); - } - } - for sh in &ev.parties { - if !parties.contains(sh.party.as_str()) { - return Err(what(format!("party {:?} not in setup.parties", sh.party))); - } - match (&sh.amount, &sh.percent) { - (None, None) => {} - (Some(a), None) => check_amount(a, false, &what)?, - (None, Some(p)) => check_amount(p, false, &what)?, - _ => return Err(what("a share is an amount or a percent".into())), - } - } - if let Some(c) = &ev.close { - check_date(&c.start, &what)?; - if !matches!(c.to.as_str(), "members" | "retained_earnings") { - return Err(what(format!("close to {:?}", c.to))); - } - if let Some(r) = &c.rounding - && !(r == "per-close" && c.to == "members") - { - return Err(what(format!("close rounding {r:?}"))); - } - } - if let Some(b) = &ev.bill { - check_document(&b.amount, &b.lines, &projects, &what)?; - if b.vendor.is_empty() { - return Err(what("a bill names its vendor".into())); - } - } - if let Some(i) = &ev.invoice { - check_document(&i.amount, &i.lines, &projects, &what)?; - if i.customer.is_empty() { - return Err(what("an invoice names its customer".into())); - } - } - if let Some(w) = &ev.write_off { - if w.invoice < 1 || w.invoice >= n { - return Err(what(format!( - "write_off names event {}: an earlier invoice", - w.invoice - ))); - } - if self.events[w.invoice - 1].invoice.is_none() { - return Err(what(format!( - "write_off names event {}, which is no invoice", - w.invoice - ))); - } - if let Some(a) = &w.amount { - check_amount(a, true, &what)?; - } - if w.reason.trim().is_empty() { - return Err(what("a write-off says why it will not be collected".into())); - } - } - if let Some(r) = &ev.run_depreciation { - check_month(&r.period, &what)?; - if !self.setup.assets.iter().any(|a| a.id == r.asset) { - return Err(what(format!("no asset {:?} in setup.assets", r.asset))); - } - } - if let Some(d) = &ev.dispose_asset { - if !self.setup.assets.iter().any(|a| a.id == d.asset) { - return Err(what(format!("no asset {:?} in setup.assets", d.asset))); - } - check_amount(&d.proceeds, false, &what)?; - for c in [&d.proceeds_account, &d.gain_loss_account] { - if !codes.contains(c.as_str()) { - return Err(what(format!("no account {c}"))); - } - } - } - if let Some(r) = &ev.run_schedules { - check_date(&r.through, &what)?; - } - if let Some(s) = &ev.register_schedule - && ev.refused.is_none() - { - s.validate(id, &codes)?; - } - } - for c in &self.changes { - let what = |m: String| e(format!("change after {}: {m}", c.after)); - if c.after < 1 || c.after > self.events.len() { - return Err(what(format!( - "after = {}: events count from 1, to {}", - c.after, - self.events.len() - ))); - } - let given = [ - c.rename.is_some(), - c.require_tax_category.is_some(), - c.correct_asset.is_some(), - ]; - if given.iter().filter(|x| **x).count() != 1 { - return Err(what( - "a change is one of rename, require_tax_category or correct_asset".into(), - )); - } - if let Some(r) = &c.rename { - if !codes.contains(r.code.as_str()) { - return Err(what(format!("no account {}", r.code))); - } - if r.name.trim().is_empty() { - return Err(what("a rename names the account".into())); - } - } - if let Some(p) = &c.require_tax_category - && !matches!(p.as_str(), "warn" | "refuse") - { - return Err(what(format!("require_tax_category {p:?}"))); - } - if let Some(f) = &c.correct_asset { - if !self.setup.assets.iter().any(|a| a.id == f.asset) { - return Err(what(format!("no asset {:?} in setup.assets", f.asset))); - } - for a in [&f.cost, &f.salvage].into_iter().flatten() { - check_amount(a, false, &what)?; - } - if let Some(d) = &f.in_service { - check_date(d, &what)?; - } - if f.life_months == Some(0) { - return Err(what("life_months is above zero".into())); - } - if f.reason.trim().is_empty() { - return Err(what("a correction gives its reason".into())); - } - } - } - for x in &self.expect { - let q = x.query.as_str(); - let what = |m: String| e(format!("{q}: {m}")); - if !QUERIES.contains(&q) { - return Err(e(format!("no query answers {q:?}"))); - } - if let Some(n) = x.after - && (n < 1 || n > self.events.len()) - { - return Err(what(format!( - "after = {n}: events count from 1, to {}", - self.events.len() - ))); - } - if x.after.is_some() && FORMAT_QUERIES.contains(&q) { - return Err(what( - "a format query is of the books after every event".into(), - )); - } - if FORMAT_QUERIES.contains(&q) && self.appendix.as_deref() != Some("formats") { - return Err(what( - "a format query belongs to an example in appendix \"formats\"".into(), - )); - } - for d in [&x.from, &x.to, &x.as_of].into_iter().flatten() { - check_date(d, &what)?; - } - only(x, &what)?; - if COMMANDS.contains(&q) { - if x.values.is_some() || (x.accepted == Some(true)) == x.refused.is_some() { - return Err(what( - "a command is accepted = true, or refused, and shows no values".into(), - )); - } - if x.accepted == Some(false) { - return Err(what("a command is accepted = true, or refused".into())); - } - if let Some(r) = &x.refused - && rule_for_refusal(r).is_none() - { - return Err(what(format!("{r:?} is no refusal the specification names"))); - } - if x.account.as_ref().is_some_and(|a| a.code.trim().is_empty()) { - return Err(what("add_account names the account's code".into())); - } - } else { - match (&x.values, &x.refused) { - (Some(v), None) if !v.is_empty() => {} - (None, Some(r)) => { - if q != "period" { - return Err(what( - "only a period may be refused instead of answering".into(), - )); - } - if rule_for_refusal(r).is_none() { - return Err(what(format!( - "{r:?} is no refusal the specification names" - ))); - } - } - _ => return Err(what("expects values (a period: values or refused)".into())), - } - } - if let Some(v) = &x.values { - match v { - Values::Figures(f) => { - for (k, val) in f { - if k.is_empty() { - return Err(what("a figure's key is empty".into())); - } - if !TEXT_VALUED.contains(&q) && !is_exact_decimal(val) { - return Err(what(format!( - "{k}: {val:?} is no exact decimal string" - ))); - } - } - } - Values::Lines(l) => { - if !matches!(q, "iif_lines" | "uncategorized_accounts") { - return Err(what("expects figures (a table), not lines".into())); - } - if l.is_empty() { - return Err(what("an empty list expects nothing".into())); - } - } - Values::Rows(rows) => { - if q != "iif_changes" { - return Err(what("expects figures (a table), not rows".into())); - } - if rows.iter().any(|r| r.len() != 3) { - return Err(what("an iif_changes row has three fields".into())); - } - } - } - } - if FORMAT_QUERIES.contains(&q) && x.values.is_none() { - return Err(what("a format query expects its lines or rows".into())); - } - } - Ok(()) - } -} - -impl Setup { - /// Check the setup's shape: its closed lists, its dates, its accounts - /// and its directory. - pub fn validate(&self, id: &str) -> Result<(), SchemaError> { - let e = |m: String| SchemaError::new(id, m); - if self.currency.len() != 3 || !self.currency.bytes().all(|b| b.is_ascii_uppercase()) { - return Err(e(format!("currency {:?}", self.currency))); - } - if !is_month_day(&self.fiscal_year_start) { - return Err(e(format!("fiscal_year_start {:?}", self.fiscal_year_start))); - } - if !matches!(self.basis.as_str(), "accrual" | "cash") { - return Err(e(format!("basis {:?}", self.basis))); - } - if !matches!( - self.rounding.as_str(), - "half-even" | "half-away-from-zero" | "largest-remainder" - ) { - return Err(e(format!("rounding {:?}", self.rounding))); - } - if let Some(t) = &self.today { - check_date(t, &e)?; - } - let mut codes: BTreeSet<&str> = BTreeSet::new(); - for a in &self.accounts { - if a.code.trim().is_empty() || a.name.trim().is_empty() { - return Err(e("an account has a code and a name".into())); - } - if !matches!( - a.kind.as_str(), - "asset" | "liability" | "equity" | "revenue" | "expense" - ) { - return Err(e(format!("account {}: type {:?}", a.code, a.kind))); - } - if !codes.insert(a.code.as_str()) { - return Err(e(format!("account {}: code used twice", a.code))); - } - if let Some(p) = &a.parent - && !codes.contains(p.as_str()) - { - return Err(e(format!( - "account {}: parent {p} is not declared before it", - a.code - ))); - } - } - for fa in &self.fixed_assets { - for c in [ - &fa.cost_account, - &fa.accumulated_account, - &fa.expense_account, - ] { - if !codes.contains(c.as_str()) { - return Err(e(format!("fixed asset: no account {c}"))); - } - } - } - let mut ids = BTreeSet::new(); - for a in &self.assets { - if a.id.is_empty() || a.description.is_empty() { - return Err(e("an asset has an id and a description".into())); - } - if !ids.insert(a.id.as_str()) { - return Err(e(format!("asset {}: id used twice", a.id))); - } - if a.method != "straight_line" { - return Err(e(format!("asset {}: method {:?}", a.id, a.method))); - } - check_amount(&a.cost, false, &e)?; - check_amount(&a.salvage, false, &e)?; - if a.life_months == 0 { - return Err(e(format!("asset {}: life_months is above zero", a.id))); - } - check_date(&a.in_service, &e)?; - if let Some(b) = &a.basis { - if !matches!( - b.rule.as_str(), - "purchase" | "carryover_723" | "personal_use_conversion" - ) { - return Err(e(format!("asset {}: basis rule {:?}", a.id, b.rule))); - } - for x in [&b.contributor_adjusted_basis, &b.fmv_at_contribution] - .into_iter() - .flatten() - { - check_amount(x, false, &e)?; - } - if let Some(m) = &b.sec704c_method - && !matches!( - m.as_str(), - "traditional" | "traditional_with_curative" | "remedial" - ) - { - return Err(e(format!("asset {}: section 704(c) method {m:?}", a.id))); - } - } - } - let mut schedule_ids = BTreeSet::new(); - for s in &self.schedules { - s.validate(id, &codes)?; - if !schedule_ids.insert(s.id.as_str()) { - return Err(e(format!("schedule {}: id used twice", s.id))); - } - } - let mut party_names = BTreeSet::new(); - for p in &self.parties { - if p.name.trim().is_empty() { - return Err(e("a party has a name".into())); - } - if !party_names.insert(p.name.as_str()) { - return Err(e(format!("party {}: named twice", p.name))); - } - if p.roles.iter().any(|r| !ROLES.contains(&r.as_str())) { - return Err(e(format!("party {}: roles {:?}", p.name, p.roles))); - } - if p.roles - .iter() - .filter(|r| *r == "vendor" || *r == "customer") - .count() - > 1 - { - return Err(e(format!( - "party {}: a vendor or a customer, not both", - p.name - ))); - } - for c in &p.creditor_of { - if !codes.contains(c.as_str()) { - return Err(e(format!("party {}: no account {c}", p.name))); - } - } - for l in [&p.tin_last4, &p.w9_tin_last4].into_iter().flatten() { - if l.len() != 4 || !l.bytes().all(|b| b.is_ascii_digit()) { - return Err(e(format!("party {}: last four {l:?}", p.name))); - } - } - if p.tin_purged && p.tin_last4.is_none() { - return Err(e(format!("party {}: tin_purged needs tin_last4", p.name))); - } - } - let mut member_names = BTreeSet::new(); - let shared = self.members.iter().filter(|m| m.share.is_some()).count(); - if shared != 0 && shared != self.members.len() { - return Err(e("every member has a share, or none does".into())); - } - let mut total = Decimal::ZERO; - for m in &self.members { - if m.name.trim().is_empty() || !member_names.insert(m.name.as_str()) { - return Err(e(format!("member {:?} is named twice", m.name))); - } - if !codes.contains(m.capital_account.as_str()) { - return Err(e(format!( - "member {}: no account {}", - m.name, m.capital_account - ))); - } - if let Some(d) = &m.distributions_account - && !codes.contains(d.as_str()) - { - return Err(e(format!("member {}: no account {d}", m.name))); - } - if let Some(s) = &m.share { - let share = decimal(s, &e)?; - if share <= Decimal::ZERO { - return Err(e(format!("member {}: share {s:?} is above zero", m.name))); - } - total += share; - } - } - if shared > 0 && total != Decimal::ONE { - return Err(e("the shares sum to 1".into())); - } - let mut payee_names = BTreeSet::new(); - for p in &self.payees { - if p.name.trim().is_empty() || !payee_names.insert(p.name.as_str()) { - return Err(e(format!("payee {:?} is named twice", p.name))); - } - if !matches!(p.form.as_str(), "1099-NEC" | "1099-MISC") { - return Err(e(format!("payee {}: form {:?}", p.name, p.form))); - } - } - for c in &self.status_changes { - if !payee_names.contains(c.payee.as_str()) { - return Err(e(format!( - "status change: payee {:?} not in setup", - c.payee - ))); - } - check_date(&c.effective, &e)?; - match c.decision.as_str() { - "not_reportable" => { - if !c - .reason_kind - .as_deref() - .is_some_and(|k| EXEMPT_REASONS.contains(&k)) - { - return Err(e(format!( - "status change of {}: not_reportable needs a reason_kind", - c.payee - ))); - } - if c.reason.as_deref().is_none_or(|r| r.trim().is_empty()) { - return Err(e(format!( - "status change of {}: not_reportable needs a reason", - c.payee - ))); - } - } - "undetermined" | "1099-NEC" | "1099-MISC" => { - if c.reason_kind.is_some() || c.reason.is_some() { - return Err(e(format!( - "status change of {}: only not_reportable takes reason_kind and reason", - c.payee - ))); - } - } - other => { - return Err(e(format!( - "status change of {}: decision {other:?}", - c.payee - ))); - } - } - } - let mut labels = BTreeSet::new(); - for i in &self.instruments { - if i.label.trim().is_empty() || !labels.insert(i.label.as_str()) { - return Err(e(format!("payment method {:?} is labelled twice", i.label))); - } - if !matches!( - i.kind.as_str(), - "bank" | "business_card" | "member_card" | "member_cash_or_check" | "platform" - ) { - return Err(e(format!("payment method {}: kind {:?}", i.label, i.kind))); - } - if !codes.contains(i.account.as_str()) { - return Err(e(format!( - "payment method {}: no account {}", - i.label, i.account - ))); - } - if let Some(o) = &i.owner - && !party_names.contains(o.as_str()) - { - return Err(e(format!( - "payment method {}: owner {o:?} not in setup.parties", - i.label - ))); - } - for c in [&i.last4].into_iter().flatten() { - check_last4(c, &e)?; - } - } - let mut project_names = BTreeSet::new(); - for p in &self.projects { - if p.name.trim().is_empty() || !project_names.insert(p.name.as_str()) { - return Err(e(format!("project {:?} is named twice", p.name))); - } - if let Some(k) = &p.kind - && !matches!(k.as_str(), "internal" | "client" | "on_spec" | "other") - { - return Err(e(format!("project {}: kind {k:?}", p.name))); - } - if let Some(s) = &p.status - && !matches!(s.as_str(), "active" | "paused" | "finished" | "abandoned") - { - return Err(e(format!("project {}: status {s:?}", p.name))); - } - if let Some(t) = &p.party - && !party_names.contains(t.as_str()) - { - return Err(e(format!( - "project {}: party {t:?} not in setup.parties", - p.name - ))); - } - } - Ok(()) - } -} - -impl ScheduleSpec { - fn validate(&self, id: &str, codes: &BTreeSet<&str>) -> Result<(), SchemaError> { - let e = |m: String| SchemaError::new(id, m); - if self.id.trim().is_empty() || self.description.trim().is_empty() { - return Err(e("a schedule has an id and a description".into())); - } - if !matches!(self.kind.as_str(), "prepaid_expense" | "deferred_revenue") { - return Err(e(format!("schedule {}: kind {:?}", self.id, self.kind))); - } - check_amount(&self.total, false, &e)?; - check_date(&self.start, &e)?; - if self.months == 0 { - return Err(e(format!("schedule {}: months is above zero", self.id))); - } - if !matches!(self.convention.as_str(), "daily" | "monthly_even") { - return Err(e(format!( - "schedule {}: convention {:?}", - self.id, self.convention - ))); - } - if self.convention == "monthly_even" { - let day = self - .start - .rsplit_once('-') - .and_then(|(_, d)| d.parse::().ok()); - if day != Some(1) { - return Err(e(format!( - "schedule {}: an equal-months schedule starts on the 1st", - self.id - ))); - } - } - for c in [&self.balance_account, &self.pl_account] { - if !codes.contains(c.as_str()) { - return Err(e(format!("schedule {}: no account {c}", self.id))); - } - } - Ok(()) - } -} - -/// The party roles the specification names. -pub const ROLES: &[&str] = &[ - "member", - "vendor", - "customer", - "contractor", - "landlord", - "employee", - "financial_institution", - "payout_platform", - "tax_agency", - "government_agency", -]; - -/// Why a party is not reportable (B-19). -pub const EXEMPT_REASONS: &[&str] = &[ - "corporation", - "tax_exempt", - "united_states", - "state", - "foreign_government", - "foreign_person", - "merchandise", - "employee", - "other", -]; - -/// A date, `YYYY-MM-DD` and a real day. -pub fn is_date(s: &str) -> bool { - s.len() == 10 && NaiveDate::parse_from_str(s, "%Y-%m-%d").is_ok() -} - -/// A month, `YYYY-MM`. -pub fn is_month(s: &str) -> bool { - s.len() == 7 && NaiveDate::parse_from_str(&format!("{s}-01"), "%Y-%m-%d").is_ok() -} - -/// An exact decimal string: digits, an optional sign and point; never an -/// exponent, never a float. -pub fn is_exact_decimal(s: &str) -> bool { - !s.is_empty() - && s.chars() - .all(|c| c.is_ascii_digit() || c == '.' || c == '-') - && s.matches('.').count() <= 1 - && s.parse::().is_ok() -} - -fn decimal(s: &str, e: &impl Fn(String) -> SchemaError) -> Result { - if !is_exact_decimal(s) { - return Err(e(format!("{s:?} is no exact decimal string"))); - } - s.parse() - .map_err(|_| e(format!("{s:?} is no exact decimal string"))) -} - -fn check_amount( - s: &str, - positive: bool, - e: &impl Fn(String) -> SchemaError, -) -> Result<(), SchemaError> { - let d = decimal(s, e)?; - if positive && d <= Decimal::ZERO { - return Err(e(format!("{s:?}: amounts are positive"))); - } - Ok(()) -} - -fn check_date(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> { - if !is_date(s) { - return Err(e(format!("{s:?} is no date (YYYY-MM-DD)"))); - } - Ok(()) -} - -fn check_month(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> { - if !is_month(s) { - return Err(e(format!("{s:?} is no month (YYYY-MM)"))); - } - Ok(()) -} - -fn check_last4(s: &str, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> { - if s.len() != 4 || !s.bytes().all(|b| b.is_ascii_digit()) { - return Err(e(format!("last four {s:?}"))); - } - Ok(()) -} - -fn check_document( - amount: &Option, - lines: &[DocumentLineSpec], - projects: &BTreeSet<&str>, - e: &impl Fn(String) -> SchemaError, -) -> Result<(), SchemaError> { - match (amount, lines) { - (Some(a), []) => { - if decimal(a, e)? <= Decimal::ZERO { - return Err(e("amounts are positive".into())); - } - } - (None, [_, ..]) => { - for l in lines { - if decimal(&l.amount, e)? <= Decimal::ZERO { - return Err(e("amounts are positive".into())); - } - if let Some(p) = &l.project - && !projects.contains(p.as_str()) - { - return Err(e(format!("project {p:?} not in setup.projects"))); - } - } - } - (Some(_), [_, ..]) => { - return Err(e("a document has an amount or lines, exactly one".into())); - } - (None, []) => return Err(e("a document has an amount or lines, exactly one".into())), - } - Ok(()) -} - -/// A key only the queries that ask for it may name. -fn only(x: &Expect, e: &impl Fn(String) -> SchemaError) -> Result<(), SchemaError> { - let q = x.query.as_str(); - let checks: [(&str, bool, &[&str]); 14] = [ - ("entry", x.entry.is_some(), &["entry", "party_part"]), - ("event", x.event.is_some(), &["run"]), - ( - "asset", - x.asset.is_some(), - &[ - "disposal", - "depreciation_schedule", - "register_row", - "asset_basis", - ], - ), - ("schedule", x.schedule.is_some(), &["schedule_plan"]), - ( - "party", - x.party.is_some(), - &["party_part", "party_totals", "party_tin_last4"], - ), - ("project", x.project.is_some(), &["project"]), - ("basis", x.basis.is_some(), &["party_totals"]), - ("payee", x.payee.is_some(), &["vendor_1099"]), - ( - "code", - x.code.is_some(), - &["rename_account", "set_tax_category"], - ), - ("name", x.name.is_some(), &["rename_account"]), - ("category", x.category.is_some(), &["set_tax_category"]), - ("account", x.account.is_some(), &["add_account"]), - ("accepted", x.accepted.is_some(), COMMANDS), - ( - "refused", - x.refused.is_some(), - &[ - "rename_account", - "add_account", - "set_tax_category", - "period", - ], - ), - ]; - for (key, given, queries) in checks { - if given && !queries.contains(&q) { - return Err(e(format!("{key} is for {} only", queries.join(" and ")))); - } - } - Ok(()) -} - -fn is_month_day(s: &str) -> bool { - let Some((m, d)) = s.split_once('-') else { - return false; - }; - let (Ok(m), Ok(d)) = (m.parse::(), d.parse::()) else { - return false; - }; - s.len() == 5 && s.as_bytes()[2] == b'-' && NaiveDate::from_ymd_opt(2001, m, d).is_some() -} diff --git a/bookspec/checker/tests/derive.rs b/bookspec/checker/tests/derive.rs deleted file mode 100644 index 7a80016..0000000 --- a/bookspec/checker/tests/derive.rs +++ /dev/null @@ -1,107 +0,0 @@ -//! The specification's own checker: every example's expected figures are -//! derived from its rules' symbolic forms, engine-free (P13 step 5). - -use std::path::{Path, PathBuf}; - -use bookspec_checker::{Derived, Example, derive}; - -fn examples() -> Vec { - let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples"); - let mut files: Vec = std::fs::read_dir(dir) - .unwrap() - .map(|e| e.unwrap().path()) - .filter(|p| p.extension().is_some_and(|x| x == "toml")) - .collect(); - files.sort(); - files - .iter() - .map(|f| Example::from_toml(&std::fs::read_to_string(f).unwrap()).unwrap()) - .collect() -} - -/// No example's figure disagrees with what its rule derives. -#[test] -fn no_example_disagrees_with_its_derivation() { - let mut bad = Vec::new(); - let mut agreed = 0; - for ex in examples() { - let d = derive(&ex); - bad.extend(d.disagreements()); - agreed += d.agreed(); - } - assert!( - bad.is_empty(), - "the examples and the rules disagree:\n{}", - bad.join("\n") - ); - assert!( - agreed >= 30, - "the reference model derived only {agreed} results" - ); -} - -/// The nine examples written for the ledger's own laws are derived in -/// full: not one of their results is skipped. -#[test] -fn the_law_examples_are_derived_in_full() { - for id in [ - "entry-an-unbalanced-entry-is-refused", - "entry-a-line-amount-is-positive", - "ledger-a-fraction-of-a-cent-is-refused", - "statement-the-books-balance-and-the-equation-holds", - "ledger-a-mistake-is-reversed-never-erased", - "period-a-close-sweeps-the-year-into-capital", - "entry-cents-add-exactly", - ] { - let ex = examples().into_iter().find(|e| e.id == id).unwrap(); - let d = derive(&ex); - assert!(!d.results.is_empty(), "{id}"); - for (what, r) in &d.results { - assert!(matches!(r, Derived::Agrees { .. }), "{id}: {what}: {r:?}"); - } - } -} - -/// An example whose expectation is wrong fails the specification's build: -/// the same example with one cent moved is caught, and so is a refusal the -/// rules do not justify. -#[test] -fn a_wrong_expectation_is_caught() { - let ex = examples() - .into_iter() - .find(|e| e.id == "statement-the-books-balance-and-the-equation-holds") - .unwrap(); - let text = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../examples/statement-the-books-balance-and-the-equation-holds.toml"), - ) - .unwrap(); - assert!(derive(&ex).disagreements().is_empty()); - let wrong = text.replacen("\"1000\" = \"6550.25\"", "\"1000\" = \"6550.26\"", 1); - let d = derive(&Example::from_toml(&wrong).unwrap()); - assert!( - d.disagreements().iter().any(|m| m.contains("6550.26")), - "{:?}", - d.disagreements() - ); - // A refusal the rules do not justify: the entry balances. - let ex = examples() - .into_iter() - .find(|e| e.id == "entry-an-unbalanced-entry-is-refused") - .unwrap(); - let text = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")) - .join("../examples/entry-an-unbalanced-entry-is-refused.toml"), - ) - .unwrap(); - assert!(derive(&ex).disagreements().is_empty()); - let wrong = text.replacen("amount = \"90.00\"", "amount = \"100.00\"", 1); - let d = derive(&Example::from_toml(&wrong).unwrap()); - assert!( - d.disagreements() - .iter() - .any(|m| m.contains("equal credits")), - "{:?}", - d.disagreements() - ); -} diff --git a/bookspec/checker/tests/docs.rs b/bookspec/checker/tests/docs.rs deleted file mode 100644 index d27d8de..0000000 --- a/bookspec/checker/tests/docs.rs +++ /dev/null @@ -1,28 +0,0 @@ -//! The documentation bookspec's identifiers live in: `docs/spec/rules.md` -//! carries each stated rule's id and speaks bookspec's `CHOICE`. - -use std::path::Path; - -use bookspec_checker::rules; - -/// `docs/spec/rules.md` carries every stated rule's id under its heading, -/// and no longer calls a choice a decision (P13 step 3). -#[test] -fn rules_md_carries_every_id() { - let text = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/rules.md"), - ) - .expect("docs/spec/rules.md"); - for rule in rules().iter().filter(|r| r.status.is_some()) { - assert!( - text.contains(&format!("**bookspec:** `{}`", rule.id)), - "B-{} carries {}", - rule.b, - rule.id - ); - } - assert!( - !text.contains("DECISION"), - "rules.md speaks bookspec's CHOICE" - ); -} diff --git a/bookspec/checker/tests/lean.rs b/bookspec/checker/tests/lean.rs deleted file mode 100644 index 78517bc..0000000 --- a/bookspec/checker/tests/lean.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! The five laws are Lean theorems in `bookspec/lean` (P13 step 4), with -//! no assumption beyond Lean's standard axioms. - -use std::path::{Path, PathBuf}; -use std::process::Command; - -const LAWS: [&str; 5] = [ - "law_debits_equal_credits", - "law_accounting_equation", - "law_trial_balance", - "law_net_income_articulates", - "law_cash_flow_is_change_in_cash", -]; - -fn lean_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../lean") -} - -/// Every law is stated in `Laws.lean`, audited in `Audit.lean`, and no -/// file of the library says `sorry`. -#[test] -fn the_five_laws_are_stated_and_audited() { - let laws = std::fs::read_to_string(lean_dir().join("Bookspec/Laws.lean")).unwrap(); - let audit = std::fs::read_to_string(lean_dir().join("Audit.lean")).unwrap(); - for law in LAWS { - assert!(laws.contains(&format!("theorem {law}")), "{law} is stated"); - assert!( - audit.contains(&format!("#print axioms {law}")), - "{law} is audited" - ); - } - for entry in std::fs::read_dir(lean_dir().join("Bookspec")).unwrap() { - let path = entry.unwrap().path(); - let text = std::fs::read_to_string(&path).unwrap(); - for (i, line) in text.lines().enumerate() { - let code = line.split("--").next().unwrap(); - assert!( - !code - .split(|c: char| !c.is_alphanumeric() && c != '_') - .any(|w| w == "sorry"), - "{}:{}: sorry", - path.display(), - i + 1 - ); - } - } -} - -/// With Lean installed, the library builds and every law depends only on -/// `propext`, `Quot.sound` and `Classical.choice`. Without it the test -/// says so and passes: `mise install` provides Lean (`just prove` is -/// where the machine is known to have it). -#[test] -fn the_laws_use_standard_axioms_only() { - let built = Command::new("lake") - .arg("build") - .current_dir(lean_dir()) - .output(); - let Ok(built) = built else { - eprintln!("lake is not installed: the Lean build is not checked here"); - return; - }; - assert!( - built.status.success(), - "lake build:\n{}", - String::from_utf8_lossy(&built.stdout) - ); - let out = Command::new("lake") - .args(["env", "lean", "Audit.lean"]) - .current_dir(lean_dir()) - .output() - .unwrap(); - let text = String::from_utf8_lossy(&out.stdout); - assert!(out.status.success(), "audit:\n{text}"); - for law in LAWS { - let line = text - .lines() - .find(|l| l.contains(&format!("Bookspec.{law}'"))) - .unwrap_or_else(|| panic!("{law} is not audited:\n{text}")); - for axiom in line - .split('[') - .nth(1) - .unwrap() - .trim_end_matches(']') - .split(", ") - { - assert!( - ["propext", "Quot.sound", "Classical.choice"].contains(&axiom.trim()), - "{law}: axiom {axiom}" - ); - } - } -} diff --git a/bookspec/checker/tests/rules.rs b/bookspec/checker/tests/rules.rs deleted file mode 100644 index 3dbc5f2..0000000 --- a/bookspec/checker/tests/rules.rs +++ /dev/null @@ -1,169 +0,0 @@ -//! The rule catalogue: identifiers, the index table, and the examples -//! behind each rule. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; - -use bookspec_checker::{Area, Claims, RuleId, Status, covered_rules, rules}; - -fn repo_path(rel: &str) -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join(rel) -} - -/// Every rule B-1 to B-45 has exactly one identifier, its area's number -/// counts from one without a gap, and the identifier spells the area. -#[test] -fn every_b_rule_has_one_id() { - let mut seen = BTreeSet::new(); - let mut by_area: BTreeMap> = BTreeMap::new(); - for b in 1..=45 { - let rule = bookspec_checker::rule(b).unwrap_or_else(|| panic!("no rule B-{b}")); - assert_eq!(rule.id.area(), rule.area, "B-{b}: the id's area"); - assert_eq!( - rule.id.as_str().split('-').next(), - Some("BS"), - "B-{b}: the identifier's prefix" - ); - assert!(seen.insert(rule.id.clone()), "B-{b}: the id is used twice"); - assert_eq!(RuleId::parse(rule.id.as_str()).unwrap(), rule.id); - let n: u32 = rule - .id - .as_str() - .rsplit_once('-') - .unwrap() - .1 - .parse() - .unwrap(); - by_area.entry(rule.area).or_default().push(n); - } - assert_eq!(seen.len(), 45); - for (area, numbers) in by_area { - let mut want: Vec = (1..=numbers.len() as u32).collect(); - let mut got = numbers; - got.sort_unstable(); - want.sort_unstable(); - assert_eq!(got, want, "{}: numbers count from one", area.key()); - } -} - -/// The index table is the catalogue: one row per rule, the same id, area -/// and status. -#[test] -fn the_index_matches_the_catalogue() { - let text = std::fs::read_to_string(repo_path("../spec/rules-index.md")) - .expect("bookspec/spec/rules-index.md"); - let mut rows = BTreeMap::new(); - for line in text.lines() { - let Some(rest) = line.strip_prefix("| B-") else { - continue; - }; - let fields: Vec<&str> = line.split('|').map(str::trim).collect(); - // "", "B-n", "`BS-...`", area, status, rule, examples, "" - assert_eq!(fields.len(), 8, "the table has six columns: {line}"); - let b: u8 = rest - .split_once(' ') - .unwrap() - .0 - .parse() - .unwrap_or_else(|_| panic!("{line}")); - let id = fields[2].trim_matches('`'); - let area = fields[3]; - let status = fields[4]; - assert!( - rows.insert(b, (id.to_string(), area.to_string(), status.to_string())) - .is_none() - ); - } - for rule in rules() { - let (id, area, status) = rows - .get(&rule.b) - .unwrap_or_else(|| panic!("the index lists B-{}", rule.b)); - assert_eq!(id, rule.id.as_str(), "B-{}", rule.b); - assert_eq!(area, rule.area.key(), "B-{}", rule.b); - let want = rule.status.map_or("—", |s| s.key()); - assert_eq!(status, want, "B-{}", rule.b); - } - assert_eq!(rows.len(), 45, "the index lists every rule once"); -} - -/// Every rule either shows an example or is allocated and not yet stated; -/// no example id is invented, and every example file is listed. -#[test] -fn every_example_is_listed_under_a_rule() { - let mut files: Vec = std::fs::read_dir(repo_path("../examples")) - .expect("bookspec/examples") - .map(|e| e.unwrap().path()) - .filter(|p| p.extension().is_some_and(|x| x == "toml")) - .map(|p| p.file_stem().unwrap().to_string_lossy().into_owned()) - .collect(); - files.sort(); - let listed: BTreeSet<&str> = rules() - .iter() - .flat_map(|r| r.examples.iter().copied()) - .collect(); - for f in &files { - assert!(listed.contains(f.as_str()), "{f} is listed under no rule"); - } - for id in &listed { - assert!( - files.iter().any(|f| f == id), - "the catalogue lists {id}, which is no example" - ); - } - for rule in rules() { - if rule.status.is_none() { - assert!( - !rule.has_examples(), - "B-{} is not yet stated, so it has no example", - rule.b - ); - } - } -} - -/// A rule with evidence is claimed by an engine; one without is not. -#[test] -fn a_claimed_rule_has_an_example() { - let covered = covered_rules(); - assert!(!covered.is_empty()); - for rule in covered { - assert!(rule.has_examples(), "{}", rule.id); - assert!( - matches!( - rule.status, - Some(Status::Authority | Status::Convention | Status::Choice) - ), - "{}", - rule.id - ); - } -} - -/// An identifier has one spelling: `BS--`, no leading zero. -#[test] -fn an_id_has_one_spelling() { - assert_eq!(RuleId::parse("BS-entry-1").unwrap().as_str(), "BS-entry-1"); - for bad in [ - "entry-1", - "BS-nothing-1", - "BS-entry", - "BS-entry-0", - "BS-entry-01", - "BS-entry-x", - "BS-entry-1-2", - ] { - assert!(RuleId::parse(bad).is_err(), "{bad:?}"); - } -} - -/// A choice rule's option is declared by the engine, not by the spec. -#[test] -fn a_choice_rule_declares_its_option() { - let rule = bookspec_checker::rule(13).unwrap(); - assert_eq!(rule.status, Some(Status::Authority)); - let choice = bookspec_checker::rule(31).unwrap(); - assert_eq!(choice.status, Some(Status::Choice)); - let claims = Claims::new([choice.id.clone()]).with_choice(choice.id.clone(), "refuse_blank"); - assert_eq!(claims.choice(&choice.id), Some("refuse_blank")); - assert_eq!(claims.choice(&rule.id), None); -} diff --git a/bookspec/checker/tests/schema.rs b/bookspec/checker/tests/schema.rs deleted file mode 100644 index 9309b50..0000000 --- a/bookspec/checker/tests/schema.rs +++ /dev/null @@ -1,296 +0,0 @@ -//! The example schema: what a well-formed bookspec example is, and what -//! the parser refuses. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; - -use bookspec_checker::{ - Example, Query, Refusal, TEXT_VALUED, Values, refusal_reasons, rule_for_refusal, -}; - -fn examples_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples") -} - -/// A minimal well-formed example, and the text with one line replaced. -const BASE: &str = r#" -id = "schema.test" -title = "A test" -rule = "Every entry MUST balance." -status = "AUTHORITY" -sources = ["bookspec/README.md"] -tags = ["schema"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[events]] -date = "2026-01-02" -description = "Contributed" -lines = [ - { account = "1000", side = "debit", amount = "100.00" }, - { account = "3000", side = "credit", amount = "100.00" }, -] - -[[expect]] -query = "trial_balance" -as_of = "2026-01-02" -[expect.values] -"1000" = "100.00" -"3000" = "-100.00" -"#; - -fn base() -> Example { - Example::from_toml(BASE).expect("the base example is well formed") -} - -/// Every example of the seed parses and follows the schema: its closed -/// lists, its dates, its amounts and its references. -#[test] -fn every_seed_example_parses_and_validates() { - let mut files: Vec = std::fs::read_dir(examples_dir()) - .expect("bookspec/examples") - .map(|e| e.unwrap().path()) - .filter(|p| p.extension().is_some_and(|x| x == "toml")) - .collect(); - files.sort(); - assert!(files.len() >= 56, "the seed has at least 56 examples"); - let mut ids = BTreeSet::new(); - for f in &files { - let text = std::fs::read_to_string(f).unwrap(); - let ex = Example::from_toml(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display())); - let stem = f.file_stem().unwrap().to_string_lossy(); - assert_eq!(ex.id, stem, "{}: the file is named for its id", f.display()); - assert!(ids.insert(ex.id.clone()), "{}: id used twice", ex.id); - } -} - -/// A key the schema does not have is refused, never ignored. -#[test] -fn an_unknown_key_is_refused() { - let text = BASE.replace("cash_accounts = ", "not_a_key = "); - let text = format!("{text}\ncash_account = []\n"); - assert!(Example::from_toml(&text).is_err()); -} - -/// The status is one of the three the specification uses. -#[test] -fn the_status_is_authority_convention_or_choice() { - let text = BASE.replace(r#"status = "AUTHORITY""#, r#"status = "DECISION""#); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("status"), "{e}"); -} - -/// A line's amount is exact and positive. -#[test] -fn a_line_amount_is_exact_and_positive() { - let text = BASE.replace(r#"amount = "100.00" }"#, r#"amount = "-100.00" }"#); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("amounts are positive"), "{e}"); - let text = BASE.replace(r#"amount = "100.00" }"#, r#"amount = "1e2" }"#); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("exact decimal"), "{e}"); -} - -/// An event is exactly one of the nine kinds. -#[test] -fn an_event_is_exactly_one_kind() { - let text = BASE.replace( - r#"lines = [ - { account = "1000", side = "debit", amount = "100.00" }, - { account = "3000", side = "credit", amount = "100.00" }, -]"#, - r#"lines = [ - { account = "1000", side = "debit", amount = "100.00" }, - { account = "3000", side = "credit", amount = "100.00" }, -] -reverses = 1"#, - ); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("an event is one of"), "{e}"); -} - -/// `reverses` counts events from 1 and never names itself or a later one. -#[test] -fn reverses_counts_events_from_one() { - let text = format!( - "{BASE}\n[[events]]\ndate = \"2026-01-03\"\ndescription = \"Reverse it\"\nreverses = 0\n" - ); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("events count from 1"), "{e}"); -} - -/// A members close needs the shares, and they sum to 1. -#[test] -fn the_members_shares_sum_to_one() { - let text = BASE.replace( - r#"[[setup.accounts]] -code = "3000""#, - r#"[[setup.members]] -name = "A" -capital_account = "3000" -share = "0.6" - -[[setup.members]] -name = "B" -capital_account = "3000" -share = "0.5" - -[[setup.accounts]] -code = "3000""#, - ); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("shares sum to 1"), "{e}"); -} - -/// A member's share is an exact decimal fraction above zero. -#[test] -fn a_share_is_a_fraction_above_zero() { - let text = BASE.replace( - r#"[[setup.accounts]] -code = "3000""#, - r#"[[setup.members]] -name = "A" -capital_account = "3000" -share = "1.5" - -[[setup.accounts]] -code = "3000""#, - ); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("share"), "{e}"); -} - -/// Every refusal reason the specification names belongs to exactly one -/// rule, and a refusal names that rule. -#[test] -fn every_refusal_reason_names_a_rule() { - for reason in refusal_reasons() { - let rule = rule_for_refusal(reason).unwrap_or_else(|| panic!("no rule for {reason:?}")); - assert!( - !rule.title.contains("allocated"), - "{reason:?} belongs to a stated rule" - ); - assert_eq!(Refusal::of_reason(reason).rule, rule.id); - assert_eq!(Refusal::of_reason(reason).reason, reason); - } - assert!(rule_for_refusal("not_a_reason").is_none()); -} - -/// A query no crate answers is refused. -#[test] -fn an_unknown_query_is_refused() { - let text = BASE.replace(r#"query = "trial_balance""#, r#"query = "trial_balances""#); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("no query answers"), "{e}"); -} - -/// A period may be refused instead of answering; another query may not. -#[test] -fn a_period_may_be_refused() { - let text = BASE.replace( - r#"query = "trial_balance" -as_of = "2026-01-02" -[expect.values] -"1000" = "100.00" -"3000" = "-100.00""#, - r#"query = "trial_balance" -as_of = "2026-01-02" -refused = "end_before_start""#, - ); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("refused is for"), "{e}"); -} - -/// A command is accepted or refused, never both, and shows no values. -#[test] -fn a_command_shows_no_values() { - let text = BASE.replace( - r#"query = "trial_balance" -as_of = "2026-01-02" -[expect.values] -"1000" = "100.00" -"3000" = "-100.00""#, - r#"query = "rename_account" -code = "1000" -name = "Cash on hand" -accepted = true -[expect.values] -"1000" = "100.00""#, - ); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("a command is accepted"), "{e}"); -} - -/// A text-valued figure query takes words; a figure query takes exact -/// decimal strings. -#[test] -fn a_figure_query_takes_exact_decimals() { - assert!(TEXT_VALUED.contains(&"vendor_1099")); - let text = BASE.replace(r#""1000" = "100.00""#, r#""1000" = "100.0.0""#); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("exact decimal"), "{e}"); -} - -/// `Query` carries the question and not the answer, and knows which -/// queries list everything they cover. -#[test] -fn a_query_does_not_carry_its_expected_values() { - let ex = base(); - let x = &ex.expect[0]; - let q: Query = x.query(); - assert_eq!(q.name, "trial_balance"); - assert!(q.is_complete()); - assert!(!q.is_command()); - assert!(!q.is_format()); - assert_eq!( - x.values("schema.test"), - &Values::Figures(BTreeMap::from([ - ("1000".to_string(), "100.00".to_string()), - ("3000".to_string(), "-100.00".to_string()), - ])) - ); -} - -/// A format query belongs to the formats appendix. -#[test] -fn a_format_query_is_of_the_formats_appendix() { - let text = BASE.replace( - r#"query = "trial_balance" -as_of = "2026-01-02""#, - r#"query = "iif_lines" -as_of = "2026-01-02""#, - ); - let e = Example::from_toml(&text).unwrap_err(); - assert!(e.message.contains("iif_lines"), "{e}"); -} - -/// The seed's index is the table the catalogue is: B-1 to B-45, one -/// identifier each. The catalogue test reads it too; this checks the ids -/// themselves. -#[test] -fn the_rule_index_lists_every_stated_rule() { - let index = std::fs::read_to_string( - Path::new(env!("CARGO_MANIFEST_DIR")).join("../spec/rules-index.md"), - ) - .expect("bookspec/spec/rules-index.md"); - for b in 1..=45 { - assert!( - index.contains(&format!("| B-{b} |")), - "the index lists B-{b}" - ); - } -} diff --git a/bookspec/checker/tests/spec.rs b/bookspec/checker/tests/spec.rs deleted file mode 100644 index 4fdf725..0000000 --- a/bookspec/checker/tests/spec.rs +++ /dev/null @@ -1,261 +0,0 @@ -//! The specification text (`bookspec/spec/*.md`, P13 step 4): every rule -//! has its section, its statement, its pinned sources, its symbolic form -//! and its examples, and every citation resolves. - -use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; - -use bookspec_checker::{Rule, rules}; - -fn spec_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../spec") -} - -fn sources_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/sources") -} - -/// A rule's section as the text gives it. -#[derive(Default, Debug)] -struct Section { - file: String, - heading: String, - fields: BTreeMap>, -} - -/// Every `### BS-...` section of the area files, by its identifier. -fn sections() -> BTreeMap { - let mut out = BTreeMap::new(); - let mut files: Vec = std::fs::read_dir(spec_dir()) - .unwrap() - .map(|e| e.unwrap().path()) - .filter(|p| p.extension().is_some_and(|x| x == "md")) - .collect(); - files.sort(); - for f in files { - let name = f.file_name().unwrap().to_string_lossy().into_owned(); - let text = std::fs::read_to_string(&f).unwrap(); - let mut current: Option<(String, Section)> = None; - let mut field = String::new(); - for line in text.lines() { - if let Some(h) = line.strip_prefix("### BS-") { - if let Some((id, s)) = current.take() { - assert!(out.insert(id.clone(), s).is_none(), "{id} twice"); - } - let id = format!("BS-{}", h.split(':').next().unwrap()); - current = Some(( - id, - Section { - file: name.clone(), - heading: h.to_string(), - ..Section::default() - }, - )); - continue; - } - let Some((_, s)) = current.as_mut() else { - continue; - }; - if let Some(rest) = line.strip_prefix("- **") { - let (key, value) = rest.split_once(":**").expect("a field: - **Name:** ..."); - field = key.to_string(); - let e = s.fields.entry(field.clone()).or_default(); - if !value.trim().is_empty() { - e.push(value.trim().to_string()); - } - } else if let Some(item) = line.strip_prefix(" - ") { - s.fields - .entry(field.clone()) - .or_default() - .push(item.to_string()); - } - } - if let Some((id, s)) = current.take() { - assert!(out.insert(id.clone(), s).is_none(), "{id} twice"); - } - } - out -} - -fn one<'a>(s: &'a Section, id: &str, field: &str) -> &'a str { - let v = s - .fields - .get(field) - .unwrap_or_else(|| panic!("{id}: no {field}")); - assert_eq!(v.len(), 1, "{id}: {field} is one line"); - &v[0] -} - -/// Whitespace and typographic quotes made plain, so a pin copied from a -/// wrapped or curly-quoted source still matches. -fn squash(s: &str) -> String { - let plain = s - .replace(['\u{2019}', '\u{2018}'], "'") - .replace(['\u{201c}', '\u{201d}'], "\""); - plain.split_whitespace().collect::>().join(" ") -} - -fn backticked(s: &str) -> Vec<&str> { - s.split('`').skip(1).step_by(2).collect() -} - -/// Every one of the 45 rules has one section, in the file of its area, -/// headed with its identifier and B-number. -#[test] -fn every_rule_has_its_section() { - let sections = sections(); - assert_eq!(sections.len(), 45, "one section per rule"); - for rule in rules() { - let s = sections - .get(rule.id.as_str()) - .unwrap_or_else(|| panic!("{}: no section", rule.id)); - assert_eq!(s.file, format!("{}.md", rule.area.key()), "{}", rule.id); - assert!( - s.heading.ends_with(&format!("(B-{})", rule.b)), - "{}: heading {:?} names B-{}", - rule.id, - s.heading, - rule.b - ); - } -} - -/// A stated rule has a status the catalogue agrees with, a normative -/// statement, a symbolic form and applicability; a reserved one says so. -#[test] -fn every_stated_rule_is_complete() { - let sections = sections(); - for rule in rules() { - let s = §ions[rule.id.as_str()]; - let id = rule.id.as_str(); - let status = one(s, id, "Status"); - match rule.status { - Some(st) => { - assert!(status.starts_with(st.key()), "{id}: status {status:?}"); - let statement = one(s, id, "Statement"); - assert!( - ["MUST", "SHOULD", "MAY"] - .iter() - .any(|w| statement.contains(w)), - "{id}: a statement says MUST, SHOULD or MAY" - ); - for field in ["Symbolic form", "Applicability"] { - assert!( - one(s, id, field).len() > 10 && !one(s, id, field).starts_with("none"), - "{id}: {field}" - ); - } - } - None => assert!(status.starts_with("reserved"), "{id}: reserved"), - } - } -} - -/// A section's examples are the catalogue's, exactly; "none yet" says why. -#[test] -fn the_examples_are_the_catalogues() { - let sections = sections(); - for rule in rules() { - let s = §ions[rule.id.as_str()]; - let id = rule.id.as_str(); - let line = one(s, id, "Examples"); - let listed: BTreeSet<&str> = backticked(line) - .into_iter() - .filter(|b| *b != "none yet") - .collect(); - let want: BTreeSet<&str> = rule.examples.iter().copied().collect(); - if rule.examples.is_empty() { - assert!(line.starts_with("none yet"), "{id}: {line}"); - if rule.status.is_some() { - assert!( - line.len() > "none yet. ".len() + 20, - "{id}: a stated rule with no example says why" - ); - } - } else { - assert_eq!(listed, want, "{id}: examples"); - } - } -} - -/// Every citation resolves: a vendored source exists, and a verbatim -/// pin appears in it; an external reference names its paragraph. -#[test] -fn every_citation_resolves() { - let sections = sections(); - let mut vendored = 0; - let mut external = 0; - let mut bad: Vec = Vec::new(); - for rule in rules() { - let s = §ions[rule.id.as_str()]; - let id = rule.id.as_str(); - let sources = s - .fields - .get("Sources") - .unwrap_or_else(|| panic!("{id}: no Sources")); - if rule.status.is_none() { - assert_eq!(sources, &vec!["none yet".to_string()], "{id}"); - continue; - } - assert!(!sources.is_empty(), "{id}: sourced"); - for item in sources { - if let Some(rest) = item.strip_prefix("vendored `") { - let (file, rest) = rest - .split_once("`:") - .unwrap_or_else(|| panic!("{id}: {item}")); - let path = sources_dir().join(file.strip_prefix("sources/").unwrap_or(file)); - let text = std::fs::read_to_string(&path) - .unwrap_or_else(|_| panic!("{id}: {file} is not vendored")); - let hay = squash(&text); - // Every quoted pin is verbatim in the file. - let quoted: Vec<&str> = rest.split('"').skip(1).step_by(2).collect(); - for q in "ed { - if !hay.contains(&squash(q)) { - bad.push(format!("{id}: {q:?} is not in {file}")); - } - } - if quoted.is_empty() { - bad.push(format!("{id}: {item} has no verbatim pin")); - } - vendored += 1; - } else if let Some(rest) = item.strip_prefix("external:") { - assert!( - rest.trim().len() > 20, - "{id}: an external reference is exact: {item}" - ); - external += 1; - } else { - panic!("{id}: a source is `vendored` or `external`: {item}"); - } - } - } - assert!(bad.is_empty(), "pins not found:\n{}", bad.join("\n")); - assert!(vendored >= 30, "vendored citations: {vendored}"); - assert!(external >= 15, "external citations: {external}"); -} - -/// The first edition's page budget: 60 pages of 55 lines. -#[test] -fn the_edition_fits_its_page_budget() { - let mut lines = 0; - for e in std::fs::read_dir(spec_dir()).unwrap() { - let p = e.unwrap().path(); - if p.extension().is_some_and(|x| x == "md") { - lines += std::fs::read_to_string(p).unwrap().lines().count(); - } - } - assert!(lines <= 60 * 55, "{lines} lines is over 60 pages"); -} - -/// A stated rule with no example is exactly one an engine cannot claim. -#[test] -fn rules_without_examples_are_not_claimable() { - let uncovered: Vec<&Rule> = rules().iter().filter(|r| !r.has_examples()).collect(); - let claimable: BTreeSet<_> = bookspec_checker::covered_rules() - .into_iter() - .map(|r| r.b) - .collect(); - for r in uncovered { - assert!(!claimable.contains(&r.b), "B-{}", r.b); - } -} diff --git a/bookspec/examples/1099-a-dated-status-applies-by-payment-date.toml b/bookspec/examples/1099-a-dated-status-applies-by-payment-date.toml deleted file mode 100644 index afed987..0000000 --- a/bookspec/examples/1099-a-dated-status-applies-by-payment-date.toml +++ /dev/null @@ -1,94 +0,0 @@ -id = "1099-a-dated-status-applies-by-payment-date" -title = "A payee's 1099 status applies to each payment as of the payment's date" -rule = """ -Whether a payee is exempt (a corporation) is judged as of each payment: the -payer may treat a payee as a corporation when the condition is met before -the payment is made. An LLC that elects to be taxed as a corporation keeps -its EIN and becomes a corporation on the election's effective date (the -date its Form 8832 specifies). Payments before that date count toward its -1099-NEC; payments from it on are payments to a corporation, for which no -return is required. (A payee that incorporates is a new legal person: a new -payee, with its own W-9.) -""" -status = "AUTHORITY" -sources = [ - "Treas. Reg. §1.6041-3(p)(1) (docs/spec/sources/irs-1099-payment-method.md)", - "Treas. Reg. §1.6049-4(c)(1)(ii)(A) (\"before a payment is made\")", - "Treas. Reg. §301.7701-2(b)(2), §301.7701-3(c)(1)(iii)", - "DECISION D-40 (wanderbooks owner, 2026-10-20): dated statuses", -] -tags = ["1099", "dated-status", "corporation", "election"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "6000" -name = "Contract Labor" -type = "expense" - -[[setup.payees]] -name = "Harbor Design LLC" -form = "1099-NEC" - -[[setup.status_changes]] -payee = "Harbor Design LLC" -effective = "2026-07-01" -decision = "not_reportable" -reason_kind = "corporation" -reason = "elected to be taxed as a corporation (Form 8832), effective July 1" - -[[events]] -date = "2026-03-01" -description = "Design work" -payee = "Harbor Design LLC" -lines = [ - { account = "6000", side = "debit", amount = "1500.00" }, - { account = "1000", side = "credit", amount = "1500.00" }, -] - -[[events]] -date = "2026-06-30" -description = "Design work, the day before the election" -payee = "Harbor Design LLC" -lines = [ - { account = "6000", side = "debit", amount = "1000.00" }, - { account = "1000", side = "credit", amount = "1000.00" }, -] - -[[events]] -date = "2026-07-01" -description = "Design work, the election's day" -payee = "Harbor Design LLC" -lines = [ - { account = "6000", side = "debit", amount = "800.00" }, - { account = "1000", side = "credit", amount = "800.00" }, -] - -[[events]] -date = "2026-09-15" -description = "Design work" -payee = "Harbor Design LLC" -lines = [ - { account = "6000", side = "debit", amount = "1200.00" }, - { account = "1000", side = "credit", amount = "1200.00" }, -] - -# By hand: before July 1, 1,500.00 + 1,000.00 = 2,500.00 counts (at least -# 2,000.00: reportable); from July 1 on, 800.00 + 1,200.00 = 2,000.00 paid -# to a corporation, not reportable. -[[expect]] -query = "vendor_1099" -payee = "Harbor Design LLC" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "reportable", threshold = "2000.00", paid = "2500.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "2000.00" } diff --git a/bookspec/examples/1099-a-members-check-through-financing-counts.toml b/bookspec/examples/1099-a-members-check-through-financing-counts.toml deleted file mode 100644 index 7222846..0000000 --- a/bookspec/examples/1099-a-members-check-through-financing-counts.toml +++ /dev/null @@ -1,100 +0,0 @@ -id = "1099-a-members-check-through-financing-counts" -title = "A member's own check for the company counts toward the payee's 1099; their card doesn't; unknown stays unknown" -rule = """ -The payer is the person who makes the payment in the course of its trade or -business. A person paying on another's behalf is the payer only if it -performs management or oversight functions or has a significant economic -interest; writing checks at another's direction is neither. A member who -pays a company bill from their own funds at the company's direction, the -company bearing the cost through its liability to the member, leaves the -company the payer: the member's check counts toward the payee's 1099. The -member's personal card is a payment card: excluded (Form 1099-K). A -member's payment whose method isn't recorded is undetermined, never -guessed; the payee is reportable only if what certainly counts reaches the -threshold. -""" -status = "CHOICE" -sources = [ - "Treas. Reg. §1.6041-1(a)(1)(i), (e)(1) (docs/spec/sources/irs-1099-payment-method.md)", - "Treas. Reg. §1.6041-1(a)(1)(iv)", - "26 U.S.C. §6041(a), as amended by Pub. L. 119-21 §70433 (docs/spec/sources/irs-1099-thresholds.md)", - "DECISION (owner, plan/served.md P3 defect 12; D-40, 2026-10-20): a payment through financing with no payment method recorded is undetermined (wanderbooks rules.md B-19)", -] -tags = ["1099", "financing", "member-paid", "instruments"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "6000" -name = "Contract Labor" -type = "expense" - -[[setup.instruments]] -label = "Riley's checks" -kind = "member_cash_or_check" -account = "2100" - -[[setup.instruments]] -label = "Riley's Visa" -kind = "member_card" -account = "2100" - -[[setup.payees]] -name = "Dana Studio" -form = "1099-NEC" - -[[events]] -date = "2026-05-01" -description = "Logo, paid by a member's personal check" -payee = "Dana Studio" -instrument = "Riley's checks" -lines = [ - { account = "6000", side = "debit", amount = "2500.00" }, - { account = "2100", side = "credit", amount = "2500.00" }, -] - -[[events]] -date = "2026-06-01" -description = "Icons, charged to a member's personal card" -payee = "Dana Studio" -instrument = "Riley's Visa" -lines = [ - { account = "6000", side = "debit", amount = "800.00" }, - { account = "2100", side = "credit", amount = "800.00" }, -] - -[[events]] -date = "2026-07-01" -description = "Revisions, paid by a member (how not recorded)" -payee = "Dana Studio" -lines = [ - { account = "6000", side = "debit", amount = "300.00" }, - { account = "2100", side = "credit", amount = "300.00" }, -] - -# By hand: counts 2,500.00 (the member's check); excluded 800.00 (their -# card); undetermined 300.00. What certainly counts, 2,500.00, is at least -# 2,000.00 however the 300.00 turns out: reportable, on 2,500.00 (or -# 2,800.00 once the 300.00 is known to be a check). -[[expect]] -query = "vendor_1099" -payee = "Dana Studio" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "reportable", threshold = "2000.00", paid = "2500.00", excluded_card = "800.00", undetermined = "300.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-and-party-totals-for-a-members-payment.toml b/bookspec/examples/1099-and-party-totals-for-a-members-payment.toml deleted file mode 100644 index 2a4b101..0000000 --- a/bookspec/examples/1099-and-party-totals-for-a-members-payment.toml +++ /dev/null @@ -1,219 +0,0 @@ -# How a payee's party totals and its 1099 amount relate (wanderbooks -# rules.md B-35 item 9, B-19): a member paying a contractor for the -# company through the members' financing account, by check, by their own -# card and with no method recorded; a company card charge; a company -# check; then the member reimbursed. Party totals count every payment as -# paid to the contractor, in money; the 1099 worksheet counts the -# company's payments that aren't by card, and holds the unrecorded one -# undetermined. The shape: bookspec/README.md. - -id = "1099-and-party-totals-for-a-members-payment" -title = "A payee's party total is its 1099 amount plus its card payments plus its payments of unknown method" -rule = """ -A member who pays a payee for the company from their own funds, the -company owing them for it through its financing account, leaves the -company the payer: on the 1099 worksheet the member's own cash or check -counts toward the payee's 1099, the member's own card is excluded (Form -1099-K), and a payment whose method isn't recorded is undetermined. Party -totals report what moved with each party, whoever handled the money: every -one of those payments is paid to the payee, in money, on its date, and the -member is paid when the company reimburses them. So the payee's party -total paid is its 1099 amount plus what was paid by card or payment -network plus what is undetermined; the reimbursement is paid to the -member and pays no payee. -""" -status = "CHOICE" -sources = [ - "Treas. Reg. §1.6041-1(e)(1): a person paying on another's behalf who only writes the check at its direction isn't the payor (wanderbooks docs/spec/sources/irs-1099-payment-method.md)", - "Treas. Reg. §1.6041-1(a)(1)(iv): a payment card transaction is reported under §6050W, not §6041 (docs/spec/sources/irs-1099-payment-method.md)", - "DECISION (owner, 2026-10-20): money is cash, cards, the members' financing and loans (wanderbooks rules.md B-35 item 5)", - "DECISION D-40 (owner, 2026-10-20): payment methods; an untagged payment through financing is undetermined (rules.md B-19)", -] -tags = ["1099", "parties", "member-paid", "instruments", "cards"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "2050" -name = "Company Visa" -type = "liability" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital — Tobin Harlow" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital — Morgan Ellis" -type = "equity" - -[[setup.accounts]] -code = "6000" -name = "Contract Labor" -type = "expense" - -[[setup.parties]] -name = "Tobin Harlow" -roles = ["member"] - -[[setup.parties]] -name = "Morgan Ellis" -roles = ["member"] - -[[setup.parties]] -name = "Dana Studio" -roles = ["vendor", "contractor"] - -[[setup.members]] -name = "Tobin Harlow" -capital_account = "3000" -share = "0.5" - -[[setup.members]] -name = "Morgan Ellis" -capital_account = "3001" -share = "0.5" - -# The contractor's vendor record, marked for a 1099-NEC. -[[setup.payees]] -name = "Dana Studio" -form = "1099-NEC" - -[[setup.instruments]] -label = "Company Visa" -kind = "business_card" -account = "2050" - -[[setup.instruments]] -label = "Tobin's checks" -kind = "member_cash_or_check" -account = "2100" -owner = "Tobin Harlow" - -[[setup.instruments]] -label = "Tobin's Visa" -kind = "member_card" -account = "2100" -owner = "Tobin Harlow" - -# 1. Tobin pays the contractor by personal check. -[[events]] -date = "2026-03-02" -description = "Design work, paid by Tobin's check" -parties = [{ party = "Dana Studio" }] -instrument = "Tobin's checks" -lines = [ - { account = "6000", side = "debit", amount = "1500.00" }, - { account = "2100", side = "credit", amount = "1500.00" }, -] - -# 2. Tobin pays on his own card. -[[events]] -date = "2026-04-01" -description = "Icons, charged to Tobin's own card" -parties = [{ party = "Dana Studio" }] -instrument = "Tobin's Visa" -lines = [ - { account = "6000", side = "debit", amount = "400.00" }, - { account = "2100", side = "credit", amount = "400.00" }, -] - -# 3. Tobin pays; how is not recorded. -[[events]] -date = "2026-05-01" -description = "Revisions, paid by Tobin" -parties = [{ party = "Dana Studio" }] -lines = [ - { account = "6000", side = "debit", amount = "300.00" }, - { account = "2100", side = "credit", amount = "300.00" }, -] - -# 4. Charged to the company card. -[[events]] -date = "2026-06-01" -description = "Banner, on the company card" -parties = [{ party = "Dana Studio" }] -lines = [ - { account = "6000", side = "debit", amount = "200.00" }, - { account = "2050", side = "credit", amount = "200.00" }, -] - -# 5. The company's own check. -[[events]] -date = "2026-06-15" -description = "Brochure, company check" -parties = [{ party = "Dana Studio" }] -lines = [ - { account = "6000", side = "debit", amount = "600.00" }, - { account = "1000", side = "credit", amount = "600.00" }, -] - -# 6. The company reimburses Tobin for events 1 to 3. -[[events]] -date = "2026-07-01" -description = "Tobin reimbursed" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "2100", side = "debit", amount = "2200.00" }, - { account = "1000", side = "credit", amount = "2200.00" }, -] - -[[expect]] -query = "party_part" -entry = 2 -party = "Dana Studio" -values = { resolved = "400.00", direction = "paid", cash = "400.00", in_kind = "0.00" } - -# By hand: 1,500.00 + 400.00 + 300.00 + 200.00 + 600.00 = 3,000.00, every -# payment paid to the contractor, in money. -[[expect]] -query = "party_totals" -party = "Dana Studio" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "3000.00", received = "0.00" } - -# By hand: counts 1,500.00 (Tobin's check) + 600.00 (the company's check) -# = 2,100.00; excluded 400.00 (Tobin's card) + 200.00 (the company card) -# = 600.00; undetermined 300.00. 2,100.00 + 600.00 + 300.00 = 3,000.00, -# the party total. What certainly counts, 2,100.00, is at least 2,000.00: -# reportable. -[[expect]] -query = "vendor_1099" -payee = "Dana Studio" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "reportable", threshold = "2000.00", paid = "2100.00", excluded_card = "600.00", undetermined = "300.00", not_reportable = "0.00" } - -# The reimbursement is paid to Tobin, and pays no payee. -[[expect]] -query = "party_totals" -party = "Tobin Harlow" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "2200.00", received = "0.00" } - -[[expect]] -query = "vendor_1099_paid" -from = "2026-01-01" -to = "2026-12-31" -values = { "Dana Studio" = "2100.00" } diff --git a/bookspec/examples/1099-card-payments-are-excluded.toml b/bookspec/examples/1099-card-payments-are-excluded.toml deleted file mode 100644 index 0c05693..0000000 --- a/bookspec/examples/1099-card-payments-are-excluded.toml +++ /dev/null @@ -1,101 +0,0 @@ -id = "1099-card-payments-are-excluded" -title = "Payments by card are left off Form 1099-NEC, and listed" -rule = """ -A payment made by payment card (credit, debit or stored-value) or through a -third party payment network that section 6041 would otherwise cover is -reported by the payment settlement entity on Form 1099-K under section 6050W, -not by the payer on Form 1099-NEC or 1099-MISC, whoever's card it was. A -payment by check, ACH or transfer counts toward the payee's 1099-NEC. A -bank payment with no payment method recorded is presumed a check, ACH or -transfer; a debit-card payment from the bank is excluded once tagged with -the card. -""" -status = "AUTHORITY" -sources = [ - "Treas. Reg. §1.6041-1(a)(1)(iv) (docs/spec/sources/irs-1099-payment-method.md)", - "Treas. Reg. §1.6050W-1(b)(1), (b)(3) (payment card; accepted as payment)", - "IRS, Understanding your Form 1099-K (credit, debit or stored value cards)", - "Instructions for Forms 1099-MISC and 1099-NEC (Rev. 12/2026), Form 1099-K", - "DECISION D-40 (wanderbooks owner, 2026-10-20): an untagged bank payment counts", -] -tags = ["1099", "payment-card", "instruments"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "2050" -name = "Company card" -type = "liability" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "6000" -name = "Contract Labor" -type = "expense" - -[[setup.instruments]] -label = "Harbor Visa" -kind = "business_card" -account = "2050" - -[[setup.instruments]] -label = "Harbor debit card" -kind = "business_card" -account = "1000" - -[[setup.payees]] -name = "Harbor Design LLC" -form = "1099-NEC" - -[[events]] -date = "2026-02-10" -description = "Design work, charged to the company card" -payee = "Harbor Design LLC" -lines = [ - { account = "6000", side = "debit", amount = "2500.00" }, - { account = "2050", side = "credit", amount = "2500.00" }, -] - -[[events]] -date = "2026-03-10" -description = "Design work, paid with the debit card" -payee = "Harbor Design LLC" -instrument = "Harbor debit card" -lines = [ - { account = "6000", side = "debit", amount = "1200.00" }, - { account = "1000", side = "credit", amount = "1200.00" }, -] - -[[events]] -date = "2026-04-10" -description = "Design work, paid by check" -payee = "Harbor Design LLC" -lines = [ - { account = "6000", side = "debit", amount = "900.00" }, - { account = "1000", side = "credit", amount = "900.00" }, -] - -# By hand: counts 900.00 (the check); excluded 2,500.00 + 1,200.00 = -# 3,700.00 (the card's account, and the bank payment tagged with the debit -# card); 900.00 < 2,000.00: no 1099 is due. -[[expect]] -query = "vendor_1099" -payee = "Harbor Design LLC" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "under_threshold", threshold = "2000.00", paid = "900.00", excluded_card = "3700.00", undetermined = "0.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-money-between-own-accounts-pays-no-one.toml b/bookspec/examples/1099-money-between-own-accounts-pays-no-one.toml deleted file mode 100644 index c4595a9..0000000 --- a/bookspec/examples/1099-money-between-own-accounts-pays-no-one.toml +++ /dev/null @@ -1,92 +0,0 @@ -id = "1099-money-between-own-accounts-pays-no-one" -title = "Paying the card bill, reimbursing a member, or paying down a loan pays no payee" -rule = """ -A 1099 reports payments made to the payee. Money moved between the payer's -own payment sources (its bank paying its card's balance, reimbursing a -member who paid for it, paying down a loan's principal) pays no payee, even -when the entry is tagged with the card issuer, the member's payee record or -the lender: the movement out of one source is offset by the movement into -the other. -""" -status = "CONVENTION" -sources = [ - "26 U.S.C. §6041(a) (payments made to the payee: docs/spec/sources/irs-1099-thresholds.md)", - "wanderbooks P3 review R1 (2026-10-20): netting an entry's payment buckets", -] -tags = ["1099", "netting"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] -financing_accounts = ["2100", "2500"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "2050" -name = "Company card" -type = "liability" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "2500" -name = "Equipment loan" -type = "liability" - -[[setup.instruments]] -label = "Harbor Visa" -kind = "business_card" -account = "2050" - -[[setup.payees]] -name = "Harbor Card Services" -form = "1099-NEC" - -[[setup.payees]] -name = "Harbor Equipment Finance" -form = "1099-NEC" - -[[events]] -date = "2026-05-25" -description = "Card bill paid from the bank" -payee = "Harbor Card Services" -lines = [ - { account = "2050", side = "debit", amount = "1200.00" }, - { account = "1000", side = "credit", amount = "1200.00" }, -] - -[[events]] -date = "2026-06-01" -description = "Loan principal" -payee = "Harbor Equipment Finance" -lines = [ - { account = "2500", side = "debit", amount = "3000.00" }, - { account = "1000", side = "credit", amount = "3000.00" }, -] - -# By hand: the bill: +1,200.00 out of the bank, −1,200.00 into the card's -# account: nets to 0.00 in every bucket. The loan: +3,000.00 out of the -# bank, −3,000.00 into the loan (a financing account): 0.00. -[[expect]] -query = "vendor_1099" -payee = "Harbor Card Services" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "under_threshold", threshold = "2000.00", paid = "0.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } - -[[expect]] -query = "vendor_1099" -payee = "Harbor Equipment Finance" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "under_threshold", threshold = "2000.00", paid = "0.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml b/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml deleted file mode 100644 index 994db64..0000000 --- a/bookspec/examples/1099-money-from-a-payee-is-netted-only-as-a-refund.toml +++ /dev/null @@ -1,109 +0,0 @@ -id = "1099-money-from-a-payee-is-netted-only-as-a-refund" -title = "Money received from a payee lowers its 1099 only when it refunds what was paid" -rule = """ -A 1099 reports the payments the payer made to the payee in the calendar -year. Money the payee gives back of a payment (a refund crediting the -expense, or a deposit returned) reduces what was paid. Money received from -the payee for anything else (its payment of the payer's invoice, a sale to -it, an asset disposed of to it, a loan from it) is not a refund of a -payment and is not netted against the payments; it is listed beside them. -""" -status = "CONVENTION" -sources = [ - "26 U.S.C. §6041(a) (payments made in the course of the trade or business: docs/spec/sources/irs-1099-thresholds.md)", - "wanderbooks P3 review R6 (2026-10-20): a sale to a payee doesn't lower its 1099", -] -tags = ["1099", "netting", "refund"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1200" -name = "Accounts Receivable" -type = "asset" - -[[setup.accounts]] -code = "1400" -name = "Deposits" -type = "asset" - -[[setup.accounts]] -code = "2600" -name = "Loan payable" -type = "liability" - -[[setup.accounts]] -code = "6000" -name = "Contract Labor" -type = "expense" - -[[setup.payees]] -name = "Dana Studio" -form = "1099-NEC" - -[[events]] -date = "2026-02-01" -description = "Design work" -payee = "Dana Studio" -lines = [ - { account = "6000", side = "debit", amount = "3000.00" }, - { account = "1000", side = "credit", amount = "3000.00" }, -] - -[[events]] -date = "2026-03-01" -description = "Dana Studio pays the company's invoice" -older = true -payee = "Dana Studio" -lines = [ - { account = "1000", side = "debit", amount = "450.00" }, - { account = "1200", side = "credit", amount = "450.00" }, -] - -[[events]] -date = "2026-04-01" -description = "Dana Studio lends the company money" -payee = "Dana Studio" -lines = [ - { account = "1000", side = "debit", amount = "1000.00" }, - { account = "2600", side = "credit", amount = "1000.00" }, -] - -[[events]] -date = "2026-05-01" -description = "Deposit for the next project" -payee = "Dana Studio" -lines = [ - { account = "1400", side = "debit", amount = "500.00" }, - { account = "1000", side = "credit", amount = "500.00" }, -] - -[[events]] -date = "2026-06-01" -description = "Deposit returned: the project was cancelled" -payee = "Dana Studio" -lines = [ - { account = "1000", side = "debit", amount = "500.00" }, - { account = "1400", side = "credit", amount = "500.00" }, -] - -# By hand: paid 3,000.00 + 500.00 (the deposit, paid when made) − 500.00 -# (its return, a refund) = 3,000.00: reportable. The invoice payment -# (450.00) and the loan (1,000.00) are listed, not netted (netting them -# would leave 1,550.00, under the threshold). -[[expect]] -query = "vendor_1099" -payee = "Dana Studio" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "reportable", threshold = "2000.00", paid = "3000.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-the-threshold-is-the-years.toml b/bookspec/examples/1099-the-threshold-is-the-years.toml deleted file mode 100644 index 10b12a4..0000000 --- a/bookspec/examples/1099-the-threshold-is-the-years.toml +++ /dev/null @@ -1,92 +0,0 @@ -id = "1099-the-threshold-is-the-years" -title = "The 1099-NEC threshold is the one in force for the year the payments were made" -rule = """ -A payer reports payments to a payee aggregating the threshold or more in a -calendar year: $600 for payments made through December 31, 2025, and -$2,000 ("of $2,000 or more") for payments made after it (indexed for -inflation for calendar years after 2026, not yet known: never guessed). The -threshold is met at exactly the amount. -""" -status = "AUTHORITY" -sources = [ - "26 U.S.C. §6041(a), (h), §6041A(a)(2) (docs/spec/sources/irs-1099-thresholds.md)", - "Pub. L. 119-21 §70433(a), (f): \"shall apply with respect to payments made after December 31, 2025\" (docs/spec/sources/irs-1099-payment-method.md)", - "Instructions for Forms 1099-MISC and 1099-NEC (Rev. April 2025): $600", -] -tags = ["1099", "threshold"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "6000" -name = "Contract Labor" -type = "expense" - -[[setup.payees]] -name = "Riley Chen" -form = "1099-NEC" - -[[setup.payees]] -name = "Cedar Studio" -form = "1099-NEC" - -[[events]] -date = "2025-11-01" -description = "Illustration" -payee = "Riley Chen" -lines = [ - { account = "6000", side = "debit", amount = "600.00" }, - { account = "1000", side = "credit", amount = "600.00" }, -] - -[[events]] -date = "2026-02-01" -description = "Illustration" -payee = "Riley Chen" -lines = [ - { account = "6000", side = "debit", amount = "1999.99" }, - { account = "1000", side = "credit", amount = "1999.99" }, -] - -[[events]] -date = "2026-02-01" -description = "Photography" -payee = "Cedar Studio" -lines = [ - { account = "6000", side = "debit", amount = "2000.00" }, - { account = "1000", side = "credit", amount = "2000.00" }, -] - -# By hand: 2025, Riley 600.00 against $600: reportable (exactly the -# threshold). 2026, Riley 1,999.99 against $2,000: under; Cedar 2,000.00: -# reportable. -[[expect]] -query = "vendor_1099" -payee = "Riley Chen" -from = "2025-01-01" -to = "2025-12-31" -values = { status = "reportable", threshold = "600.00", paid = "600.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } - -[[expect]] -query = "vendor_1099" -payee = "Riley Chen" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "under_threshold", threshold = "2000.00", paid = "1999.99", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } - -[[expect]] -query = "vendor_1099" -payee = "Cedar Studio" -from = "2026-01-01" -to = "2026-12-31" -values = { status = "reportable", threshold = "2000.00", paid = "2000.00", excluded_card = "0.00", undetermined = "0.00", not_reportable = "0.00" } diff --git a/bookspec/examples/1099-worksheet-through-as-of.toml b/bookspec/examples/1099-worksheet-through-as-of.toml deleted file mode 100644 index 4d17f2d..0000000 --- a/bookspec/examples/1099-worksheet-through-as-of.toml +++ /dev/null @@ -1,144 +0,0 @@ -# What a 1099 worksheet as of a day counts toward a payee: the payments -# of that day's calendar year made from January 1 through the day. -# -# wanderbooks: P3 defect 21 and decision D-36 (the CPA package's 1099 -# worksheet counted the whole calendar year in a package as of June 30); -# rules.md B-19, B-33; regression test `the_1099_worksheet_stops_at_as_of` -# (ledger-package tests/package.rs). -# -# Setup beyond the accounts (bookspec/README.md): `cash_accounts` are the -# accounts payments are made from; each `[[setup.payees]]` is a payee -# marked for a form; an event's `payee` tags the payment to them. Event N -# is entry N. Expected values: vendor_1099_paid gives each marked payee's -# payments counted from `from`, January 1, through `to`, every payee -# listed. The 2026 threshold is $2,000 (B-19), so the payee is not yet -# reportable as of June 30 (1400.00) and is for the year (3050.00). - -id = "1099-worksheet-through-as-of" -title = "A 1099 worksheet as of a day counts the year's payments through that day" -rule = "A payee's 1099 amount as of a day D MUST be the payments to the payee made from January 1 of D's calendar year through D, excluding payments made with a payment card; as of December 31 it is the whole calendar year's." -status = "AUTHORITY" -sources = [ - "26 U.S.C. §6041(a), as amended by Pub. L. 119-21 §70433: payments \"of $2,000 or more in any calendar year\" (docs/spec/sources/irs-1099-thresholds.md)", - "26 U.S.C. §6041A(a)(2): \"the aggregate of such remuneration paid to such person during such calendar year\" (docs/spec/sources/irs-1099-thresholds.md)", - "IRS, Instructions for Forms 1099-MISC and 1099-NEC (Rev. December 2026), Form 1099-K: \"Payments made with a credit card or payment card and certain other types of payments, including third-party network transactions, must be reported on Form 1099-K by the payment settlement entity under section 6050W and are not subject to reporting on Form 1099-MISC or Form 1099-NEC.\" (docs/spec/sources/irs-1099-thresholds.md)", -] -tags = ["1099", "as-of", "calendar-year", "information-return", "period-boundary"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "2200" -name = "Credit card" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Owner capital" -type = "equity" - -[[setup.accounts]] -code = "6100" -name = "Contract labor" -type = "expense" - -[[setup.payees]] -name = "Contractor A" -form = "1099-NEC" - -# Entry 1: the year before. -[[events]] -date = "2025-12-31" -description = "Contract work, December" -payee = "Contractor A" -lines = [ - { account = "6100", side = "debit", amount = "100.00" }, - { account = "1000", side = "credit", amount = "100.00" }, -] - -# Entry 2. -[[events]] -date = "2026-01-02" -description = "Owner contribution" -lines = [ - { account = "1000", side = "debit", amount = "10000.00" }, - { account = "3000", side = "credit", amount = "10000.00" }, -] - -# Entry 3. -[[events]] -date = "2026-03-15" -description = "Contract work, March" -payee = "Contractor A" -lines = [ - { account = "6100", side = "debit", amount = "900.00" }, - { account = "1000", side = "credit", amount = "900.00" }, -] - -# Entry 4: by card, so reported on Form 1099-K, never counted here. -[[events]] -date = "2026-06-10" -description = "Contract work, June, by card" -payee = "Contractor A" -lines = [ - { account = "6100", side = "debit", amount = "300.00" }, - { account = "2200", side = "credit", amount = "300.00" }, -] - -# Entry 5: made on the day itself, so counted. -[[events]] -date = "2026-06-30" -description = "Contract work, June" -payee = "Contractor A" -lines = [ - { account = "6100", side = "debit", amount = "500.00" }, - { account = "1000", side = "credit", amount = "500.00" }, -] - -# Entry 6: the day after. -[[events]] -date = "2026-07-01" -description = "Contract work, July" -payee = "Contractor A" -lines = [ - { account = "6100", side = "debit", amount = "700.00" }, - { account = "1000", side = "credit", amount = "700.00" }, -] - -# Entry 7. -[[events]] -date = "2026-11-20" -description = "Contract work, November" -payee = "Contractor A" -lines = [ - { account = "6100", side = "debit", amount = "950.00" }, - { account = "1000", side = "credit", amount = "950.00" }, -] - -[[expect]] -query = "vendor_1099_paid" -from = "2026-01-01" -to = "2026-06-30" -values = { "Contractor A" = "1400.00" } - -[[expect]] -query = "vendor_1099_paid" -from = "2026-01-01" -to = "2026-12-31" -values = { "Contractor A" = "3050.00" } - -[[expect]] -query = "vendor_1099_paid" -from = "2025-01-01" -to = "2025-12-31" -values = { "Contractor A" = "100.00" } diff --git a/bookspec/examples/arap-hand-control-posting-refused.toml b/bookspec/examples/arap-hand-control-posting-refused.toml deleted file mode 100644 index a788146..0000000 --- a/bookspec/examples/arap-hand-control-posting-refused.toml +++ /dev/null @@ -1,77 +0,0 @@ -id = "arap-hand-control-posting-refused" -title = "A hand line on receivables is refused; one an earlier version wrote still reads right" -rule = "A line on a receivables or payables control account MUST carry its document, so a hand entry MUST be refused; an entry an earlier version wrote is replayed as it stands." -status = "CHOICE" -sources = [ - "plan/served.md 8.3.1 A-6 (the owner: hand entries to 1200 and 2000 are refused)", - "plan/served.md D-45", - "ASC 310-10 (receivables)", - "ASC 405-20-40-1 (a liability is removed only when paid or released)", -] -tags = ["accrual", "book", "us", "receivables", "legacy"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "1200" -name = "Accounts Receivable" -type = "asset" - -[[setup.accounts]] -code = "4000" -name = "Revenue" -type = "revenue" - -# The invoice makes 1200 a control account: its balance is now its -# documents'. The work was billed and the income counted. -[[events]] -date = "2026-01-10" -description = "Survey work" -invoice = { customer = "Ridgeway Co", amount = "100.00" } - -# A line an earlier version wrote by hand on 1200: this version's commands -# refuse one, but books that hold it must still read right, so it is -# posted as replay posts it. -[[events]] -date = "2026-01-15" -description = "Receivable entered by hand" -older = true -lines = [ - { account = "1200", side = "debit", amount = "50.00" }, - { account = "4000", side = "credit", amount = "50.00" }, -] - -# The same line written today is refused: no document carries it. -[[events]] -date = "2026-01-20" -description = "Another hand line" -refused = "untagged_control_line" -lines = [ - { account = "1200", side = "debit", amount = "25.00" }, - { account = "4000", side = "credit", amount = "25.00" }, -] - -[[expect]] -query = "balances" -as_of = "2026-12-31" -[expect.values] -"1000" = "0.00" -"1200" = "150.00" -"4000" = "150.00" - -[[expect]] -query = "trial_balance" -as_of = "2026-12-31" -[expect.values] -"1000" = "0.00" -"1200" = "150.00" -"4000" = "-150.00" diff --git a/bookspec/examples/as-of-package-stops-at-as-of.toml b/bookspec/examples/as-of-package-stops-at-as-of.toml deleted file mode 100644 index 0994451..0000000 --- a/bookspec/examples/as-of-package-stops-at-as-of.toml +++ /dev/null @@ -1,114 +0,0 @@ -# A report as of a day shows the books through that day, and nothing -# dated after it, however long after the day it is made. -# -# wanderbooks: P3 defect 21 (the CPA package's QuickBooks file held entries -# dated after the package's as_of); rules.md B-33; regression tests -# `every_file_in_an_as_of_package_stops_at_as_of` and -# `as_of_package_iif_stops_at_as_of` (ledger-package tests/package.rs). -# -# Expected values (bookspec/README.md): trial_balance gives each -# account's balance as of the day, debit positive and credit negative, -# every account listed; profit_and_loss gives each revenue and expense -# account's activity for the period on its normal side, every one -# listed, and net_income (revenue less expenses). - -id = "as-of-package-stops-at-as-of" -title = "A report as of a day leaves out what is dated after it" -rule = "A balance or report as of a day D MUST include every posted entry dated on or before D and MUST NOT include any entry dated after D, whenever it is produced; a period report through D covers the entries dated from the period's first day through D." -status = "CONVENTION" -sources = [ - "IRS Publication 538 (Rev. January 2022), Tax year: \"A tax year is an annual accounting period for keeping records and reporting income and expenses.\" (docs/spec/sources/irs-periods-and-methods.md)", - "Pacioli, Summa de arithmetica (1494), ch. 36, the trial balance: the total of the debits equals the total of the credits (Geijsbeek translation, 1914; docs/spec/sources/pacioli-1494.md)", -] -tags = ["as-of", "trial-balance", "profit-and-loss", "reversal", "period-boundary"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Owner capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" - -[[setup.accounts]] -code = "6000" -name = "Rent" -type = "expense" - -# Entry 1. -[[events]] -date = "2026-01-02" -description = "Owner contribution" -lines = [ - { account = "1000", side = "debit", amount = "10000.00" }, - { account = "3000", side = "credit", amount = "10000.00" }, -] - -# Entry 2. -[[events]] -date = "2026-02-01" -description = "Sale" -lines = [ - { account = "1000", side = "debit", amount = "4000.00" }, - { account = "4000", side = "credit", amount = "4000.00" }, -] - -# Entry 3: dated the day itself, so in. -[[events]] -date = "2026-06-30" -description = "June rent" -lines = [ - { account = "6000", side = "debit", amount = "1200.00" }, - { account = "1000", side = "credit", amount = "1200.00" }, -] - -# Entry 4: the day after, so out of anything as of 2026-06-30. -[[events]] -date = "2026-07-01" -description = "July rent" -lines = [ - { account = "6000", side = "debit", amount = "1200.00" }, - { account = "1000", side = "credit", amount = "1200.00" }, -] - -# Entry 5: a reversal dated after the day leaves its original in. -[[events]] -date = "2026-07-15" -description = "Reversal of the February sale" -reverses = 2 - -[[expect]] -query = "trial_balance" -as_of = "2026-06-30" -values = { "1000" = "12800.00", "3000" = "-10000.00", "4000" = "-4000.00", "6000" = "1200.00" } - -[[expect]] -query = "profit_and_loss" -from = "2026-01-01" -to = "2026-06-30" -values = { "4000" = "4000.00", "6000" = "1200.00", net_income = "2800.00" } - -# The same books a month later: the July rent and the reversal are in. -[[expect]] -query = "trial_balance" -as_of = "2026-07-31" -values = { "1000" = "7600.00", "3000" = "-10000.00", "4000" = "0.00", "6000" = "2400.00" } - -[[expect]] -query = "profit_and_loss" -from = "2026-01-01" -to = "2026-07-31" -values = { "4000" = "0.00", "6000" = "2400.00", net_income = "-2400.00" } diff --git a/bookspec/examples/assets-723-basis-apart-from-depreciable-basis.toml b/bookspec/examples/assets-723-basis-apart-from-depreciable-basis.toml deleted file mode 100644 index 202300b..0000000 --- a/bookspec/examples/assets-723-basis-apart-from-depreciable-basis.toml +++ /dev/null @@ -1,73 +0,0 @@ -# P3 defect 13, named regression 4 -# (personal_use_contribution_keeps_its_723_basis_apart_from_depreciable_basis). -id = "assets-723-basis-apart-from-depreciable-basis" -title = "Property contributed from personal use: the §723 basis apart from the depreciable basis" -rule = "The partnership's basis in property a partner contributes is the partner's adjusted basis (IRC §723). Its depreciable basis, for property converted from personal use, is the lesser of fair market value and that adjusted basis at conversion (CHOICE recorded by the owner as D-41, the CPA to confirm). The §704(c) built-in gain or loss is fair market value less the §723 basis at contribution; a built-in loss counts only for the contributing partner (IRC §704(c)(1)(C))." -status = "CHOICE" -sources = [ - "26 U.S.C. §723", - "Treas. Reg. §1.168(i)-4(b)(1): 'the lesser of its fair market value or its adjusted depreciable basis'", - "IRS Pub. 946 (2025), Property changed from personal use", - "Treas. Reg. §1.704-3(a)(3)(ii)", - "26 U.S.C. §704(c)(1)(C)", - "DECISION D-41 (owner, 2026-10-20): the depreciable basis of contributed personal-use property is the lesser of its fair market value and the member's adjusted basis, the CPA to confirm (wanderbooks plan/served.md; rules.md B-25)", -] -tags = ["fixed-assets", "tax-basis", "partnerships", "704c"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -# A laptop a member contributes from personal use: worth 1,440.00, held by -# the member at an adjusted basis of 1,688.40. (Nothing rounds here.) -[[setup.assets]] -id = "laptop" -description = "Laptop" -cost = "1440.00" -salvage = "0.00" -life_months = 36 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1688.40", fmv_at_contribution = "1440.00" } - -[[events]] -date = "2026-01-01" -description = "Laptop contributed at its value" -lines = [ - { account = "1500", side = "debit", amount = "1440.00" }, - { account = "3000", side = "credit", amount = "1440.00" }, -] - -# Hand check: §723 basis 1,688.40 (the member's adjusted basis). -# Depreciable: the lesser of 1,440.00 and 1,688.40 = 1,440.00. Built-in -# gain: 1,440.00 - 1,688.40 = -248.40, a loss (§704(c)(1)(C)). -[[expect]] -query = "asset_basis" -asset = "laptop" -values = { tax_basis_723 = "1688.40", depreciable_basis = "1440.00", built_in_gain_704c = "-248.40", sec704c_1c = "true" } diff --git a/bookspec/examples/assets-a-reversed-run-is-not-posted.toml b/bookspec/examples/assets-a-reversed-run-is-not-posted.toml deleted file mode 100644 index aafc03f..0000000 --- a/bookspec/examples/assets-a-reversed-run-is-not-posted.toml +++ /dev/null @@ -1,95 +0,0 @@ -# P3 defect 13, named regression 2 (a_reversed_depreciation_run_is_not_posted), its -# posted figure; a reversal of the reversal reinstates the run. -id = "assets-a-reversed-run-is-not-posted" -title = "A reversed depreciation run is no longer posted; reversing the reversal reinstates it" -rule = "The accumulated depreciation the books carry for an asset counts each run while its entry stands: a reversal takes the run back from the reversal's date, and a reversal of that reversal puts it back from its own date." -status = "AUTHORITY" -sources = [ - "wanderbooks docs/spec/rules.md B-8, B-9 (a correction is an opposite entry; reversing the reversal reinstates the original)", - "wanderbooks docs/spec/rules.md B-15 rules 6 and 8", -] -tags = ["fixed-assets", "depreciation", "reversal"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -# 1,200.00, no salvage, 12 months from 2026-01-01: 100.00 a month. -[[setup.assets]] -id = "printer" -description = "Printer" -cost = "1200.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 - -[[events]] -date = "2026-01-01" -description = "Printer bought" -lines = [ - { account = "1500", side = "debit", amount = "1200.00" }, - { account = "1000", side = "credit", amount = "1200.00" }, -] - -# January's run: 100.00, dated Jan 31. -[[events]] -run_depreciation = { asset = "printer", period = "2026-01" } - -[[events]] -date = "2026-02-01" -description = "Reversal of the January run" -reverses = 2 - -[[events]] -date = "2026-02-02" -description = "Reversal of the reversal" -reverses = 3 - -# Hand check: on Jan 31 the books carry January's run, 100.00; on Feb 1 its -# reversal takes it back, 0.00, while the schedule through Feb 1 is -# 100.00 + 100.00 x 1/28 = 103.57 (all of it unposted); on Feb 2 the -# reversal of the reversal puts the run back, 100.00. -[[expect]] -query = "register_row" -asset = "printer" -as_of = "2026-01-31" -values = { accumulated_posted = "100.00", book_value = "1100.00" } - -[[expect]] -query = "register_row" -asset = "printer" -as_of = "2026-02-01" -values = { accumulated_posted = "0.00", accumulated_scheduled = "103.57", unposted = "103.57", book_value = "1200.00" } - -[[expect]] -query = "register_row" -asset = "printer" -as_of = "2026-02-02" -values = { accumulated_posted = "100.00", book_value = "1100.00" } diff --git a/bookspec/examples/assets-acquired-before-in-service.toml b/bookspec/examples/assets-acquired-before-in-service.toml deleted file mode 100644 index 6346733..0000000 --- a/bookspec/examples/assets-acquired-before-in-service.toml +++ /dev/null @@ -1,84 +0,0 @@ -# P3 defect 13, named regression 3 -# (an_asset_bought_before_it_is_in_service_is_on_the_register_from_acquisition). -id = "assets-acquired-before-in-service" -title = "An asset is on the register from acquisition and depreciates from its in-service date" -rule = "Depreciation starts on the day the asset is placed in service; the asset is on the register (and the books carry its cost) from the day the company acquired it, the date of the entry that put it on the books." -status = "CHOICE" -sources = [ - "ASC 360-10-35-4 (cost allocated over the periods the asset serves)", - "IRS Pub. 946 (2025), Placed in Service: 'ready and available for a specific use' (for tax depreciation, the CPA's)", - "wanderbooks docs/spec/rules.md B-15, B-24", - "DECISION (owner, 2026-10-20; wanderbooks plan/served.md P3, \"Settled in design\"): an asset's acquired date comes from the entry it is linked to (rules.md B-15 rule 7)", -] -tags = ["fixed-assets", "dates"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -# A kiln bought 2026-03-10 for 3,000.00, placed in service 2026-04-01, 36 -# months, no salvage. -[[setup.assets]] -id = "kiln" -description = "Kiln" -cost = "3000.00" -salvage = "0.00" -life_months = 36 -in_service = "2026-04-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 - -[[events]] -date = "2026-03-10" -description = "Kiln bought" -lines = [ - { account = "1500", side = "debit", amount = "3000.00" }, - { account = "1000", side = "credit", amount = "3000.00" }, -] - -# Hand check: on the register from Mar 10 (its entry's date), nothing -# accrued before Apr 1 (a March charge from the purchase date would have -# been 3,000.00 / 36 x 22/31 = 59.14: none is due), and the register -# carries the cost the books do. -[[expect]] -query = "register_row" -asset = "kiln" -as_of = "2026-03-31" -values = { acquired = "2026-03-10", in_service = "2026-04-01", accumulated_scheduled = "0.00", accumulated_posted = "0.00", book_value = "3000.00" } - -[[expect]] -query = "register_totals" -as_of = "2026-03-31" -values = { total_cost = "3000.00", unregistered_cost = "0.00" } - -# April: 3,000.00 / 36 = 83.333... -> 83.33. -[[expect]] -query = "depreciation_schedule" -asset = "kiln" -as_of = "2026-04-30" -values = { "2026-03" = "0.00", "2026-04" = "83.33" } diff --git a/bookspec/examples/assets-book-value-is-what-the-books-carry.toml b/bookspec/examples/assets-book-value-is-what-the-books-carry.toml deleted file mode 100644 index 9edc9d9..0000000 --- a/bookspec/examples/assets-book-value-is-what-the-books-carry.toml +++ /dev/null @@ -1,86 +0,0 @@ -# P3 defect 13, named regression 1 (list_assets_book_value_is_what_the_books_carry). -id = "assets-book-value-is-what-the-books-carry" -title = "Book value is cost less the depreciation posted, never the schedule's" -rule = "An asset's carrying amount is its cost less the accumulated depreciation the books have recorded for it; what the depreciation schedule says is due through a date is a second figure, reported beside it, and their difference is depreciation not yet posted." -status = "AUTHORITY" -sources = [ - "ASC 360-10-35-4 (cost less salvage allocated over the periods the asset serves)", - "wanderbooks docs/spec/rules.md B-24 (the register reconciles to the general ledger)", -] -tags = ["fixed-assets", "depreciation", "posted-vs-scheduled"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -# A laptop contributed 2026-05-15 at 1,440.00, in service that day, 36 -# months, no salvage: 40.00 a month. -[[setup.assets]] -id = "laptop" -description = "Laptop" -cost = "1440.00" -salvage = "0.00" -life_months = 36 -in_service = "2026-05-15" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 - -[[events]] -date = "2026-05-15" -description = "Laptop contributed" -lines = [ - { account = "1500", side = "debit", amount = "1440.00" }, - { account = "3000", side = "credit", amount = "1440.00" }, -] - -# May's run, dated the month's last day: 40.00 x 17/31 = 21.9355 -> 21.94. -[[events]] -run_depreciation = { asset = "laptop", period = "2026-05" } - -[[expect]] -query = "run" -event = 2 - -[expect.values] -amount = "21.94" -caught_up = "0.00" - -# Hand check: through May 20, 6 days of May, 40.00 x 6/31 = 7.7419 -> -# 7.74 is due; the run is dated May 31, so nothing is posted yet, and the -# book value is the cost (before P3 the tool called 1,432.26 book value). -[[expect]] -query = "register_row" -asset = "laptop" -as_of = "2026-05-20" -values = { accumulated_posted = "0.00", accumulated_scheduled = "7.74", unposted = "7.74", book_value = "1440.00" } - -[[expect]] -query = "register_row" -asset = "laptop" -as_of = "2026-05-31" -values = { accumulated_posted = "21.94", accumulated_scheduled = "21.94", unposted = "0.00", book_value = "1418.06" } diff --git a/bookspec/examples/assets-built-in-gain-fixed-at-contribution.toml b/bookspec/examples/assets-built-in-gain-fixed-at-contribution.toml deleted file mode 100644 index efe5fc4..0000000 --- a/bookspec/examples/assets-built-in-gain-fixed-at-contribution.toml +++ /dev/null @@ -1,78 +0,0 @@ -# P3 defect 13, named regression 5 (built_in_gain_is_fixed_at_contribution_not_book_cost). -id = "assets-built-in-gain-fixed-at-contribution" -title = "The §704(c) built-in gain is fixed at contribution, whatever the book cost becomes" -rule = "The built-in gain on contributed property is the excess of its book value (its fair market value) over the contributing partner's adjusted tax basis upon contribution; correcting the book cost later never changes the figure at contribution." -status = "AUTHORITY" -sources = [ - "Treas. Reg. §1.704-3(a)(3)(i)-(ii)", -] -tags = ["fixed-assets", "tax-basis", "704c"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -# A press contributed from business use: worth 1,000.00, held by the member -# at an adjusted basis of 400.00. (Nothing rounds here.) -[[setup.assets]] -id = "press" -description = "Press" -cost = "1000.00" -salvage = "0.00" -life_months = 36 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 -basis = { rule = "carryover_723", contributor_adjusted_basis = "400.00", fmv_at_contribution = "1000.00" } - -[[events]] -date = "2026-01-01" -description = "Press contributed at its value" -lines = [ - { account = "1500", side = "debit", amount = "1000.00" }, - { account = "3000", side = "credit", amount = "1000.00" }, -] - -# Hand check: §723 basis 400.00, depreciated from (carryover); built-in -# gain 1,000.00 - 400.00 = 600.00. -[[expect]] -query = "asset_basis" -asset = "press" -after = 1 -values = { tax_basis_723 = "400.00", depreciable_basis = "400.00", built_in_gain_704c = "600.00" } - -# The book cost corrected to 1,100.00 (costs to bring it to use): the gain -# at contribution stays 1,000.00 - 400.00 = 600.00, not 700.00. -[[changes]] -after = 1 -correct_asset = { asset = "press", cost = "1100.00", reason = "costs to bring it to use" } - -[[expect]] -query = "asset_basis" -asset = "press" -values = { tax_basis_723 = "400.00", fmv_at_contribution = "1000.00", built_in_gain_704c = "600.00" } diff --git a/bookspec/examples/assets-four-contributed-devices.toml b/bookspec/examples/assets-four-contributed-devices.toml deleted file mode 100644 index 37028b7..0000000 --- a/bookspec/examples/assets-four-contributed-devices.toml +++ /dev/null @@ -1,156 +0,0 @@ -# P3 defect 13: four devices two members contribute from personal use, each at the -# lower of its value and its basis. -id = "assets-four-contributed-devices" -title = "Each contributed device takes the lower of its value and its basis; gains and losses are per property" -rule = "Each item of contributed property is its own: depreciable basis is the lesser of its fair market value and the contributor's adjusted basis; its built-in gain or loss is its own, and gains and losses of different properties are never netted." -status = "CHOICE" -sources = [ - "Treas. Reg. §1.168(i)-4(b)(1)", - "Treas. Reg. §1.704-3(a)(2): section 704(c) applies 'on a property-by-property basis'", - "Treas. Reg. §1.704-3(a)(3)(ii)", - "26 U.S.C. §704(c)(1)(C)", - "DECISION D-41 (owner, 2026-10-20): the depreciable basis of contributed personal-use property is the lesser of its fair market value and the member's adjusted basis, the CPA to confirm (wanderbooks plan/served.md; rules.md B-25)", -] -tags = ["fixed-assets", "tax-basis", "partnerships", "704c"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital, Tobin Harlow" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital, Morgan Ellis" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.members]] -name = "Tobin Harlow" -capital_account = "3000" -share = "0.5" - -[[setup.members]] -name = "Morgan Ellis" -capital_account = "3001" -share = "0.5" - -# Four devices the two members contribute jointly from personal use, each -# at its value. (Nothing rounds here.) -[[setup.assets]] -id = "laptop" -description = "Laptop" -cost = "1400.00" -salvage = "0.00" -life_months = 36 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1750.40", fmv_at_contribution = "1400.00", sec704c_method = "traditional" } - -[[setup.assets]] -id = "phone" -description = "Phone" -cost = "900.00" -salvage = "0.00" -life_months = 36 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1129.99", fmv_at_contribution = "900.00", sec704c_method = "traditional" } - -[[setup.assets]] -id = "camera" -description = "Camera" -cost = "1300.00" -salvage = "0.00" -life_months = 36 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1080.00", fmv_at_contribution = "1300.00", sec704c_method = "traditional" } - -[[setup.assets]] -id = "monitor" -description = "Monitor" -cost = "500.00" -salvage = "0.00" -life_months = 36 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "500.00", fmv_at_contribution = "500.00", sec704c_method = "traditional" } - -[[events]] -date = "2026-01-01" -description = "Four devices contributed jointly" -lines = [ - { account = "1500", side = "debit", amount = "1400.00" }, - { account = "1500", side = "debit", amount = "900.00" }, - { account = "1500", side = "debit", amount = "1300.00" }, - { account = "1500", side = "debit", amount = "500.00" }, - { account = "3000", side = "credit", amount = "2050.00" }, - { account = "3001", side = "credit", amount = "2050.00" }, -] - -# Hand check, each device its own: the lesser of value and basis, and the -# value less the basis. -[[expect]] -query = "asset_basis" -asset = "laptop" -values = { tax_basis_723 = "1750.40", depreciable_basis = "1400.00", built_in_gain_704c = "-350.40", sec704c_1c = "true" } - -[[expect]] -query = "asset_basis" -asset = "phone" -values = { tax_basis_723 = "1129.99", depreciable_basis = "900.00", built_in_gain_704c = "-229.99", sec704c_1c = "true" } - -[[expect]] -query = "asset_basis" -asset = "camera" -values = { tax_basis_723 = "1080.00", depreciable_basis = "1080.00", built_in_gain_704c = "220.00", sec704c_1c = "false" } - -[[expect]] -query = "asset_basis" -asset = "monitor" -values = { tax_basis_723 = "500.00", depreciable_basis = "500.00", built_in_gain_704c = "0.00", sec704c_1c = "false" } - -# Totals: cost 1,400.00 + 900.00 + 1,300.00 + 500.00 = 4,100.00; §723 -# 1,750.40 + 1,129.99 + 1,080.00 + 500.00 = 4,460.39; depreciable -# 1,400.00 + 900.00 + 1,080.00 + 500.00 = 3,880.00. The gains and losses -# are each property's own: the register totals none of them. -[[expect]] -query = "register_totals" -as_of = "2026-06-30" -values = { total_cost = "4100.00", total_tax_basis_723 = "4460.39", total_depreciable_basis = "3880.00", missing_basis = "0" } diff --git a/bookspec/examples/bad-debt-direct-write-off.toml b/bookspec/examples/bad-debt-direct-write-off.toml deleted file mode 100644 index 7d706f0..0000000 --- a/bookspec/examples/bad-debt-direct-write-off.toml +++ /dev/null @@ -1,76 +0,0 @@ -id = "bad-debt-direct-write-off" -title = "An invoice that will not be collected is written off, in parts" -rule = "A debt that becomes worthless MUST be written off in the year it becomes worthless, a partial write-off MUST be deductible to the extent charged off, and no write-off may exceed what is owed." -status = "CHOICE" -sources = [ - "IRC §166(a)", - "IRC §111", - "Treas. Reg. §1.166-1(e)", - "Treas. Reg. §1.166-2(a)", - "Treas. Reg. §1.166-3(a)", - "plan/served.md 8.3.1 A-5: the direct write-off method", - "ASC 326-20 (noted: an allowance is the GAAP preference)", -] -tags = ["accrual", "book", "us", "receivables"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "1200" -name = "Accounts Receivable" -type = "asset" - -[[setup.accounts]] -code = "4000" -name = "Revenue" -type = "revenue" - -[[setup.accounts]] -code = "6990" -name = "Bad Debt Expense" -type = "expense" -tax_category = "bad_debts" - -# The work was done and billed: the invoice is income already counted. -[[events]] -date = "2026-01-10" -description = "Survey work" -invoice = { customer = "Ridgeway Co", amount = "100.00" } - -# Part of it is charged off when the collection efforts end; the rest -# stays owed and aged. -[[events]] -date = "2026-03-15" -write_off = { invoice = 1, amount = "40.00", reason = "No answer; the collection agency closed the file" } - -# The liquidation notice makes the rest worthless, and it is written off. -[[events]] -date = "2026-04-20" -write_off = { invoice = 1, amount = "60.00", reason = "Liquidation notice received" } - -[[expect]] -query = "balances" -as_of = "2026-12-31" -[expect.values] -"1000" = "0.00" -"1200" = "0.00" -"4000" = "100.00" -"6990" = "100.00" - -[[expect]] -query = "trial_balance" -as_of = "2026-12-31" -[expect.values] -"1000" = "0.00" -"1200" = "0.00" -"4000" = "-100.00" -"6990" = "100.00" diff --git a/bookspec/examples/cash-flow.debt-to-equity.toml b/bookspec/examples/cash-flow.debt-to-equity.toml deleted file mode 100644 index d170594..0000000 --- a/bookspec/examples/cash-flow.debt-to-equity.toml +++ /dev/null @@ -1,94 +0,0 @@ -id = "cash-flow.debt-to-equity" -title = "An expense a member paid personally, then the member's advance converted to capital" -rule = "An expense a member pays for the company on credit to the member MUST be shown as cash borrowed from the member and spent, on its date; converting that debt to equity MUST be disclosed as a noncash financing activity, not reported as a cash flow, though both its accounts are financing." -# CHOICE: entry 1. ASC 230 doesn't address a payment a third party makes -# on the entity's behalf; the firm guides present it by substance as a -# constructive receipt and disbursement (a financing inflow and an -# operating outflow), and the owner chose that (wanderbooks D-42, revising -# D-35's noncash presentation). Entry 2's disclosure is 50-4's own example. -status = "CHOICE" -sources = [ - "ASC 230-10-45-14(b)", - "ASC 230-10-45-17(b)", - "ASC 230-10-50-3", - "ASC 230-10-50-4", - "PwC Viewpoint, Financial statement presentation 6.9.3.1 (ASC 230 does not address cash a third party receives or pays on the entity's behalf; include it as though through the entity's accounts)", - "Deloitte Roadmap, Statement of cash flows 7.2 (constructive receipt and disbursement)", - "BDO Blueprint, Statement of cash flows under ASC 230 (December 2025) 3.4", - "wanderbooks D-42 (owner, 2026-10-20: a member or lender paying for the company is cash borrowed and spent)", -] -tags = ["accrual", "book", "us", "cash-flow", "noncash", "constructive", "partnership"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "2100" -name = "Due to members" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital — Member A" -type = "equity" - -[[setup.accounts]] -code = "6000" -name = "Supplies" -type = "expense" - -[[setup.members]] -name = "Member A" -capital_account = "3000" - -[[events]] -date = "2026-02-03" -description = "Supplies, paid by Member A personally" -lines = [ - { account = "6000", side = "debit", amount = "500.00" }, - { account = "2100", side = "credit", amount = "500.00" }, -] - -[[events]] -date = "2026-02-10" -description = "Member A's advance converted to capital" -lines = [ - { account = "2100", side = "debit", amount = "500.00" }, - { account = "3000", side = "credit", amount = "500.00" }, -] - -# By hand: no company cash moved. Entry 1 is borrowed from the member -# (financing +500.00, 45-14(b)) and spent (operating -500.00, class (d), -# 45-17(b)) on 2026-02-03, all of it on the company's behalf; net income -# -500.00 needs no reconciling item. Entry 2 converts debt to equity, 50-4's -# first example: disclosed 500.00, though both its accounts are financing. -[[expect]] -query = "cash_flow" -from = "2026-02-01" -to = "2026-02-28" - -[expect.values] -operating = "-500.00" -investing = "0.00" -financing = "500.00" -net_change = "0.00" -difference = "0.00" -net_income = "-500.00" -"operating.1" = "-500.00" -"operating.1.on_behalf" = "-500.00" -"operating.count" = "1" -"financing.1" = "500.00" -"financing.1.on_behalf" = "500.00" -"financing.count" = "1" -"noncash.2" = "500.00" -"noncash.count" = "1" diff --git a/bookspec/examples/cash-flow.government-fees.toml b/bookspec/examples/cash-flow.government-fees.toml deleted file mode 100644 index ab7b151..0000000 --- a/bookspec/examples/cash-flow.government-fees.toml +++ /dev/null @@ -1,184 +0,0 @@ -# A payment to a government is not a supplier's, whatever account it is -# booked to: the payee decides (and so does a taxes-and-licenses account). -# -# wanderbooks: the P3 cutover review's open point 2 (a state's LLC filing -# fee, booked to an organization-costs account, read as cash paid to -# suppliers); rules.md B-14 rule 5; regression tests -# `a_payment_to_a_government_is_other_operating`, -# `a_government_payee_moves_a_fee_on_any_account`, -# `a_shared_government_payment_splits_to_the_cent`, -# `operating_classes_follow_asc_230_10_45_25` (ledger-core -# tests/spec_cash_flow.rs). - -id = "cash-flow.government-fees" -title = "Fees paid to a government are other operating payments, not cash paid to suppliers" -rule = "In a statement of cash flows by the direct method, an operating cash payment to a government for taxes, duties, fines or other fees or penalties MUST NOT be reported as cash paid to suppliers and employees: it MUST be reported among other operating cash payments (income taxes apart), whether the payee is a government or the account is one for taxes and licenses. A payment to a supplier on the same account MUST stay cash paid to suppliers, and a payment shared between a government and a supplier MUST be split by their shares." -status = "AUTHORITY" -sources = [ - "ASC 230-10-45-17(b): \"Cash payments to other suppliers and employees for other goods or services\"; 45-17(c): \"Cash payments to governments for taxes, duties, fines, and other fees or penalties\" (EY Financial reporting developments, Statement of cash flows, July 2026, p. 43; docs/spec/sources/fasb-asc.md)", - "ASC 230-10-45-22: \"a reporting entity shall determine each separately identifiable source or each separately identifiable use within the cash receipts and cash payments on the basis of the nature of the underlying cash flows\" (EY p. 44)", - "ASC 230-10-45-25: the classes reported at a minimum, (d) cash paid to employees and other suppliers, (f) income taxes paid, (g) other operating cash payments (EY p. 8)", -] -tags = ["cash", "book", "us", "cash-flow", "operating-classes"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-even" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -# Formation costs: a state's filing fee and a lawyer's bill both land here. -[[setup.accounts]] -code = "6000" -name = "Organization costs" -type = "expense" -tax_category = "organization_startup" - -[[setup.accounts]] -code = "6400" -name = "Office supplies" -type = "expense" -tax_category = "office_expense" - -[[setup.accounts]] -code = "6700" -name = "Taxes and licenses" -type = "expense" -tax_category = "taxes_licenses" - -[[setup.parties]] -name = "Department of Commerce, Corporations Division" -roles = ["government_agency"] - -[[setup.parties]] -name = "Department of Revenue" -roles = ["tax_agency"] - -[[setup.parties]] -name = "Ashgrove Legal LLP" -roles = ["vendor"] - -[[setup.parties]] -name = "Paper Goods Co." -roles = ["vendor"] - -[[setup.parties]] -name = "Northgate Registered Agents" -roles = ["vendor"] - -# Event 1. -[[events]] -date = "2026-01-05" -description = "Opening contribution" -lines = [ - { account = "1000", side = "debit", amount = "2000.00" }, - { account = "3000", side = "credit", amount = "2000.00" }, -] - -# Event 2: the state's fee to file the company, on the formation account. -[[events]] -date = "2026-02-02" -description = "Certificate of organization filing fee" -lines = [ - { account = "6000", side = "debit", amount = "75.00" }, - { account = "1000", side = "credit", amount = "75.00" }, -] -parties = [{ party = "Department of Commerce, Corporations Division" }] - -# Event 3: the lawyer's bill for the operating agreement, the same account. -[[events]] -date = "2026-02-03" -description = "Operating agreement" -lines = [ - { account = "6000", side = "debit", amount = "350.00" }, - { account = "1000", side = "credit", amount = "350.00" }, -] -parties = [{ party = "Ashgrove Legal LLP" }] - -# Event 4: a business license, on the taxes-and-licenses account, no -# party named. -[[events]] -date = "2026-02-10" -description = "City business license" -lines = [ - { account = "6700", side = "debit", amount = "40.00" }, - { account = "1000", side = "credit", amount = "40.00" }, -] - -# Event 5: a late-filing penalty paid to the tax agency, on supplies by -# mistake: still the government's. -[[events]] -date = "2026-02-12" -description = "Late filing penalty" -lines = [ - { account = "6400", side = "debit", amount = "15.00" }, - { account = "1000", side = "credit", amount = "15.00" }, -] -parties = [{ party = "Department of Revenue" }] - -# Event 6: paper and toner. -[[events]] -date = "2026-02-20" -description = "Paper and toner" -lines = [ - { account = "6400", side = "debit", amount = "62.50" }, - { account = "1000", side = "credit", amount = "62.50" }, -] -parties = [{ party = "Paper Goods Co." }] - -# Event 7: an annual report filed through a registered agent, one -# payment: 37.00 of it the state's fee, 100.00 the agent's service. -[[events]] -date = "2026-02-25" -description = "Annual report through the registered agent" -lines = [ - { account = "6000", side = "debit", amount = "137.00" }, - { account = "1000", side = "credit", amount = "137.00" }, -] -parties = [ - { party = "Department of Commerce, Corporations Division", amount = "37.00" }, - { party = "Northgate Registered Agents", amount = "100.00" }, -] - -# By hand: February's payments are 75.00 + 350.00 + 40.00 + 15.00 + 62.50 -# + 137.00 = 679.50, all operating. To governments (45-17(c)), other -# operating cash payments (g): the filing fee (a government agency is its -# payee), the license (its account is taxes and licenses), the penalty (a -# tax agency is its payee) and the state's part of the annual report -# (separately identifiable by its share, 45-22) = 75.00 + 40.00 + 15.00 + -# 37.00 = 167.00. To suppliers (45-17(b)), (d): the lawyer, the paper and -# the agent's part = 350.00 + 62.50 + 100.00 = 512.50. Cash 2,000.00 to -# 1,320.50. -[[expect]] -query = "cash_flow" -from = "2026-02-01" -to = "2026-02-28" - -[expect.values] -operating = "-679.50" -investing = "0.00" -financing = "0.00" -net_change = "-679.50" -cash_beginning = "2000.00" -cash_ending = "1320.50" -difference = "0.00" -"operating.2" = "-75.00" -"operating.3" = "-350.00" -"operating.4" = "-40.00" -"operating.5" = "-15.00" -"operating.6" = "-62.50" -"operating.7" = "-137.00" -"operating.count" = "6" -"class.d" = "-512.50" -"class.g" = "-167.00" diff --git a/bookspec/examples/cash-flow.paid-on-the-companys-behalf.toml b/bookspec/examples/cash-flow.paid-on-the-companys-behalf.toml deleted file mode 100644 index 6757237..0000000 --- a/bookspec/examples/cash-flow.paid-on-the-companys-behalf.toml +++ /dev/null @@ -1,187 +0,0 @@ -id = "cash-flow.paid-on-the-companys-behalf" -title = "A member and a lender pay for the company directly; the member is repaid" -rule = "When a member or a lender pays a company expense or purchase directly, with no cash account moving, the statement of cash flows MUST show a financing inflow (borrowed from the member or lender, or contributed when the member's capital is credited for an expense) and the operating or investing outflow, both on that payment's date; repaying the member or lender MUST be a financing outflow; such a payment and its reversal MUST net out together." -# CHOICE: ASC 230 does not address cash a third party pays on the entity's -# behalf. The firm guides present it by substance as a constructive -# receipt and disbursement (PwC 6.9.3.1; Deloitte 7.2, Example 7-2, a -# lender paying an insurer; BDO 3.4, Example 3-5, a bank paying the -# supplier of equipment, and a parent paying a subsidiary's expenses), -# and the owner chose that (wanderbooks D-42). KPMG also accepts a noncash -# presentation for equipment a lender pays for. -status = "CHOICE" -sources = [ - "ASC 230-10-45-13(c)", - "ASC 230-10-45-14(a)", - "ASC 230-10-45-14(b)", - "ASC 230-10-45-15(b)", - "ASC 230-10-45-17(b)", - "PwC Viewpoint, Financial statement presentation 6.9.3.1 (cash a third party receives or pays on the entity's behalf is included as though through the entity's accounts)", - "Deloitte Roadmap, Statement of cash flows 7.2 (constructive receipt and disbursement; Example 7-2)", - "BDO Blueprint, Statement of cash flows under ASC 230 (December 2025) 3.4 and Example 3-5", - "EY Financial reporting developments, Statement of cash flows (July 2026) 4.6.2 and 5.3.1", - "wanderbooks D-42 (owner, 2026-10-20: a member or lender paying for the company is cash borrowed and spent)", -] -tags = ["accrual", "book", "us", "cash-flow", "constructive", "partnership"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] -financing_accounts = ["2100", "2500"] - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated depreciation" -type = "asset" - -[[setup.accounts]] -code = "2100" -name = "Due to members" -type = "liability" - -[[setup.accounts]] -code = "2500" -name = "Bank loan" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital — Member A" -type = "equity" - -[[setup.accounts]] -code = "6000" -name = "Supplies" -type = "expense" - -[[setup.accounts]] -code = "6100" -name = "Depreciation" -type = "expense" - -[[setup.fixed_assets]] -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6100" - -[[setup.members]] -name = "Member A" -capital_account = "3000" - -[[events]] -date = "2026-03-02" -description = "Member A contributes cash" -lines = [ - { account = "1000", side = "debit", amount = "1000.00" }, - { account = "3000", side = "credit", amount = "1000.00" }, -] - -[[events]] -date = "2026-03-05" -description = "Supplies, paid by Member A personally (owed back)" -lines = [ - { account = "6000", side = "debit", amount = "120.00" }, - { account = "2100", side = "credit", amount = "120.00" }, -] - -[[events]] -date = "2026-03-09" -description = "Equipment, paid to the seller by the bank (a loan)" -lines = [ - { account = "1500", side = "debit", amount = "2400.00" }, - { account = "2500", side = "credit", amount = "2400.00" }, -] - -[[events]] -date = "2026-03-12" -description = "Supplies, paid by Member A personally (contributed)" -lines = [ - { account = "6000", side = "debit", amount = "45.00" }, - { account = "3000", side = "credit", amount = "45.00" }, -] - -[[events]] -date = "2026-03-15" -description = "Supplies, paid by Member A personally (posted in error)" -lines = [ - { account = "6000", side = "debit", amount = "30.00" }, - { account = "2100", side = "credit", amount = "30.00" }, -] - -[[events]] -date = "2026-03-16" -description = "Reverse entry 5" -reverses = 5 - -[[events]] -date = "2026-03-20" -description = "Reimburse Member A" -lines = [ - { account = "2100", side = "debit", amount = "120.00" }, - { account = "1000", side = "credit", amount = "120.00" }, -] - -[[events]] -date = "2026-03-28" -description = "Loan payment" -lines = [ - { account = "2500", side = "debit", amount = "200.00" }, - { account = "1000", side = "credit", amount = "200.00" }, -] - -# By hand, March. Operating: entry 2, -120.00, and entry 4, -45.00 (cash -# paid to suppliers, 45-17(b), by the member for the company): -165.00; -# entries 5 and 6 net out. Investing: entry 3, -2,400.00 (equipment, -# 45-13(c), paid by the bank). Financing: entry 1 +1,000.00 and entry 4 -# +45.00 (contributions, 45-14(a)), entry 2 +120.00 and entry 3 +2,400.00 -# (borrowing, 45-14(b)), entry 7 -120.00 and entry 8 -200.00 (repayments, -# 45-15(b)): +3,245.00. Sum -165.00 - 2,400.00 + 3,245.00 = +680.00, the -# change in cash (1,000.00 - 120.00 - 200.00). Net income -165.00 equals -# operating: no reconciling item. Nothing is disclosed: no part of any -# entry went unsettled. -[[expect]] -query = "cash_flow" -from = "2026-03-01" -to = "2026-03-31" - -[expect.values] -operating = "-165.00" -investing = "-2400.00" -financing = "3245.00" -net_change = "680.00" -cash_beginning = "0.00" -cash_ending = "680.00" -difference = "0.00" -net_income = "-165.00" -"operating.2" = "-120.00" -"operating.2.on_behalf" = "-120.00" -"operating.4" = "-45.00" -"operating.count" = "2" -"investing.3" = "-2400.00" -"investing.3.on_behalf" = "-2400.00" -"investing.count" = "1" -"financing.1" = "1000.00" -"financing.1.on_behalf" = "0.00" -"financing.2" = "120.00" -"financing.2.on_behalf" = "120.00" -"financing.3" = "2400.00" -"financing.3.on_behalf" = "2400.00" -"financing.4" = "45.00" -"financing.4.on_behalf" = "45.00" -"financing.7" = "-120.00" -"financing.7.on_behalf" = "0.00" -"financing.8" = "-200.00" -"financing.count" = "6" -"noncash.count" = "0" diff --git a/bookspec/examples/cash-flow.part-cash-purchase.toml b/bookspec/examples/cash-flow.part-cash-purchase.toml deleted file mode 100644 index 3fc8ea4..0000000 --- a/bookspec/examples/cash-flow.part-cash-purchase.toml +++ /dev/null @@ -1,90 +0,0 @@ -id = "cash-flow.part-cash-purchase" -title = "Equipment bought part for cash, part on the seller's note" -rule = "Of a transaction that is part cash and part noncash, only the cash portion MUST be reported in the statement of cash flows; the rest MUST be disclosed as a noncash investing and financing activity." -status = "AUTHORITY" -sources = ["ASC 230-10-50-5", "ASC 230-10-50-3", "ASC 230-10-50-4", "ASC 230-10-45-13(c)", "EY Financial reporting developments, Statement of cash flows (July 2026) 5.3.1 (seller-financed purchases are noncash to the extent of the seller's financing)"] -tags = ["accrual", "book", "us", "cash-flow", "noncash"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] -# The note is the seller's own credit, not a lender's: it is not listed as -# financing (a lender that paid the seller would be: the purchase would -# then be borrowed and invested, cash-flow.paid-on-the-companys-behalf). - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "1520" -name = "Vehicles" -type = "asset" - -[[setup.accounts]] -code = "1530" -name = "Accumulated depreciation, vehicles" -type = "asset" - -[[setup.accounts]] -code = "2500" -name = "Note payable to the seller" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6100" -name = "Depreciation" -type = "expense" - -[[setup.fixed_assets]] -cost_account = "1520" -accumulated_account = "1530" -expense_account = "6100" - -[[events]] -date = "2026-01-02" -description = "Opening contribution" -lines = [ - { account = "1000", side = "debit", amount = "10000.00" }, - { account = "3000", side = "credit", amount = "10000.00" }, -] - -[[events]] -date = "2026-02-08" -description = "Vehicle: 300 down, 700 on the seller's note" -lines = [ - { account = "1520", side = "debit", amount = "1000.00" }, - { account = "1000", side = "credit", amount = "300.00" }, - { account = "2500", side = "credit", amount = "700.00" }, -] - -# By hand: 300 left the bank for the vehicle (investing, 45-13(c)); the -# 700 the seller financed moved no cash, to anyone, and is disclosed (50-4: -# acquiring an asset by assuming a directly related liability). Cash -# 10,000.00 to 9,700.00 in February. -[[expect]] -query = "cash_flow" -from = "2026-02-01" -to = "2026-02-28" - -[expect.values] -operating = "0.00" -investing = "-300.00" -financing = "0.00" -net_change = "-300.00" -cash_beginning = "10000.00" -cash_ending = "9700.00" -difference = "0.00" -"investing.2" = "-300.00" -"investing.count" = "1" -"noncash.2" = "700.00" -"noncash.count" = "1" diff --git a/bookspec/examples/cash-flow.reversal-of-a-reversal.toml b/bookspec/examples/cash-flow.reversal-of-a-reversal.toml deleted file mode 100644 index abbf567..0000000 --- a/bookspec/examples/cash-flow.reversal-of-a-reversal.toml +++ /dev/null @@ -1,90 +0,0 @@ -id = "cash-flow.reversal-of-a-reversal" -title = "A contribution, its reversal and the reversal's reversal" -rule = "Reversals MUST pair along the chain from its root: an entry and its reversal both in the period drop out together, and a reversal of a reversal whose partner is already paired MUST be reported, as its root would be." -status = "AUTHORITY" -sources = [ - "ASC 230-10-45-14(a)", - "ASC 230-10-45-24", - "Pacioli, Summa (1494), ch. 31 (the correcting entry)", -] -tags = ["accrual", "book", "us", "cash-flow", "reversal"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3001" -name = "Capital — Member B" -type = "equity" - -[[setup.members]] -name = "Member B" -capital_account = "3001" - -[[events]] -date = "2026-01-20" -description = "Opening contribution" -lines = [ - { account = "1000", side = "debit", amount = "1000.00" }, - { account = "3001", side = "credit", amount = "1000.00" }, -] - -[[events]] -date = "2026-02-02" -description = "Contribution — Member B" -lines = [ - { account = "1000", side = "debit", amount = "300.00" }, - { account = "3001", side = "credit", amount = "300.00" }, -] - -[[events]] -date = "2026-02-03" -description = "Reverse entry 2" -reverses = 2 - -[[events]] -date = "2026-02-04" -description = "Reverse entry 3: the contribution stands" -reverses = 3 - -# By hand, February: entries 2 and 3 pair (the root and its reversal); -# entry 4 reinstates the contribution and is reported: financing +300.00, -# the change in cash. -[[expect]] -query = "cash_flow" -from = "2026-02-01" -to = "2026-02-28" - -[expect.values] -financing = "300.00" -net_change = "300.00" -cash_beginning = "1000.00" -cash_ending = "1300.00" -difference = "0.00" -"financing.4" = "300.00" -"financing.count" = "1" -"noncash.count" = "0" - -# From February 3 the root is before the period: entries 3 and 4 pair, and -# nothing is reported (no cash moved net, 1,300.00 to 1,300.00). -[[expect]] -query = "cash_flow" -from = "2026-02-03" -to = "2026-02-28" - -[expect.values] -financing = "0.00" -net_change = "0.00" -cash_beginning = "1300.00" -cash_ending = "1300.00" -difference = "0.00" -"financing.count" = "0" diff --git a/bookspec/examples/cash-flow.reversed-property-contribution.toml b/bookspec/examples/cash-flow.reversed-property-contribution.toml deleted file mode 100644 index 97ccd5a..0000000 --- a/bookspec/examples/cash-flow.reversed-property-contribution.toml +++ /dev/null @@ -1,84 +0,0 @@ -id = "cash-flow.reversed-property-contribution" -title = "A property contribution reversed the same day leaves the statement of cash flows empty" -rule = "An entry and its reversal both in the period MUST contribute nothing to any section or disclosure, and the sections MUST sum to the change in cash." -status = "AUTHORITY" -sources = [ - "ASC 230-10-45-1", - "ASC 230-10-45-24", - "ASC 230-10-50-3", - "Pacioli, Summa (1494), ch. 31 (the correcting entry)", -] -tags = ["accrual", "book", "us", "cash-flow", "reversal", "regression:p3-defect-1"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated depreciation" -type = "asset" - -[[setup.accounts]] -code = "6100" -name = "Depreciation" -type = "expense" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.fixed_assets]] -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6100" - -[[events]] -date = "2026-01-10" -description = "Property contributed" -lines = [ - { account = "1500", side = "debit", amount = "2340.00" }, - { account = "3000", side = "credit", amount = "2340.00" }, -] - -[[events]] -date = "2026-01-10" -description = "Reverse entry 1" -reverses = 1 - -# By hand: no cash moved (sections 0.00, change in cash 0.00), and the -# entry and its reversal cancel, so nothing is disclosed either. Before the -# fix (wanderbooks P3, defect 1) investing showed -2,340.00 against a -# change of 0.00: the reversal's credit to equipment fell in no section. -[[expect]] -query = "cash_flow" -from = "2026-01-01" -to = "2026-12-31" - -[expect.values] -operating = "0.00" -investing = "0.00" -financing = "0.00" -net_change = "0.00" -cash_beginning = "0.00" -cash_ending = "0.00" -difference = "0.00" -net_income = "0.00" -"operating.count" = "0" -"investing.count" = "0" -"financing.count" = "0" -"noncash.count" = "0" diff --git a/bookspec/examples/cash-flow.two-member-llc-first-year.toml b/bookspec/examples/cash-flow.two-member-llc-first-year.toml deleted file mode 100644 index 47a877c..0000000 --- a/bookspec/examples/cash-flow.two-member-llc-first-year.toml +++ /dev/null @@ -1,443 +0,0 @@ -id = "cash-flow.two-member-llc-first-year" -title = "A two-member LLC's first weeks: contributions, member-paid expenses, property contributed, corrections" -rule = "The statement of cash flows MUST report cash receipts and payments, by section, with each reversal netted against its original in the period; expenses members paid personally on credit to them MUST be shown as cash borrowed from the members and spent, on each payment's date; property the members contributed MUST be disclosed as a noncash activity; the sections MUST sum to the change in cash." -# CHOICE: the member-paid expenses. ASC 230 doesn't address a payment made -# on the entity's behalf; the firm guides present it by substance as a -# constructive receipt and disbursement (a financing inflow and an -# operating outflow), and the owner chose that (D-42, revising D-35's -# noncash presentation). The rest is the Codification's. -status = "CHOICE" -sources = [ - "ASC 230-10-10-1", - "ASC 230-10-45-14(a)", - "ASC 230-10-45-14(b)", - "ASC 230-10-45-15(b)", - "ASC 230-10-45-17(b)", - "ASC 230-10-45-24", - "ASC 230-10-45-28", - "ASC 230-10-45-30", - "ASC 230-10-50-3", - "ASC 230-10-50-4", - "PwC Viewpoint, Financial statement presentation 6.9.3.1 (cash a third party receives or pays on the entity's behalf is included as though through the entity's accounts)", - "Deloitte Roadmap, Statement of cash flows 7.2 (constructive receipt and disbursement)", - "BDO Blueprint, Statement of cash flows under ASC 230 (December 2025) 3.4 (a parent paying a subsidiary's expenses)", - "wanderbooks D-42 (owner, 2026-10-20: a member or lender paying for the company is cash borrowed and spent)", -] -tags = ["accrual", "book", "us", "cash-flow", "partnership", "constructive", "regression:p3-defect-1"] - -# A two-member LLC's first weeks, in the shape of the books P3 defect 1 -# was found in: the same 23 entries in the same order, on the same -# accounts and roles (two contributions in cash, expenses the members paid -# personally through Due to Members and two reimbursements, a domain -# expensed then corrected to a prepaid, two card charges, four devices the -# members owned contributed, two of them first entered wrongly and -# reversed), with every amount and date this example's own. Before the fix -# the statement didn't foot: the reversals' credits to equipment (#13, -# #18) belonged to no section, and the contributed devices read as -# purchases. - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "1300" -name = "Prepaid Expenses" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "2050" -name = "Business Card" -type = "liability" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital — Member A" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital — Member B" -type = "equity" - -[[setup.accounts]] -code = "6000" -name = "Organizational Costs" -type = "expense" - -[[setup.accounts]] -code = "6050" -name = "Domain & Hosting" -type = "expense" - -[[setup.accounts]] -code = "6200" -name = "Developer Fees" -type = "expense" - -[[setup.accounts]] -code = "6300" -name = "Software & Subscriptions" -type = "expense" - -[[setup.accounts]] -code = "6400" -name = "Memberships & Licenses" -type = "expense" - -[[setup.accounts]] -code = "6500" -name = "Depreciation Expense" -type = "expense" - -[[setup.fixed_assets]] -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6500" - -[[setup.members]] -name = "Member A" -capital_account = "3000" - -[[setup.members]] -name = "Member B" -capital_account = "3001" - -# Entry 1. -[[events]] -date = "2026-03-02" -description = "Formation filing fee, paid by the members" -lines = [ - { account = "6000", side = "debit", amount = "85.00" }, - { account = "2100", side = "credit", amount = "85.00" }, -] - -# Entry 2. -[[events]] -date = "2026-03-03" -description = "Domain, 1 year, paid by the members" -lines = [ - { account = "6050", side = "debit", amount = "12.97" }, - { account = "2100", side = "credit", amount = "12.97" }, -] - -# Entry 3. -[[events]] -date = "2026-03-05" -description = "Developer registration, paid by the members" -lines = [ - { account = "6200", side = "debit", amount = "99.00" }, - { account = "2100", side = "credit", amount = "99.00" }, -] - -# Entry 4. -[[events]] -date = "2026-03-09" -description = "Contribution — Member A" -lines = [ - { account = "1000", side = "debit", amount = "1200.00" }, - { account = "3000", side = "credit", amount = "1200.00" }, -] - -# Entry 5. -[[events]] -date = "2026-03-10" -description = "Contribution — Member B" -lines = [ - { account = "1000", side = "debit", amount = "800.00" }, - { account = "3001", side = "credit", amount = "800.00" }, -] - -# Entry 6. -[[events]] -date = "2026-03-11" -description = "Subscription top-up, paid by the members" -lines = [ - { account = "6300", side = "debit", amount = "7.49" }, - { account = "2100", side = "credit", amount = "7.49" }, -] - -# Entry 7. -[[events]] -date = "2026-03-16" -description = "Developer membership, paid by the members" -lines = [ - { account = "6400", side = "debit", amount = "129.00" }, - { account = "2100", side = "credit", amount = "129.00" }, -] - -# Entry 8. -[[events]] -date = "2026-03-23" -description = "Reimburse members" -lines = [ - { account = "2100", side = "debit", amount = "333.46" }, - { account = "1000", side = "credit", amount = "333.46" }, -] - -# Entry 9. -[[events]] -date = "2026-03-20" -description = "Domain, 2 years, paid by the members" -lines = [ - { account = "6050", side = "debit", amount = "46.38" }, - { account = "2100", side = "credit", amount = "46.38" }, -] - -# Entry 10. -[[events]] -date = "2026-03-25" -description = "API credits on the card" -lines = [ - { account = "6300", side = "debit", amount = "14.20" }, - { account = "2050", side = "credit", amount = "14.20" }, -] - -# Entry 11. -[[events]] -date = "2026-03-26" -description = "Reimburse members" -lines = [ - { account = "2100", side = "debit", amount = "46.38" }, - { account = "1000", side = "credit", amount = "46.38" }, -] - -# Entry 12. -[[events]] -date = "2026-03-27" -description = "Laptop contributed by Member A" -lines = [ - { account = "1500", side = "debit", amount = "1480.00" }, - { account = "3000", side = "credit", amount = "1480.00" }, -] - -# Entry 13. -[[events]] -date = "2026-03-27" -description = "Reverse entry 12: contributed jointly" -reverses = 12 - -# Entry 14. -[[events]] -date = "2026-03-27" -description = "Laptop contributed jointly" -lines = [ - { account = "1500", side = "debit", amount = "1480.00" }, - { account = "3000", side = "credit", amount = "740.00" }, - { account = "3001", side = "credit", amount = "740.00" }, -] - -# Entry 15. -[[events]] -date = "2026-03-27" -description = "Laptop contributed jointly" -lines = [ - { account = "1500", side = "debit", amount = "1190.00" }, - { account = "3000", side = "credit", amount = "595.00" }, - { account = "3001", side = "credit", amount = "595.00" }, -] - -# Entry 16. -[[events]] -date = "2026-03-27" -description = "Desktop contributed jointly" -lines = [ - { account = "1500", side = "debit", amount = "2210.00" }, - { account = "3000", side = "credit", amount = "1105.00" }, - { account = "3001", side = "credit", amount = "1105.00" }, -] - -# Entry 17. -[[events]] -date = "2026-03-27" -description = "Tablet contributed jointly" -lines = [ - { account = "1500", side = "debit", amount = "630.00" }, - { account = "3000", side = "credit", amount = "315.00" }, - { account = "3001", side = "credit", amount = "315.00" }, -] - -# Entry 18. -[[events]] -date = "2026-03-27" -description = "Reverse entry 15: the service plan is separate" -reverses = 15 - -# Entry 19. -[[events]] -date = "2026-03-27" -description = "Laptop and its service plan contributed jointly" -lines = [ - { account = "1500", side = "debit", amount = "1151.00" }, - { account = "1300", side = "debit", amount = "39.00" }, - { account = "3000", side = "credit", amount = "595.00" }, - { account = "3001", side = "credit", amount = "595.00" }, -] - -# Entry 20. -[[events]] -date = "2026-03-20" -description = "Domain, 2 years, paid by the members, prepaid" -lines = [ - { account = "1300", side = "debit", amount = "46.38" }, - { account = "2100", side = "credit", amount = "46.38" }, -] - -# Entry 21. -[[events]] -date = "2026-03-12" -description = "Subscription, paid by the members" -lines = [ - { account = "6300", side = "debit", amount = "216.40" }, - { account = "2100", side = "credit", amount = "216.40" }, -] - -# Entry 22. -[[events]] -date = "2026-03-20" -description = "Reverse entry 9: prepaid, not expensed" -reverses = 9 - -# Entry 23. -[[events]] -date = "2026-03-30" -description = "API credits on the card" -lines = [ - { account = "6300", side = "debit", amount = "42.75" }, - { account = "2050", side = "credit", amount = "42.75" }, -] -# By hand, the year to 2026-03-31 (rules.md B-14; D-42 for what the -# members paid). Cash is 1000; 2100 (Due to Members) is financing. -# - Operating: the members paid #1 85.00, #2 12.97, #3 99.00, #6 7.49, -# #21 216.40, #7 129.00 and #20 46.38 (the prepaid domain) for the -# company through 2100, each borrowed (financing, 45-14(b)) and spent -# (operating, 45-17(b)) on its date: 85.00 + 12.97 + 99.00 + 7.49 + -# 216.40 + 129.00 + 46.38 = 596.24, so operating -596.24, all cash paid -# to suppliers. #9 and its reversal #22 net out (rule 6). The card's #10 -# and #23 moved no cash and 2050 is not financing: no line. -# - Investing 0.00: every device was property the members owned (50-4), -# disclosed below; #12/#13 and #15/#18 net out. -# - Financing: #4 +1,200.00 and #5 +800.00 (contributions, 45-14(a)); -# +596.24 borrowed from the members (the seven above); the -# reimbursements #8 -333.46 (#1, #2, #3, #6 and #7: 85.00 + 12.97 + -# 99.00 + 7.49 + 129.00) and #11 -46.38 (#20), 45-15(b): 1,200.00 + -# 800.00 + 596.24 - 333.46 - 46.38 = +2,216.40. -# - Sum: -596.24 + 0.00 + 2,216.40 = +1,620.16, the change in cash (in -# 1,200.00 + 800.00, out 333.46 + 46.38; 0.00 to 1,620.16); difference -# 0.00. -# - Reconciliation (45-28 to 45-30): net income is minus the expenses, -# 85.00 + 12.97 + 99.00 + 7.49 + 129.00 + 46.38 - 46.38 + 14.20 + 216.40 -# + 42.75 = 606.81, so -606.81; other noncash +39.00 (#19's service -# plan, contributed with the laptop); increase in 1300, 39.00 + 46.38, -# so -85.38; increase in 2050, 14.20 + 42.75, so +56.95. Total -606.81 + -# 39.00 - 85.38 + 56.95 = -596.24, the operating section. -# - Disclosed (50-3, 50-4), the property contributed: #14 1,480.00, #16 -# 2,210.00, #17 630.00, #19 1,190.00 (1,151.00 equipment and 39.00 a -# prepaid service plan), 5,510.00 in all; #12/#13 and #15/#18 drop out. -[[expect]] -query = "cash_flow" -from = "2026-01-01" -to = "2026-03-31" - -[expect.values] -operating = "-596.24" -investing = "0.00" -financing = "2216.40" -net_change = "1620.16" -cash_beginning = "0.00" -cash_ending = "1620.16" -difference = "0.00" -net_income = "-606.81" -"operating.1" = "-85.00" -"operating.2" = "-12.97" -"operating.3" = "-99.00" -"operating.6" = "-7.49" -"operating.7" = "-129.00" -"operating.20" = "-46.38" -"operating.21" = "-216.40" -"operating.count" = "7" -"investing.count" = "0" -"financing.1" = "85.00" -"financing.1.on_behalf" = "85.00" -"financing.2" = "12.97" -"financing.3" = "99.00" -"financing.4" = "1200.00" -"financing.4.on_behalf" = "0.00" -"financing.5" = "800.00" -"financing.6" = "7.49" -"financing.7" = "129.00" -"financing.8" = "-333.46" -"financing.11" = "-46.38" -"financing.20" = "46.38" -"financing.20.on_behalf" = "46.38" -"financing.21" = "216.40" -"financing.count" = "11" -"reconciliation.other_noncash" = "39.00" -"reconciliation.balance_change.1300" = "-85.38" -"reconciliation.balance_change.2050" = "56.95" -"noncash.14" = "1480.00" -"noncash.16" = "2210.00" -"noncash.17" = "630.00" -"noncash.19" = "1190.00" -"noncash.count" = "4" - -# The same books to 2026-03-18: #1 to #7 and #21 are dated by then (#9, -# #20 and #22 on 03-20, the rest later). The members paid #1, #2, #3, #6, -# #7 and #21 for the company, 85.00 + 12.97 + 99.00 + 7.49 + 129.00 + -# 216.40 = 549.86: operating -549.86; financing #4 +1,200.00, #5 +800.00 -# and the 549.86 borrowed, +2,549.86; the change in cash 2,000.00 -# (-549.86 + 2,549.86); net income -549.86 with no reconciling item (no -# operating balance has moved yet); nothing disclosed. -[[expect]] -query = "cash_flow" -from = "2026-01-01" -to = "2026-03-18" - -[expect.values] -operating = "-549.86" -investing = "0.00" -financing = "2549.86" -net_change = "2000.00" -difference = "0.00" -net_income = "-549.86" -"operating.1" = "-85.00" -"operating.2" = "-12.97" -"operating.3" = "-99.00" -"operating.6" = "-7.49" -"operating.7" = "-129.00" -"operating.21" = "-216.40" -"operating.count" = "6" -"financing.1" = "85.00" -"financing.2" = "12.97" -"financing.3" = "99.00" -"financing.4" = "1200.00" -"financing.5" = "800.00" -"financing.6" = "7.49" -"financing.7" = "129.00" -"financing.21" = "216.40" -"financing.count" = "8" -"noncash.count" = "0" diff --git a/bookspec/examples/default-period-fiscal-year-to-date.toml b/bookspec/examples/default-period-fiscal-year-to-date.toml deleted file mode 100644 index a681846..0000000 --- a/bookspec/examples/default-period-fiscal-year-to-date.toml +++ /dev/null @@ -1,163 +0,0 @@ -# A report asked for without dates covers this fiscal year so far. -# -# wanderbooks: P3 defect 16 (the report tools, books.show and the viewer -# each read omitted dates their own way); rules.md B-30; regression tests -# `prop_period_defaults_resolve` (ledger-core tests/properties.rs), -# `one_default_period_everywhere_defect_16` (ledger-cli -# tests/mcp_journeys.rs), `show_sends_the_dates_it_names_defect_16` and -# `show_refuses_start_after_end_defect_15` (ledger-cli tests/oracle_mcp.rs). -# -# Expected values (bookspec/README.md): `period` gives the dates a period -# report asked with these dates covers, `day` the day a report as of a day -# is made as of, `calendar_year` the year a calendar-year count counts, -# each by the one rule on `today`; a query that omits a date resolves it -# by the same rule. The amounts fall one to a fiscal year (July to June), -# so each period's figures show which period it was. - -id = "default-period-fiscal-year-to-date" -title = "A report asked for without dates covers this fiscal year so far" -rule = "A period report whose end is omitted MUST end today, in the company's time zone, and one whose start is omitted MUST start on the first day of the fiscal year that holds its end; a start after the end, given or derived, MUST be refused, never swapped. A report as of a day whose day is omitted MUST be as of today, and a count by calendar year whose year is omitted MUST count this calendar year." -status = "CHOICE" -sources = [ - "IRS Publication 538 (Rev. January 2022), Tax year: \"A tax year is an annual accounting period for keeping records and reporting income and expenses.\" (the period: AUTHORITY; docs/spec/sources/irs-periods-and-methods.md)", - "26 U.S.C. §6041(a), \"in any calendar year\"; §6041A(a)(2), \"during such calendar year\" (the calendar-year counts: AUTHORITY; docs/spec/sources/irs-1099-thresholds.md)", - "DECISION, the owner, 2026-10-20 (wanderbooks plan/served.md P3, \"Settled in design\"): the fiscal year to date as the default, everywhere; no authority says what omitted dates mean (wanderbooks docs/spec/rules.md B-30)", -] -tags = ["periods", "reports", "defaults", "fiscal-year"] - -[setup] -currency = "USD" -fiscal_year_start = "07-01" -basis = "accrual" -rounding = "half-even" -today = "2026-10-20" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" - -[[setup.accounts]] -code = "6000" -name = "Software" -type = "expense" - -# Entry 1: in the fiscal year July 2024 to June 2025. -[[events]] -date = "2025-03-15" -description = "Capital contributed" -lines = [ - { account = "1000", side = "debit", amount = "500.00" }, - { account = "3000", side = "credit", amount = "500.00" }, -] - -# Entries 2 and 3: in the fiscal year July 2025 to June 2026. -[[events]] -date = "2025-09-10" -description = "Sale" -lines = [ - { account = "1000", side = "debit", amount = "120.00" }, - { account = "4000", side = "credit", amount = "120.00" }, -] - -[[events]] -date = "2026-02-01" -description = "Software" -lines = [ - { account = "6000", side = "debit", amount = "20.00" }, - { account = "1000", side = "credit", amount = "20.00" }, -] - -# Entry 4: in the fiscal year that holds today, July 2026 to June 2027. -[[events]] -date = "2026-08-03" -description = "Sale" -lines = [ - { account = "1000", side = "debit", amount = "7.00" }, - { account = "4000", side = "credit", amount = "7.00" }, -] - -# Both dates omitted: the fiscal year that holds today, through today. -[[expect]] -query = "period" -values = { from = "2026-07-01", to = "2026-10-20" } - -[[expect]] -query = "profit_and_loss" -values = { "4000" = "7.00", "6000" = "0.00", net_income = "7.00" } - -# Only an end: the fiscal year that holds the end, through it (never the -# first entry's day, never the fiscal year of today). -[[expect]] -query = "period" -to = "2026-03-31" -values = { from = "2025-07-01", to = "2026-03-31" } - -[[expect]] -query = "profit_and_loss" -to = "2026-03-31" -values = { "4000" = "120.00", "6000" = "20.00", net_income = "100.00" } - -# Only a start: through today. -[[expect]] -query = "period" -from = "2025-01-01" -values = { from = "2025-01-01", to = "2026-10-20" } - -[[expect]] -query = "profit_and_loss" -from = "2025-01-01" -values = { "4000" = "127.00", "6000" = "20.00", net_income = "107.00" } - -# A one-day period is a period. -[[expect]] -query = "period" -from = "2025-09-10" -to = "2025-09-10" -values = { from = "2025-09-10", to = "2025-09-10" } - -# A start after the end is refused, never swapped. -[[expect]] -query = "period" -from = "2026-12-31" -to = "2026-01-01" -refused = "end_before_start" - -# Only a start, after today: the end an omitted end means is today, so the -# period would end before it starts. Refused, never swapped. -[[expect]] -query = "period" -from = "2027-01-01" -refused = "start_after_today" - -# A report as of a day: today. -[[expect]] -query = "day" -values = { as_of = "2026-10-20" } - -[[expect]] -query = "trial_balance" -values = { "1000" = "607.00", "3000" = "-500.00", "4000" = "-127.00", "6000" = "20.00" } - -# A count by calendar year: this calendar year, not the fiscal one. -[[expect]] -query = "calendar_year" -values = { year = "2026" } - -# The calendar's first fiscal year: a July year holding a day early in -# year 1 began in year 0, outside the calendar; it starts on the -# calendar's first day. -[[expect]] -query = "period" -to = "0001-03-01" -values = { from = "0001-01-01", to = "0001-03-01" } diff --git a/bookspec/examples/depreciation-catch-up-month.toml b/bookspec/examples/depreciation-catch-up-month.toml deleted file mode 100644 index e61ffc8..0000000 --- a/bookspec/examples/depreciation-catch-up-month.toml +++ /dev/null @@ -1,112 +0,0 @@ -id = "depreciation-catch-up-month" -title = "A month of depreciation skipped is caught up in the month the run is made" -rule = "A depreciation run for a month posts everything the schedule says is due through that month's end that the books don't carry, so a skipped month is caught up in it, and a later run for the skipped month posts nothing." -status = "CHOICE" -sources = ["ASC 360-10-35-4", "DECISION: plan/served.md P3, Settled in design (2026-10-20)"] -tags = ["depreciation", "catch-up", "skipped-month", "p3"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.accounts]] -code = "7100" -name = "Gain or Loss on Disposal" -type = "revenue" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a -# month; December the final month). -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -# February is skipped; March is run. -[[events]] -run_depreciation = { asset = "X", period = "2026-03" } - -# February run afterwards: nothing is due (March caught it up). -[[events]] -run_depreciation = { asset = "X", period = "2026-02" } - -# Hand check (worked without the code): the schedule through Mar 31 is -# 3 x 1,000.00 = 3,000.00; January's run carried 1,000.00, so March's run -# posts 2,000.00 (its own 1,000.00 and February's, caught up). February's -# run then posts 0.00: the books already carry the schedule through -# February's end and beyond. -[[expect]] -query = "run" -event = 4 - -[expect.values] -amount = "2000.00" -caught_up = "1000.00" - -[[expect]] -query = "run" -event = 5 - -[expect.values] -amount = "0.00" -caught_up = "0.00" - -[[expect]] -query = "balances" -as_of = "2026-03-31" - -[expect.values] -"1510" = "-3000.00" -"6200" = "3000.00" diff --git a/bookspec/examples/depreciation-disposal-after-a-late-change-undone.toml b/bookspec/examples/depreciation-disposal-after-a-late-change-undone.toml deleted file mode 100644 index 2931fa5..0000000 --- a/bookspec/examples/depreciation-disposal-after-a-late-change-undone.toml +++ /dev/null @@ -1,158 +0,0 @@ -id = "depreciation-disposal-after-a-late-change-undone" -title = "A disposal is taken once a late change to an earlier run is undone, on any later day" -rule = "A disposal clears what the asset's runs carry now, so it waits until each run carries at the end of the disposal's month what it carries now. A reversal of an earlier run dated after that month is undone by reversing it, on any open day: the disposal month and the end are then right, and a later month end keeps the change and its correction as they were made." -status = "CONVENTION" -sources = ["ASC 360-10-35-4", "Pacioli 1494, ch. 31"] -tags = ["depreciation", "disposal", "refusal", "reversal", "p3-defect-22"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.accounts]] -code = "7100" -name = "Gain or Loss on Disposal" -type = "revenue" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a -# month; December the final month). -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-02" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-03" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-04" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-05" } - -# May's run reversed on Aug 1 (doubted). Its month, and July's, still -# carried it at their ends. -[[events]] -date = "2026-08-01" -description = "Reverse event 7" -reverses = 7 - -# Disposed of 2026-06-15 for 7,000.00: refused. The disposal clears what -# the runs carry now (January to April, 4,000.00) and would charge May -# again at Jun 15, so June would end carrying 6,500.00 against a -# schedule of 5,500.00. The refusal names May's run and the reversal. -[[events]] -date = "2026-06-15" -dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } -refused = "depreciation_past_disposal" - -# May was right: its reversal reversed on Sep 2 (August may be closed by -# then; any open day undoes it). -[[events]] -date = "2026-09-02" -description = "Reverse event 8" -reverses = 8 - -[[events]] -date = "2026-06-15" -dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } - -# Hand check (worked without the code): the runs carry 5,000.00 at Jun 30 -# and now. Held through Jun 15: 5,000.00 + 1,000.00 x 15/30 = 5,500.00, so -# the final depreciation is 500.00 and the disposal clears 5,500.00. Book -# value 6,500.00; gain 7,000.00 - 6,500.00 = 500.00. August's end carries -# May's reversal (accumulated depreciation a 1,000.00 debit, the expense -# 4,500.00) and September's its correction, as they would without the -# disposal. -[[expect]] -query = "disposal" -asset = "X" - -[expect.values] -final_depreciation = "500.00" -catch_up_depreciation = "0.00" -excess_depreciation = "0.00" -accumulated = "5500.00" -book_value = "6500.00" -gain_loss = "500.00" - -[[expect]] -query = "balances" -as_of = "2026-06-30" - -[expect.values] -"1510" = "0.00" -"6200" = "5500.00" -"7100" = "500.00" - -[[expect]] -query = "balances" -as_of = "2026-08-31" - -[expect.values] -"1510" = "1000.00" -"6200" = "4500.00" - -[[expect]] -query = "balances" -as_of = "2026-09-30" - -[expect.values] -"1510" = "0.00" -"6200" = "5500.00" diff --git a/bookspec/examples/depreciation-disposal-before-a-later-run.toml b/bookspec/examples/depreciation-disposal-before-a-later-run.toml deleted file mode 100644 index f309041..0000000 --- a/bookspec/examples/depreciation-disposal-before-a-later-run.toml +++ /dev/null @@ -1,146 +0,0 @@ -id = "depreciation-disposal-before-a-later-run" -title = "A disposal dated before a later month's run is refused until that run is reversed" -rule = "A disposal is refused while a run of a month after the disposal's stands: crediting it back at the disposal day would leave the disposal month's accumulated depreciation a debit and its expense short at the month's end. Once that run is reversed, the disposal takes the depreciation of the days held." -status = "CONVENTION" -sources = ["ASC 360-10-35-4"] -tags = ["depreciation", "disposal", "refusal", "later-run", "p3-defect-22"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.accounts]] -code = "7100" -name = "Gain or Loss on Disposal" -type = "revenue" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a -# month; December the final month). -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-02" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-03" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-04" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-05" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-06" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-07" } - -# Disposed of 2026-06-15 for 7,000.00, after July's run: refused, naming -# July's run. -[[events]] -date = "2026-06-15" -dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } -refused = "depreciation_past_disposal" - -# July's run reversed, dated in July. -[[events]] -date = "2026-07-31" -description = "Reverse event 9" -reverses = 9 - -[[events]] -date = "2026-06-15" -dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } - -# Hand check (worked without the code): January through June carry -# 6,000.00 once July's run is reversed. Held through Jun 15: 5,000.00 + -# 1,000.00 x 15/30 = 5,500.00, so June's run posted 500.00 past the -# disposal day. Book value 6,500.00; gain 7,000.00 - 6,500.00 = 500.00. -# Had the disposal taken July's run back at Jun 15 (1,500.00 credited), -# June would end with accumulated depreciation a 1,000.00 debit and -# 4,500.00 of expense. -[[expect]] -query = "disposal" -asset = "X" - -[expect.values] -final_depreciation = "0.00" -catch_up_depreciation = "0.00" -excess_depreciation = "500.00" -accumulated = "6000.00" -book_value = "6500.00" -gain_loss = "500.00" - -[[expect]] -query = "balances" -as_of = "2026-06-30" - -[expect.values] -"1510" = "0.00" -"6200" = "5500.00" -"7100" = "500.00" - -[[expect]] -query = "balances" -as_of = "2026-07-31" - -[expect.values] -"1510" = "0.00" -"6200" = "5500.00" diff --git a/bookspec/examples/depreciation-disposal-in-final-month.toml b/bookspec/examples/depreciation-disposal-in-final-month.toml deleted file mode 100644 index be7d3ee..0000000 --- a/bookspec/examples/depreciation-disposal-in-final-month.toml +++ /dev/null @@ -1,136 +0,0 @@ -id = "depreciation-disposal-in-final-month" -title = "A disposal in the life's final month depreciates that month by its days held" -rule = "A disposal dated inside an asset's final month takes depreciation for the days held in that month, not the month's whole charge." -status = "CONVENTION" -sources = ["ASC 360-10-35-4"] -tags = ["depreciation", "disposal", "final-month", "p3-defect-22"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.accounts]] -code = "7100" -name = "Gain or Loss on Disposal" -type = "revenue" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a -# month; December the final month). -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-02" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-03" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-04" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-05" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-06" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-07" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-08" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-09" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-10" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-11" } - -# Disposed of 2026-12-10 for 1,000.00. -[[events]] -date = "2026-12-10" -dispose_asset = { asset = "X", proceeds = "1000.00", proceeds_account = "1000", gain_loss_account = "7100" } - -# Hand check (worked without the code): January..November run, 11,000.00. -# December held through the 10th: 1,000.00 x 10/31 = 322.580... -> 322.58. -# Accumulated 11,322.58; book value 677.42; gain 1,000.00 - 677.42 = -# 322.58. (Before P3: 1,000.00 of depreciation and a 1,000.00 gain.) -[[expect]] -query = "disposal" -asset = "X" - -[expect.values] -final_depreciation = "322.58" -catch_up_depreciation = "0.00" -excess_depreciation = "0.00" -accumulated = "11322.58" -book_value = "677.42" -gain_loss = "322.58" - -[[expect]] -query = "balances" -as_of = "2026-12-31" - -[expect.values] -"1000" = "1000.00" -"1510" = "0.00" -"6200" = "11322.58" -"7100" = "322.58" diff --git a/bookspec/examples/depreciation-disposal-inside-a-posted-month.toml b/bookspec/examples/depreciation-disposal-inside-a-posted-month.toml deleted file mode 100644 index 5145d75..0000000 --- a/bookspec/examples/depreciation-disposal-inside-a-posted-month.toml +++ /dev/null @@ -1,122 +0,0 @@ -id = "depreciation-disposal-inside-a-posted-month" -title = "A disposal inside a month already run takes back the days after it" -rule = "When a month's depreciation was posted through its end and the asset is disposed of inside that month, the disposal credits back the depreciation for the days after the disposal day, so book value and gain are of the days held." -status = "CONVENTION" -sources = ["ASC 360-10-35-4"] -tags = ["depreciation", "disposal", "mid-month", "excess-depreciation", "p3-defect-22"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.accounts]] -code = "7100" -name = "Gain or Loss on Disposal" -type = "revenue" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a -# month; December the final month). -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-02" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-03" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-04" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-05" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-06" } - -# Disposed of 2026-06-15 for 7,000.00, after June's run (dated Jun 30). -[[events]] -date = "2026-06-15" -dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } - -# Hand check (worked without the code): the runs carried 6,000.00. Held -# through Jun 15: 5,000.00 + 1,000.00 x 15/30 = 5,500.00, so 500.00 was -# posted past the disposal day. The disposal entry: Dr accumulated -# depreciation 6,000.00, Dr bank 7,000.00 / Cr equipment 12,000.00, Cr -# depreciation expense 500.00, Cr gain 500.00 (13,000.00 a side). Book -# value 6,500.00; gain 7,000.00 - 6,500.00 = 500.00. -[[expect]] -query = "disposal" -asset = "X" - -[expect.values] -final_depreciation = "0.00" -catch_up_depreciation = "0.00" -excess_depreciation = "500.00" -accumulated = "6000.00" -book_value = "6500.00" -gain_loss = "500.00" - -[[expect]] -query = "balances" -as_of = "2026-06-30" - -[expect.values] -"1510" = "0.00" -"6200" = "5500.00" -"7100" = "500.00" diff --git a/bookspec/examples/depreciation-disposal-mid-month.toml b/bookspec/examples/depreciation-disposal-mid-month.toml deleted file mode 100644 index d581198..0000000 --- a/bookspec/examples/depreciation-disposal-mid-month.toml +++ /dev/null @@ -1,134 +0,0 @@ -id = "depreciation-disposal-mid-month" -title = "A disposed asset depreciates through its disposal day, and its schedule stops there" -rule = "An asset disposed of mid-month is depreciated through the disposal day, held, and its schedule, accumulated depreciation and book value afterwards are what the disposal took." -status = "CONVENTION" -sources = ["ASC 360-10-35-4"] -tags = ["depreciation", "disposal", "mid-month", "p3-defect-22"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.accounts]] -code = "7100" -name = "Gain or Loss on Disposal" -type = "revenue" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a -# month; December the final month). -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-02" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-03" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-04" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-05" } - -# Disposed of 2026-06-15 for 7,000.00. -[[events]] -date = "2026-06-15" -dispose_asset = { asset = "X", proceeds = "7000.00", proceeds_account = "1000", gain_loss_account = "7100" } - -# Hand check (worked without the code): January..May run, 5 x 1,000.00 = -# 5,000.00. June held through the 15th: 1,000.00 x 15/30 = 500.00, the -# final depreciation. Accumulated 5,500.00; book value 12,000.00 - -# 5,500.00 = 6,500.00; gain 7,000.00 - 6,500.00 = 500.00. -[[expect]] -query = "disposal" -asset = "X" - -[expect.values] -final_depreciation = "500.00" -catch_up_depreciation = "0.00" -excess_depreciation = "0.00" -accumulated = "5500.00" -book_value = "6500.00" -gain_loss = "500.00" - -# The disposed asset's schedule ends on the disposal day, whatever date it -# is asked through. -[[expect]] -query = "depreciation_schedule" -asset = "X" -as_of = "2030-12-31" - -[expect.values] -"2026-06" = "500.00" -accumulated = "5500.00" -book_value = "6500.00" - -# The books: depreciation expense 5,500.00 (debit), the gain 500.00 -# (credit), accumulated depreciation and equipment cleared, the bank -# 7,000.00 (debit). -[[expect]] -query = "balances" -as_of = "2026-06-30" - -[expect.values] -"1000" = "7000.00" -"1500" = "0.00" -"1510" = "0.00" -"6200" = "5500.00" -"7100" = "500.00" diff --git a/bookspec/examples/depreciation-final-month-by-days.toml b/bookspec/examples/depreciation-final-month-by-days.toml deleted file mode 100644 index 3c3a54f..0000000 --- a/bookspec/examples/depreciation-final-month-by-days.toml +++ /dev/null @@ -1,93 +0,0 @@ -id = "depreciation-final-month-by-days" -title = "Straight-line depreciation by the day through the life's final month" -rule = "Depreciation through a day of an asset's final month is the depreciation before that month plus the final month's share for the days held, never the whole remaining cost." -status = "CONVENTION" -sources = ["ASC 360-10-35-4"] -tags = ["depreciation", "straight-line", "final-month", "p3-defect-22"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01. Its life is -# 2026-01-01 through 2026-12-31; December is its final month. -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -# Hand check (worked without the code): 12,000.00 / 12 = 1,000.00 a month. -# Through Dec 15: January..November, 11 x 1,000.00 = 11,000.00, plus -# December's 1,000.00 x 15/31 = 483.870... -> 483.87 (half away from zero). -# Accumulated 11,483.87; book value 12,000.00 - 11,483.87 = 516.13. -[[expect]] -query = "depreciation_schedule" -asset = "X" -as_of = "2026-12-15" - -[expect.values] -"2026-11" = "1000.00" -"2026-12" = "483.87" -accumulated = "11483.87" -book_value = "516.13" - -# Hand check: through the life's last day, the whole of it. -[[expect]] -query = "depreciation_schedule" -asset = "X" -as_of = "2026-12-31" - -[expect.values] -"2026-12" = "1000.00" -accumulated = "12000.00" -book_value = "0.00" diff --git a/bookspec/examples/depreciation-final-month-drift.toml b/bookspec/examples/depreciation-final-month-drift.toml deleted file mode 100644 index e871319..0000000 --- a/bookspec/examples/depreciation-final-month-drift.toml +++ /dev/null @@ -1,98 +0,0 @@ -id = "depreciation-final-month-drift" -title = "The final month of a mid-month life takes what the months before it leave, evenly over its days" -rule = "When a life does not start on the 1st, its final month is charged the depreciable amount less what the months before it took, spread evenly over the final month's days." -status = "CONVENTION" -sources = ["ASC 360-10-35-4"] -tags = ["depreciation", "straight-line", "final-month", "month-length-drift", "p3-defect-22"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -# Cost 1,030.00, salvage 100.00 (930.00 to depreciate), 3 months from -# 2026-01-15: the life is 2026-01-15 through 2026-04-14. -[[setup.assets]] -id = "D" -description = "Asset D" -cost = "1030.00" -salvage = "100.00" -life_months = 3 -in_service = "2026-01-15" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-15" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "1030.00" }, - { account = "3000", side = "credit", amount = "1030.00" }, -] - -[[events]] -date = "2026-01-15" -description = "Bought asset D" -lines = [ - { account = "1500", side = "debit", amount = "1030.00" }, - { account = "1000", side = "credit", amount = "1030.00" }, -] - -# Hand check (worked without the code): 930.00 / 3 = 310.00 a month. -# January holds 17 of its 31 days: 310.00 x 17/31 = 170.00. February and -# March are whole: 310.00 each. Before April: 790.00. April holds 14 days -# (1-14) and is charged what is left, 930.00 - 790.00 = 140.00, evenly: -# 10.00 a day (January's own daily rate, 310.00/31). Through Apr 13: -# 130.00; accumulated 920.00; book value 1,030.00 - 920.00 = 110.00. -[[expect]] -query = "depreciation_schedule" -asset = "D" -as_of = "2026-04-13" - -[expect.values] -"2026-01" = "170.00" -"2026-02" = "310.00" -"2026-03" = "310.00" -"2026-04" = "130.00" -accumulated = "920.00" -book_value = "110.00" - -# Through Apr 14, the life's last day: April 140.00, accumulated 930.00, -# book value 100.00 (the salvage, never depreciated). -[[expect]] -query = "depreciation_schedule" -asset = "D" -as_of = "2026-04-14" - -[expect.values] -"2026-04" = "140.00" -accumulated = "930.00" -book_value = "100.00" diff --git a/bookspec/examples/depreciation-reversal-before-its-run-is-refused.toml b/bookspec/examples/depreciation-reversal-before-its-run-is-refused.toml deleted file mode 100644 index 3fe9be8..0000000 --- a/bookspec/examples/depreciation-reversal-before-its-run-is-refused.toml +++ /dev/null @@ -1,117 +0,0 @@ -id = "depreciation-reversal-before-its-run-is-refused" -title = "A depreciation run can't be reversed on a day before its own" -rule = "A correction comes after what it corrects: a depreciation run, or a reversal of one, can't be reversed on a day before its own. A run of March reversed on Jan 31 would leave January and February with negative depreciation." -status = "CONVENTION" -sources = [ - "Pacioli 1494, ch. 31", - "wanderbooks docs/spec/rules.md B-15 rule 6, B-9: a correction comes after what it corrects (CONVENTION)", -] -tags = ["depreciation", "reversal", "refusal", "p3-defect-22"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.accounts]] -code = "7100" -name = "Gain or Loss on Disposal" -type = "revenue" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a -# month; December the final month). -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-02" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-03" } - -# March's run (dated Mar 31) reversed on Feb 10: refused, naming its date. -[[events]] -date = "2026-02-10" -description = "Reverse event 5" -reverses = 5 -refused = "entry_irreversible" - -# On its own day it is taken. -[[events]] -date = "2026-03-31" -description = "Reverse event 5" -reverses = 5 - -# Hand check (worked without the code): January and February carry -# 2,000.00 at February's end, never 1,000.00 (with March's 1,000.00 taken -# away on Feb 10); March's end carries 2,000.00 (its run and its reversal -# both dated Mar 31). -[[expect]] -query = "balances" -as_of = "2026-02-28" - -[expect.values] -"1510" = "-2000.00" -"6200" = "2000.00" - -[[expect]] -query = "balances" -as_of = "2026-03-31" - -[expect.values] -"1510" = "-2000.00" -"6200" = "2000.00" diff --git a/bookspec/examples/depreciation-run-reversed-after-its-month.toml b/bookspec/examples/depreciation-run-reversed-after-its-month.toml deleted file mode 100644 index 905404d..0000000 --- a/bookspec/examples/depreciation-run-reversed-after-its-month.toml +++ /dev/null @@ -1,121 +0,0 @@ -id = "depreciation-run-reversed-after-its-month" -title = "A run reversed after its month ended is caught up in the reversal's month, never posted twice" -rule = "What the books carry is read entry by entry, each in its own month: a run reversed after its month ended still carried that month at its end, so running the month again posts nothing, and the run of the reversal's month catches the month up." -status = "CHOICE" -sources = ["ASC 360-10-35-4", "DECISION: plan/served.md P3, Settled in design (2026-10-20)", "docs/spec/rules.md B-9"] -tags = ["depreciation", "catch-up", "reversal", "p3"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -# Asset X: 12,000.00, no salvage, 12 months from 2026-01-01 (1,000.00 a -# month; December the final month). -[[setup.assets]] -id = "X" -description = "Asset X" -cost = "12000.00" -salvage = "0.00" -life_months = 12 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" - -[[events]] -date = "2026-01-01" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "12000.00" }, - { account = "3000", side = "credit", amount = "12000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Bought asset X" -lines = [ - { account = "1500", side = "debit", amount = "12000.00" }, - { account = "1000", side = "credit", amount = "12000.00" }, -] - -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -# January's run reversed on 2026-02-02, after January ended. -[[events]] -date = "2026-02-02" -description = "Reverse event 3" -reverses = 3 - -# January run again: nothing is due (Jan 31 carried January). -[[events]] -run_depreciation = { asset = "X", period = "2026-01" } - -[[events]] -run_depreciation = { asset = "X", period = "2026-02" } - -# Hand check (worked without the code): January's run (1,000.00, dated Jan -# 31) stood on Jan 31, so the books carried the schedule there, 1,000.00; -# running January again posts 0.00 (were it to post, Jan 31 would carry -# 2,000.00). The reversal took 1,000.00 away on Feb 2, so February's run -# posts the schedule through Feb 28, 2,000.00, less the 0.00 then carried: -# 2,000.00, of which 1,000.00 is January caught up. -[[expect]] -query = "run" -event = 5 - -[expect.values] -amount = "0.00" -caught_up = "0.00" - -[[expect]] -query = "run" -event = 6 - -[expect.values] -amount = "2000.00" -caught_up = "1000.00" - -[[expect]] -query = "balances" -as_of = "2026-01-31" - -[expect.values] -"1510" = "-1000.00" -"6200" = "1000.00" - -[[expect]] -query = "balances" -as_of = "2026-02-28" - -[expect.values] -"1510" = "-2000.00" -"6200" = "2000.00" diff --git a/bookspec/examples/entry-a-line-amount-is-positive.toml b/bookspec/examples/entry-a-line-amount-is-positive.toml deleted file mode 100644 index f6a8adf..0000000 --- a/bookspec/examples/entry-a-line-amount-is-positive.toml +++ /dev/null @@ -1,75 +0,0 @@ -id = "entry-a-line-amount-is-positive" -title = "A negative or zero line amount is refused; the side gives the direction" -rule = "A line's amount MUST be greater than zero, and its side, not its sign, MUST give the direction money moves; an entry with a negative or zero line MUST be refused." -status = "CONVENTION" -sources = [ - "Pacioli, Summa (1494), ch. 36: debtors at the left, creditors at the right (docs/spec/sources/pacioli-1494.md)", - "CONVENTION, the owner, 2026-10-13 (docs/spec/rules.md B-2): no authority forbids a negative amount outright; one reading of every line is chosen", -] -tags = ["accrual", "book", "us", "entry"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" -tax_category = "gross_receipts" - -[[setup.accounts]] -code = "6000" -name = "Rent" -type = "expense" -tax_category = "rent_property" - -# A credit of minus 100.00 would be a debit of 100.00 written the other -# way; the books keep one way to write it. -[[events]] -date = "2026-01-05" -description = "A negative credit" -refused = "negative_amount" -lines = [ - { account = "1000", side = "debit", amount = "100.00" }, - { account = "4000", side = "credit", amount = "-100.00" }, -] - -[[events]] -date = "2026-01-06" -description = "A zero line" -refused = "zero_amount" -lines = [ - { account = "1000", side = "debit", amount = "0.00" }, - { account = "4000", side = "credit", amount = "0.00" }, -] - -# The same 100.00 written with the sides doing the work is accepted. -[[events]] -date = "2026-01-07" -description = "A sale" -lines = [ - { account = "1000", side = "debit", amount = "100.00" }, - { account = "4000", side = "credit", amount = "100.00" }, -] - -[[expect]] -query = "trial_balance" -as_of = "2026-12-31" -[expect.values] -"1000" = "100.00" -"3000" = "0.00" -"4000" = "-100.00" -"6000" = "0.00" diff --git a/bookspec/examples/entry-an-unbalanced-entry-is-refused.toml b/bookspec/examples/entry-an-unbalanced-entry-is-refused.toml deleted file mode 100644 index 989baf1..0000000 --- a/bookspec/examples/entry-an-unbalanced-entry-is-refused.toml +++ /dev/null @@ -1,76 +0,0 @@ -id = "entry-an-unbalanced-entry-is-refused" -title = "An entry whose debits and credits differ is refused and posts nothing" -rule = "An entry MUST have debits equal to credits, exactly, and an entry that does not MUST be refused whole, leaving the books as they were." -status = "AUTHORITY" -sources = [ - "Pacioli, Summa (1494), ch. 36: \"All entries made in the ledger have to be double entries\" (docs/spec/sources/pacioli-1494.md)", - "IRS Publication 583: \"the total debits must equal the total credits\" (docs/spec/sources/irs-records.md)", -] -tags = ["accrual", "book", "us", "entry", "double-entry"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" -tax_category = "gross_receipts" - -[[setup.accounts]] -code = "6000" -name = "Rent" -type = "expense" -tax_category = "rent_property" - -[[events]] -date = "2026-01-02" -description = "Opening contribution" -lines = [ - { account = "1000", side = "debit", amount = "1000.00" }, - { account = "3000", side = "credit", amount = "1000.00" }, -] - -# Debits 100.00, credits 90.00: ten dollars short on the credit side. -[[events]] -date = "2026-01-05" -description = "A sale that does not balance" -refused = "unbalanced" -lines = [ - { account = "1000", side = "debit", amount = "100.00" }, - { account = "4000", side = "credit", amount = "90.00" }, -] - -# One cent over is just as wrong: there is no epsilon. -[[events]] -date = "2026-01-06" -description = "A sale a cent over" -refused = "unbalanced" -lines = [ - { account = "1000", side = "debit", amount = "100.01" }, - { account = "4000", side = "credit", amount = "100.00" }, -] - -# By hand: only event 1 posted, so cash holds 1,000.00 (a debit), capital -# 1,000.00 (a credit, shown negative), and sales nothing. -[[expect]] -query = "trial_balance" -as_of = "2026-12-31" -[expect.values] -"1000" = "1000.00" -"3000" = "-1000.00" -"4000" = "0.00" -"6000" = "0.00" diff --git a/bookspec/examples/entry-cents-add-exactly.toml b/bookspec/examples/entry-cents-add-exactly.toml deleted file mode 100644 index 09ab2ef..0000000 --- a/bookspec/examples/entry-cents-add-exactly.toml +++ /dev/null @@ -1,68 +0,0 @@ -id = "entry-cents-add-exactly" -title = "Amounts are kept exactly: ten cents and twenty cents make thirty" -rule = "The books MUST keep every amount exactly and add exactly, so that no sum of cents differs from its true total by any amount, however small; rounding MUST NOT occur inside the books." -status = "AUTHORITY" -sources = [ - "IRS Form 1065 instructions: \"include cents when adding the amounts and round off only the total\" (docs/spec/sources/irs-partnerships.md)", - "Pacioli, Summa (1494), ch. 36: the credits equal the debits, which no float sum guarantees (docs/spec/sources/pacioli-1494.md)", -] -tags = ["accrual", "book", "us", "entry", "exact"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "2000" -name = "Loan" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" -tax_category = "gross_receipts" - -[[setup.accounts]] -code = "6000" -name = "Rent" -type = "expense" -tax_category = "rent_property" - -# In binary floating point 0.1 + 0.2 is 0.30000000000000004. Two debits of -# 0.10 and 0.20 against one credit of 0.30 balance exactly. -[[events]] -date = "2026-01-05" -description = "Three small sales" -lines = [ - { account = "1000", side = "debit", amount = "0.10" }, - { account = "1000", side = "debit", amount = "0.20" }, - { account = "4000", side = "credit", amount = "0.30" }, -] - -[[events]] -date = "2026-01-06" -description = "A sale of a trillion and a cent" -lines = [ - { account = "1000", side = "debit", amount = "1000000000000.01" }, - { account = "4000", side = "credit", amount = "1000000000000.01" }, -] - -[[expect]] -query = "balances" -as_of = "2026-12-31" -[expect.values] -"1000" = "1000000000000.31" -"4000" = "1000000000000.31" diff --git a/bookspec/examples/iif-quickbooks-journal-file.toml b/bookspec/examples/iif-quickbooks-journal-file.toml deleted file mode 100644 index 93b8092..0000000 --- a/bookspec/examples/iif-quickbooks-journal-file.toml +++ /dev/null @@ -1,202 +0,0 @@ -# The journal file QuickBooks Desktop imports (IIF), as of a day. A format, -# not accounting: P13's appendix of interchange formats. -# -# wanderbooks: P3 defects 7 and 21, decision D-36 and the P3 review (the -# file numbered transactions by their place in it, held entries dated after -# the package's as_of, wrote '?' for every character past ASCII, and knew -# nothing of QuickBooks' length limits, its double quote, or its rule that -# a subaccount has its parent's type, and could declare a subaccount -# before its parent; and the cutover review: it left CLASS empty though -# lines carry projects); rules.md B-34; regression tests -# `iif_docnum_is_the_entry_id`, -# `iif_writes_plain_ascii_and_lists_every_change`, -# `iif_fits_quickbooks_desktop_limits`, -# `iif_a_subaccount_of_another_type_stands_at_the_top_level`, -# `iif_declares_each_parent_before_its_subaccounts`, -# `iif_writes_each_lines_class_and_declares_the_classes_first`, -# `iif_class_names_fit_quickbooks_and_every_change_is_listed` (ledger-import -# tests/spec_import.rs) and `as_of_package_iif_stops_at_as_of` -# (ledger-package tests/package.rs). -# -# Setup beyond the accounts: `cash_accounts` are the bank accounts (BANK); -# an account's QuickBooks type otherwise follows its type (asset OCASSET, -# liability OCLIAB, equity EQUITY, revenue INC, expense EXP); an account's -# `parent` is its parent's code; project N in `setup.projects` is class N -# (the books number classes from 1). Event N is entry N (bookspec/README.md). -# Expected values: iif_lines gives the file's lines in order, each a -# string of fields joined by tabs ("\t"), every line ending CR LF in the -# file; iif_changes gives each text written differently from the books, -# in file order (the accounts as declared: by code, each after its -# parent; then the classes; then the entries), as [what, books' text, -# file's text], where what is "name ", "number" (left out of ACCNUM -# when the file's text is empty), "top level " (written apart from -# its parent, the books' text: the parent's code), "class " (a -# project's name) or "memo ". - -id = "iif-quickbooks-journal-file" -appendix = "formats" -title = "A QuickBooks Desktop journal file as of a day" -rule = "An IIF journal file as of a day D MUST declare every account first (NAME, ACCNTTYPE, ACCNUM), then hold one GENERAL JOURNAL transaction per posted entry dated on or before D, period closes excepted, in the order posted: a TRNS line and SPL lines, debits positive and credits negative, dated M/D/YYYY, each numbered (DOCNUM) with the entry's id; then ENDTRNS. It MUST be ASCII text, every line ending CR LF, no field holding a tab, a line break or a double quote; an account name level MUST be at most 31 characters and no two NAMEs alike, an account number at most 7 characters and no two alike (else left out), a memo at most 4,095 characters; each parent MUST be declared before its subaccounts, and a subaccount whose QuickBooks type differs from its parent's MUST be written at the top level. Every project MUST be declared as a class (NAME, one level of at most 31 characters, no two alike) after the accounts and before the transactions, and each line's project MUST be its CLASS. Each text written differently from the books MUST be reported." -status = "CHOICE" -sources = [ - "Intuit, IIF Overview: import kit, sample files, and headers: IIF files are ASCII text, tab-separated; an account not already in the company file is created as a new bank account (docs/spec/sources/intuit-iif.md)", - "Intuit, IIF Import Kit, IIF Header Help !TRNS, !SPL, !ACCNT: TRNS and SPL lines then ENDTRNS; \"Debit amounts are always positive, credit amounts are always negative\"; DOCNUM is the transaction's number; a subaccount's NAME its parents' names first, joined by colons (docs/spec/sources/intuit-iif.md)", - "Intuit, IIF Import Kit, 2007 General Journal Entry example file: GENERAL JOURNAL, M/D/YYYY, every line ending CR LF (docs/spec/sources/intuit-iif.md)", - "Intuit, Character limitations for fields in QuickBooks (US, updated 8/3/2026): Account Name 31, Account Numbers 7, Memo 4,095 (docs/spec/sources/intuit-iif.md)", - "Intuit, IIF Import Kit, IIF Header Help !CLASS: \"Only the NAME column is required\"; a subclass's NAME its parents' names first, colons between; the kit's list declarations example declares !CLASS before any transaction (docs/spec/sources/intuit-iif.md)", - "Conductor, QuickBooks Desktop API reference, Create a class: a class's name, \"Maximum length: 31 characters\", case-insensitive (docs/spec/sources/intuit-iif.md; not Intuit: Intuit states no class limit)", - "Intuit, Add, edit, or delete accounts in QuickBooks Desktop (updated 8/4/2026): \"The parent and subaccount must be the same account type.\" (docs/spec/sources/intuit-iif.md)", - "Reckon, IIF import changes a double quote to a single one by design; J. Clark (2003): QuickBooks uses double quotes as text delimiters (docs/spec/sources/intuit-iif.md; not Intuit)", - "DECISION D-36 (owner, 2026-10-20): plain ASCII, what isn't ASCII transliterated and listed; CR LF; DOCNUM the entry's id; closes left out (wanderbooks plan/served.md; rules.md B-34)", -] -tags = ["iif", "quickbooks", "as-of", "format", "transliteration", "projects"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "cash" -rounding = "half-away-from-zero" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1090" -name = "Bank accounts" -type = "asset" - -# A bank account (BANK) under a header that is no bank (OCASSET): written -# at the top level. -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" -parent = "1090" - -[[setup.accounts]] -code = "12345678" -name = "Suspense" -type = "expense" - -[[setup.accounts]] -code = "3000" -name = "Owner capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" - -[[setup.accounts]] -code = "6000" -name = "Café meals" -type = "expense" - -# Coded before its parent: declared after it. -[[setup.accounts]] -code = "5900" -name = "Snacks" -type = "expense" -parent = "6000" - -[[setup.accounts]] -code = "6100" -name = "Professional services and consulting fees" -type = "expense" - -# Class 1: a colon and an em dash, written "-" and "--". -[[setup.projects]] -name = "Café: lunch — rebrand" - -# Class 2: declared though no line carries it, as every account is. -[[setup.projects]] -name = "Glaze tests" - -# Entry 1. -[[events]] -date = "2026-01-02" -description = "Owner contribution" -lines = [ - { account = "1000", side = "debit", amount = "5000.00" }, - { account = "3000", side = "credit", amount = "5000.00" }, -] - -# Entry 2. An em dash and double quotes; the meal is project 1's. -[[events]] -date = "2026-01-15" -description = "Lunch — \"team\"" -lines = [ - { account = "6000", side = "debit", amount = "42.50", project = "Café: lunch — rebrand" }, - { account = "1000", side = "credit", amount = "42.50" }, -] - -# Entry 3. An account named past 31 characters. -[[events]] -date = "2026-01-20" -description = "Consulting" -lines = [ - { account = "6100", side = "debit", amount = "300.00" }, - { account = "1000", side = "credit", amount = "300.00" }, -] - -# Entry 4, dated the day itself: in. -[[events]] -date = "2026-02-01" -description = "Sale" -lines = [ - { account = "1000", side = "debit", amount = "1000.00" }, - { account = "4000", side = "credit", amount = "1000.00" }, -] - -# Entry 5, the day after: out. -[[events]] -date = "2026-02-02" -description = "Second sale" -lines = [ - { account = "1000", side = "debit", amount = "250.00" }, - { account = "4000", side = "credit", amount = "250.00" }, -] - -[[expect]] -query = "iif_lines" -as_of = "2026-02-01" -values = [ - "!ACCNT\tNAME\tACCNTTYPE\tACCNUM", - "ACCNT\tChecking\tBANK\t1000", - "ACCNT\tBank accounts\tOCASSET\t1090", - "ACCNT\tSuspense\tEXP\t", - "ACCNT\tOwner capital\tEQUITY\t3000", - "ACCNT\tSales\tINC\t4000", - "ACCNT\tCafe meals\tEXP\t6000", - "ACCNT\tCafe meals:Snacks\tEXP\t5900", - "ACCNT\tProfessional services and consu\tEXP\t6100", - "!CLASS\tNAME", - "CLASS\tCafe- lunch -- rebrand", - "CLASS\tGlaze tests", - "!TRNS\tTRNSID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO", - "!SPL\tSPLID\tTRNSTYPE\tDATE\tACCNT\tCLASS\tAMOUNT\tDOCNUM\tMEMO", - "!ENDTRNS", - "TRNS\t\tGENERAL JOURNAL\t1/2/2026\tChecking\t\t5000.00\t1\tOwner contribution", - "SPL\t\tGENERAL JOURNAL\t1/2/2026\tOwner capital\t\t-5000.00\t1\tOwner contribution", - "ENDTRNS", - "TRNS\t\tGENERAL JOURNAL\t1/15/2026\tCafe meals\tCafe- lunch -- rebrand\t42.50\t2\tLunch -- 'team'", - "SPL\t\tGENERAL JOURNAL\t1/15/2026\tChecking\t\t-42.50\t2\tLunch -- 'team'", - "ENDTRNS", - "TRNS\t\tGENERAL JOURNAL\t1/20/2026\tProfessional services and consu\t\t300.00\t3\tConsulting", - "SPL\t\tGENERAL JOURNAL\t1/20/2026\tChecking\t\t-300.00\t3\tConsulting", - "ENDTRNS", - "TRNS\t\tGENERAL JOURNAL\t2/1/2026\tChecking\t\t1000.00\t4\tSale", - "SPL\t\tGENERAL JOURNAL\t2/1/2026\tSales\t\t-1000.00\t4\tSale", - "ENDTRNS", -] - -[[expect]] -query = "iif_changes" -as_of = "2026-02-01" -values = [ - ["top level 1000", "1090", ""], - ["number", "12345678", ""], - ["name 6000", "Café meals", "Cafe meals"], - ["name 6100", "Professional services and consulting fees", "Professional services and consu"], - ["class 1", "Café: lunch — rebrand", "Cafe- lunch -- rebrand"], - ["memo 2", "Lunch — \"team\"", "Lunch -- 'team'"], -] diff --git a/bookspec/examples/ledger-a-child-account-has-its-parents-type.toml b/bookspec/examples/ledger-a-child-account-has-its-parents-type.toml deleted file mode 100644 index c8adcae..0000000 --- a/bookspec/examples/ledger-a-child-account-has-its-parents-type.toml +++ /dev/null @@ -1,55 +0,0 @@ -events = [] - -id = "ledger-a-child-account-has-its-parents-type" -title = "An account under a parent has the parent's type" -rule = "An account MUST be one of the five elements (asset, liability, equity, revenue, expense), and an account under a parent MUST have its parent's type, so a rollup never mixes elements." -status = "AUTHORITY" -sources = [ - "FASB Concepts Statement 8, Ch. 4, E16, E37, E61, E80, E81: the five elements (docs/spec/sources/fasb-asc.md)", - "CHOICE, the owner, 2026-10-13 (docs/spec/rules.md B-4): a child takes its parent's type", -] -tags = ["accrual", "book", "us", "ledger", "chart"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "6000" -name = "Operating" -type = "expense" -header = true - -[[setup.accounts]] -code = "6150" -name = "Software" -type = "expense" -parent = "6000" -tax_category = "office_expense" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -# A revenue account under an expense header would mix two elements. -[[expect]] -query = "add_account" -account = { code = "6200", name = "Refunds", type = "revenue", parent = "6000", tax_category = "gross_receipts" } -refused = "parent_type_mismatch" - -# An expense under the expense header is a child of the same element. -[[expect]] -query = "add_account" -account = { code = "6200", name = "Hosting", type = "expense", parent = "6000", tax_category = "office_expense" } -accepted = true - -[[expect]] -query = "trial_balance" -as_of = "2026-12-31" -[expect.values] -"6000" = "0.00" -"6150" = "0.00" -"1000" = "0.00" diff --git a/bookspec/examples/ledger-a-fraction-of-a-cent-is-refused.toml b/bookspec/examples/ledger-a-fraction-of-a-cent-is-refused.toml deleted file mode 100644 index 896cd5c..0000000 --- a/bookspec/examples/ledger-a-fraction-of-a-cent-is-refused.toml +++ /dev/null @@ -1,65 +0,0 @@ -id = "ledger-a-fraction-of-a-cent-is-refused" -title = "A line finer than the currency's smallest unit is refused" -rule = "A ledger kept in one currency MUST hold amounts in whole units of that currency's smallest unit, so a line finer than it MUST be refused, never rounded silently." -status = "CHOICE" -sources = [ - "Pacioli, Summa (1494), ch. 36: \"the money that you reckon by at the beginning should be the same all through the Ledger\" (docs/spec/sources/pacioli-1494.md)", - "ISO 4217: the US dollar has two minor-unit digits (docs/spec/sources/iso-4217.md)", - "CHOICE, the owner, 2026-10-13 (docs/spec/rules.md B-3): books in the smallest unit, so cash can match a bank statement", -] -tags = ["accrual", "book", "us", "ledger", "currency"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" -tax_category = "gross_receipts" - -[[setup.accounts]] -code = "6000" -name = "Rent" -type = "expense" -tax_category = "rent_property" - -# 10.005 dollars is no amount anyone can pay. -[[events]] -date = "2026-01-05" -description = "Half a cent too fine" -refused = "finer_than_minor_unit" -lines = [ - { account = "1000", side = "debit", amount = "10.005" }, - { account = "4000", side = "credit", amount = "10.005" }, -] - -[[events]] -date = "2026-01-06" -description = "The same sale in cents" -lines = [ - { account = "1000", side = "debit", amount = "10.01" }, - { account = "4000", side = "credit", amount = "10.01" }, -] - -[[expect]] -query = "trial_balance" -as_of = "2026-12-31" -[expect.values] -"1000" = "10.01" -"3000" = "0.00" -"4000" = "-10.01" -"6000" = "0.00" diff --git a/bookspec/examples/ledger-a-mistake-is-reversed-never-erased.toml b/bookspec/examples/ledger-a-mistake-is-reversed-never-erased.toml deleted file mode 100644 index 48caf69..0000000 --- a/bookspec/examples/ledger-a-mistake-is-reversed-never-erased.toml +++ /dev/null @@ -1,101 +0,0 @@ -id = "ledger-a-mistake-is-reversed-never-erased" -title = "A reversal cancels an entry and leaves the original standing, and an entry is reversed only once" -rule = "A posted entry MUST NOT be changed or erased; a mistake MUST be corrected by an entry opposite to it, which MUST leave the original's lines as written, and an entry MUST be reversed at most once." -status = "AUTHORITY" -sources = [ - "Pacioli, Summa (1494), ch. 31: \"make another entry opposite this one\"; ch. 36: \"You should not write backward, but always forward\" (docs/spec/sources/pacioli-1494.md)", - "Rev. Proc. 98-25 section 5.01: the audit trail (docs/spec/sources/irs-records.md)", - "CHOICE, the owner, 2026-10-13 (docs/spec/rules.md B-10): a correction corrects once", -] -tags = ["accrual", "book", "us", "ledger", "reversal"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "2000" -name = "Loan" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" -tax_category = "gross_receipts" - -[[setup.accounts]] -code = "6000" -name = "Rent" -type = "expense" -tax_category = "rent_property" - -[[events]] -date = "2026-01-10" -description = "Rent paid twice by mistake" -lines = [ - { account = "6000", side = "debit", amount = "800.00" }, - { account = "1000", side = "credit", amount = "800.00" }, -] - -[[events]] -date = "2026-01-12" -description = "Reverse entry 1" -reverses = 1 - -# A second reversal of the same entry would take the 800.00 out twice. -[[events]] -date = "2026-01-13" -description = "Reverse entry 1 again" -refused = "already_reversed" -reverses = 1 - -# The original still reads as it was written: posted is forever. -[[expect]] -query = "entry" -entry = 1 -[expect.values] -"6000.debit" = "800.00" -"1000.credit" = "800.00" - -# The reversal is the original with its sides flipped. -[[expect]] -query = "entry" -entry = 2 -[expect.values] -"6000.credit" = "800.00" -"1000.debit" = "800.00" - -# Net of the two, nothing: as if nothing had been written (ch. 31). -[[expect]] -query = "trial_balance" -as_of = "2026-12-31" -[expect.values] -"1000" = "0.00" -"2000" = "0.00" -"3000" = "0.00" -"4000" = "0.00" -"6000" = "0.00" - -# Before the reversal's date the original stands alone. -[[expect]] -query = "trial_balance" -as_of = "2026-01-11" -[expect.values] -"1000" = "-800.00" -"2000" = "0.00" -"3000" = "0.00" -"4000" = "0.00" -"6000" = "800.00" diff --git a/bookspec/examples/members-close.odd-cent-across-closes.toml b/bookspec/examples/members-close.odd-cent-across-closes.toml deleted file mode 100644 index d8d286f..0000000 --- a/bookspec/examples/members-close.odd-cent-across-closes.toml +++ /dev/null @@ -1,133 +0,0 @@ -id = "members-close.odd-cent-across-closes" -title = "An odd cent split cumulatively across closes, a close to retained earnings between" -rule = "Each close to the members MUST give each member their largest-remainder split of everything closed to the same members and shares so far, to the minor unit, less what earlier closes gave them; a close to retained earnings allocates nothing to the members and MUST NOT restart the count." -status = "CHOICE" -sources = [ - "26 U.S.C. §704(a)", - "Treas. Reg. §1.704-1(b)(2)(iv)(b)", - "DECISION (owner, 2026-10-13: largest remainder; D-10, 2026-10-16: counted from the first close) (wanderbooks rules.md B-13)", -] -tags = ["partnership", "close", "rounding", "book", "us"] - -# The regression this example comes from (wanderbooks P3 defect 17, audit -# F52, anonymized): a net loss of 287.53 at 50/50, closed in January and -# again in March, with February closed to retained earnings. Restarting the -# count at March gave A the tied cent twice (A −287.54, B −287.52). -# -# By hand, from the rule: -# January: −287.53 × 0.5 = −143.765 each; rounded toward zero, −143.76 -# each, one cent short; the remainders tie (0.005), so the cent goes to -# the member listed first: A −143.77, B −143.76. -# February: 10.00 to retained earnings; the members' count skips it. -# March: the count's running total is −575.06, −287.53 each exactly; -# less January's −143.77 / −143.76, March gives A −143.76, B −143.77. -# Each member ends at −287.53, their exact share. - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "largest-remainder" # odd cents to the largest remainders, ties - # to the member listed first, cumulatively - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital, A" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital, B" -type = "equity" - -[[setup.accounts]] -code = "3999" -name = "Retained earnings" -type = "equity" - -[[setup.accounts]] -code = "6000" -name = "Fees" -type = "expense" - -[[setup.members]] -name = "A" -capital_account = "3000" -share = "0.5" - -[[setup.members]] -name = "B" -capital_account = "3001" -share = "0.5" - -[[events]] # event 1 -date = "2026-01-15" -description = "January's fees" -lines = [ - { account = "6000", side = "debit", amount = "287.53" }, - { account = "1000", side = "credit", amount = "287.53" }, -] - -[[events]] # event 2: the closing entry -date = "2026-01-31" -description = "Close January to the members" -close = { start = "2026-01-01", to = "members" } - -[[events]] # event 3 -date = "2026-02-15" -description = "February's fees" -lines = [ - { account = "6000", side = "debit", amount = "10.00" }, - { account = "1000", side = "credit", amount = "10.00" }, -] - -[[events]] # event 4 -date = "2026-02-28" -description = "Close February to retained earnings" -close = { start = "2026-02-01", to = "retained_earnings" } - -[[events]] # event 5 -date = "2026-03-15" -description = "March's fees" -lines = [ - { account = "6000", side = "debit", amount = "287.53" }, - { account = "1000", side = "credit", amount = "287.53" }, -] - -[[events]] # event 6 -date = "2026-03-31" -description = "Close March to the members" -close = { start = "2026-03-01", to = "members" } - -[[expect]] -query = "entry" -entry = 2 - -[expect.values] -"6000.credit" = "287.53" -"3000.debit" = "143.77" -"3001.debit" = "143.76" - -[[expect]] -query = "entry" -entry = 6 - -[expect.values] -"6000.credit" = "287.53" -"3000.debit" = "143.76" -"3001.debit" = "143.77" - -[[expect]] -query = "balances" -as_of = "2026-03-31" - -[expect.values] -"3000" = "-287.53" -"3001" = "-287.53" -"3999" = "-10.00" -"1000" = "-585.06" diff --git a/bookspec/examples/members-close.statement-before-and-after-a-close.toml b/bookspec/examples/members-close.statement-before-and-after-a-close.toml deleted file mode 100644 index af84595..0000000 --- a/bookspec/examples/members-close.statement-before-and-after-a-close.toml +++ /dev/null @@ -1,116 +0,0 @@ -id = "members-close.statement-before-and-after-a-close" -title = "A member's capital on a day is the same before and after its month closes" -rule = "The statement of members' equity MUST split earnings not yet closed the way the next close to the members will (each member's split of everything closed plus those earnings, less their split of what was closed), so a member's capital as of a day MUST NOT change when a period ending on or before it is closed." -status = "CHOICE" -sources = [ - "Treas. Reg. §1.704-1(b)(2)(iv)(b)", - "DECISION D-38 (owner, 2026-10-20): the statement splits unclosed earnings the same running way as the closes (wanderbooks rules.md B-13)", -] -tags = ["partnership", "close", "rounding", "statement", "book", "us"] - -# Two members at 50/50; January's cent is closed to them, February's is -# earned but not yet closed (event 3), then closed (event 4). -# -# By hand, from the rule: -# January's close: 0.01 is 0.005 each; the tied cent to A (listed first): -# A 0.01, B 0.00. -# As of Feb 28, February not yet closed: closed 0.01, unclosed 0.01. The -# split of everything, 0.02, is 0.01 / 0.01; of the closed part, 0.01 / -# 0.00; so the unclosed cent is B's: A 0.01 + 0.00, B 0.00 + 0.01. -# (Splitting February's cent on its own would give it to A: A 0.02, -# B 0.00, and the close would then move it to B.) -# February's close gives the running 0.02, 0.01 each, less 0.01 / 0.00: -# B 0.01. As of Feb 28: A 0.01, B 0.01, as before the close. - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "largest-remainder" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital, A" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital, B" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" - -[[setup.members]] -name = "A" -capital_account = "3000" -share = "0.5" - -[[setup.members]] -name = "B" -capital_account = "3001" -share = "0.5" - -[[events]] # event 1 -date = "2026-01-10" -description = "January's sale" -lines = [ - { account = "1000", side = "debit", amount = "0.01" }, - { account = "4000", side = "credit", amount = "0.01" }, -] - -[[events]] # event 2 -date = "2026-01-31" -description = "Close January to the members" -close = { start = "2026-01-01", to = "members" } - -[[events]] # event 3 -date = "2026-02-10" -description = "February's sale" -lines = [ - { account = "1000", side = "debit", amount = "0.01" }, - { account = "4000", side = "credit", amount = "0.01" }, -] - -[[events]] # event 4 -date = "2026-02-28" -description = "Close February to the members" -close = { start = "2026-02-01", to = "members" } - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-02-28" -after = 3 # the books before February's close - -[expect.values] -"A.ending" = "0.01" -"B.ending" = "0.01" -"A.net_income" = "0.01" -"B.net_income" = "0.01" - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-02-28" # after every event: February closed - -[expect.values] -"A.ending" = "0.01" -"B.ending" = "0.01" -"A.net_income" = "0.01" -"B.net_income" = "0.01" - -[[expect]] -query = "entry" -entry = 4 - -[expect.values] -"4000.debit" = "0.01" -"3001.credit" = "0.01" diff --git a/bookspec/examples/members-close.thirds-over-twelve-months.toml b/bookspec/examples/members-close.thirds-over-twelve-months.toml deleted file mode 100644 index 79589f6..0000000 --- a/bookspec/examples/members-close.thirds-over-twelve-months.toml +++ /dev/null @@ -1,292 +0,0 @@ -id = "members-close.thirds-over-twelve-months" -title = "Three members at a third each, twelve monthly closes" -rule = "Each monthly close to the members MUST give each member their largest-remainder split of everything closed so far, to the minor unit, less what earlier closes gave them, so after every close each member's total is less than one minor unit from their exact share." -status = "CHOICE" -sources = [ - "26 U.S.C. §704(a)", - "Treas. Reg. §1.704-1(b)(2)(iv)(b)", - "DECISION (owner, 2026-10-13: largest remainder; D-10, 2026-10-16: counted from the first close) (wanderbooks rules.md B-13)", -] -tags = ["partnership", "close", "rounding", "book", "us"] - -# A third each can't be written as decimal shares summing to one, so the -# members hold 33.33%, 33.33% and 33.34%. Twelve months, each closed to -# them: 100.00, 0.01, 0.02, 33.33, -0.05, 0.10, 1.00, 0.07, 0.01, 12.34, -# -5.00, 0.02 (141.85 in all). -# -# By hand, from the rule (the running total, its exact shares, the floors -# to the cent, then the left-over cents to the largest remainders): -# January, 100.00: 33.33 / 33.33 / 33.34 exactly. -# February, running 100.01: 33.333333 / 33.333333 / 33.343334; floors -# 33.33 / 33.33 / 33.34 leave a cent for the largest remainder, C's -# .003334: 33.33 / 33.33 / 33.35, so February gives C 0.01. -# March, running 100.03: 33.339999 / 33.339999 / 33.350002; floors 33.33 -# / 33.33 / 33.35 leave two cents for A's and B's .009999: 33.34 / -# 33.34 / 33.35, so March gives A 0.01 and B 0.01. -# November, running 141.83: 47.271939 / 47.271939 / 47.286122; floors -# 47.27 / 47.27 / 47.28 leave a cent for C's .006122: 47.27 / 47.27 / -# 47.29. -# December, running 141.85: 47.278605 / 47.278605 / 47.29279; floors -# 47.27 / 47.27 / 47.29 leave two cents for A's and B's .008605: 47.28 -# / 47.28 / 47.29, so December gives A 0.01 and B 0.01. - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "largest-remainder" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital, A" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital, B" -type = "equity" - -[[setup.accounts]] -code = "3002" -name = "Capital, C" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" - -[[setup.accounts]] -code = "6000" -name = "Fees" -type = "expense" - -[[setup.members]] -name = "A" -capital_account = "3000" -share = "0.3333" - -[[setup.members]] -name = "B" -capital_account = "3001" -share = "0.3333" - -[[setup.members]] -name = "C" -capital_account = "3002" -share = "0.3334" - -[[events]] # event 1 -date = "2026-01-10" -description = "January's sales" -lines = [ - { account = "1000", side = "debit", amount = "100.00" }, - { account = "4000", side = "credit", amount = "100.00" }, -] - -[[events]] # event 2 -date = "2026-01-31" -description = "Close January to the members" -close = { start = "2026-01-01", to = "members" } - -[[events]] # event 3 -date = "2026-02-10" -description = "February's sales" -lines = [ - { account = "1000", side = "debit", amount = "0.01" }, - { account = "4000", side = "credit", amount = "0.01" }, -] - -[[events]] # event 4 -date = "2026-02-28" -description = "Close February to the members" -close = { start = "2026-02-01", to = "members" } - -[[events]] # event 5 -date = "2026-03-10" -description = "March's sales" -lines = [ - { account = "1000", side = "debit", amount = "0.02" }, - { account = "4000", side = "credit", amount = "0.02" }, -] - -[[events]] # event 6 -date = "2026-03-31" -description = "Close March to the members" -close = { start = "2026-03-01", to = "members" } - -[[events]] # event 7 -date = "2026-04-10" -description = "April's sales" -lines = [ - { account = "1000", side = "debit", amount = "33.33" }, - { account = "4000", side = "credit", amount = "33.33" }, -] - -[[events]] # event 8 -date = "2026-04-30" -description = "Close April to the members" -close = { start = "2026-04-01", to = "members" } - -[[events]] # event 9 -date = "2026-05-10" -description = "May's fees" -lines = [ - { account = "6000", side = "debit", amount = "0.05" }, - { account = "1000", side = "credit", amount = "0.05" }, -] - -[[events]] # event 10 -date = "2026-05-31" -description = "Close May to the members" -close = { start = "2026-05-01", to = "members" } - -[[events]] # event 11 -date = "2026-06-10" -description = "June's sales" -lines = [ - { account = "1000", side = "debit", amount = "0.10" }, - { account = "4000", side = "credit", amount = "0.10" }, -] - -[[events]] # event 12 -date = "2026-06-30" -description = "Close June to the members" -close = { start = "2026-06-01", to = "members" } - -[[events]] # event 13 -date = "2026-07-10" -description = "July's sales" -lines = [ - { account = "1000", side = "debit", amount = "1.00" }, - { account = "4000", side = "credit", amount = "1.00" }, -] - -[[events]] # event 14 -date = "2026-07-31" -description = "Close July to the members" -close = { start = "2026-07-01", to = "members" } - -[[events]] # event 15 -date = "2026-08-10" -description = "August's sales" -lines = [ - { account = "1000", side = "debit", amount = "0.07" }, - { account = "4000", side = "credit", amount = "0.07" }, -] - -[[events]] # event 16 -date = "2026-08-31" -description = "Close August to the members" -close = { start = "2026-08-01", to = "members" } - -[[events]] # event 17 -date = "2026-09-10" -description = "September's sales" -lines = [ - { account = "1000", side = "debit", amount = "0.01" }, - { account = "4000", side = "credit", amount = "0.01" }, -] - -[[events]] # event 18 -date = "2026-09-30" -description = "Close September to the members" -close = { start = "2026-09-01", to = "members" } - -[[events]] # event 19 -date = "2026-10-10" -description = "October's sales" -lines = [ - { account = "1000", side = "debit", amount = "12.34" }, - { account = "4000", side = "credit", amount = "12.34" }, -] - -[[events]] # event 20 -date = "2026-10-31" -description = "Close October to the members" -close = { start = "2026-10-01", to = "members" } - -[[events]] # event 21 -date = "2026-11-10" -description = "November's fees" -lines = [ - { account = "6000", side = "debit", amount = "5.00" }, - { account = "1000", side = "credit", amount = "5.00" }, -] - -[[events]] # event 22 -date = "2026-11-30" -description = "Close November to the members" -close = { start = "2026-11-01", to = "members" } - -[[events]] # event 23 -date = "2026-12-10" -description = "December's sales" -lines = [ - { account = "1000", side = "debit", amount = "0.02" }, - { account = "4000", side = "credit", amount = "0.02" }, -] - -[[events]] # event 24 -date = "2026-12-31" -description = "Close December to the members" -close = { start = "2026-12-01", to = "members" } - -[[expect]] -query = "entry" -entry = 2 - -[expect.values] -"4000.debit" = "100.00" -"3000.credit" = "33.33" -"3001.credit" = "33.33" -"3002.credit" = "33.34" - -[[expect]] -query = "entry" -entry = 4 - -[expect.values] -"4000.debit" = "0.01" -"3002.credit" = "0.01" - -[[expect]] -query = "entry" -entry = 6 - -[expect.values] -"4000.debit" = "0.02" -"3000.credit" = "0.01" -"3001.credit" = "0.01" - -[[expect]] -query = "entry" -entry = 24 - -[expect.values] -"4000.debit" = "0.02" -"3000.credit" = "0.01" -"3001.credit" = "0.01" - -[[expect]] -query = "balances" -as_of = "2026-11-30" - -[expect.values] -"3000" = "47.27" -"3001" = "47.27" -"3002" = "47.29" - -[[expect]] -query = "balances" -as_of = "2026-12-31" - -[expect.values] -"3000" = "47.28" -"3001" = "47.28" -"3002" = "47.29" diff --git a/bookspec/examples/members-close.true-up-of-a-per-close-split.toml b/bookspec/examples/members-close.true-up-of-a-per-close-split.toml deleted file mode 100644 index 7ad9f22..0000000 --- a/bookspec/examples/members-close.true-up-of-a-per-close-split.toml +++ /dev/null @@ -1,160 +0,0 @@ -id = "members-close.true-up-of-a-per-close-split" -title = "Closes rounded one at a time, trued up by the first cumulative close" -rule = "When earlier closes split each close's net on its own, the first cumulative close that posts an entry MUST give each member their split of everything closed to the members, less what the earlier closes gave them, so it trues up the rounding they left; until it posts, the statement of members' equity MUST show the capital those closes posted, not the pending true-up." -status = "CHOICE" -sources = [ - "Treas. Reg. §1.704-1(b)(2)(iv)(b)", - "DECISION D-10 (owner, 2026-10-16): members closes counted from the first close, so a later close carries any earlier rounding as a true-up (wanderbooks rules.md B-13)", -] -tags = ["partnership", "close", "rounding", "statement", "book", "us"] - -# Books migrated from an engine that rounded each close on its own: three -# months of one cent at 50/50, each tie to A. April's cent is closed by the -# cumulative rule, counting from the first close. -# -# By hand, from the rule: -# January to March, each close on its own: 0.005 each, the tie to A, so -# A 0.03, B 0.00 (exact: 0.015 each). -# As of Apr 30, April's cent not yet closed: the split of everything, -# 0.04, is 0.02 / 0.02; of the closed 0.03, 0.02 / 0.01 (the tie to -# A); the unclosed cent is B's (0.02 − 0.01): A 0.03, B 0.01. The -# rounding the earlier closes left (A 0.01 ahead) shows only once -# posted. -# April's close: the running 0.04 is 0.02 / 0.02, less what was given -# (0.03 / 0.00): A −0.01, B +0.02. The entry: Dr 4000 0.01, Dr 3000 -# 0.01, Cr 3001 0.02. As of Apr 30: A 0.02, B 0.02, each exact; as of -# Mar 31, still A 0.03, B 0.00. - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "largest-remainder" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital, A" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital, B" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" - -[[setup.members]] -name = "A" -capital_account = "3000" -share = "0.5" - -[[setup.members]] -name = "B" -capital_account = "3001" -share = "0.5" - -[[events]] # event 1 -date = "2026-01-10" -description = "January's sale" -lines = [ - { account = "1000", side = "debit", amount = "0.01" }, - { account = "4000", side = "credit", amount = "0.01" }, -] - -[[events]] # event 2 -date = "2026-01-31" -description = "Close January, rounded on its own" -close = { start = "2026-01-01", to = "members", rounding = "per-close" } - -[[events]] # event 3 -date = "2026-02-10" -description = "February's sale" -lines = [ - { account = "1000", side = "debit", amount = "0.01" }, - { account = "4000", side = "credit", amount = "0.01" }, -] - -[[events]] # event 4 -date = "2026-02-28" -description = "Close February, rounded on its own" -close = { start = "2026-02-01", to = "members", rounding = "per-close" } - -[[events]] # event 5 -date = "2026-03-10" -description = "March's sale" -lines = [ - { account = "1000", side = "debit", amount = "0.01" }, - { account = "4000", side = "credit", amount = "0.01" }, -] - -[[events]] # event 6 -date = "2026-03-31" -description = "Close March, rounded on its own" -close = { start = "2026-03-01", to = "members", rounding = "per-close" } - -[[events]] # event 7 -date = "2026-04-10" -description = "April's sale" -lines = [ - { account = "1000", side = "debit", amount = "0.01" }, - { account = "4000", side = "credit", amount = "0.01" }, -] - -[[events]] # event 8 -date = "2026-04-30" -description = "Close April, cumulatively" -close = { start = "2026-04-01", to = "members" } - -[[expect]] -query = "balances" -as_of = "2026-03-31" -after = 6 - -[expect.values] -"3000" = "0.03" -"3001" = "0.00" - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-04-30" -after = 7 - -[expect.values] -"A.ending" = "0.03" -"B.ending" = "0.01" - -[[expect]] -query = "entry" -entry = 8 - -[expect.values] -"4000.debit" = "0.01" -"3000.debit" = "0.01" -"3001.credit" = "0.02" - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-04-30" - -[expect.values] -"A.ending" = "0.02" -"B.ending" = "0.02" - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-03-31" - -[expect.values] -"A.ending" = "0.03" -"B.ending" = "0.00" diff --git a/bookspec/examples/p3-party-accrual-counts-once.toml b/bookspec/examples/p3-party-accrual-counts-once.toml deleted file mode 100644 index 8d8d099..0000000 --- a/bookspec/examples/p3-party-accrual-counts-once.toml +++ /dev/null @@ -1,134 +0,0 @@ -# P3 defect 24 (found in design; wanderbooks plan/served.md P3 "Settled in -# design"): party totals treated every liability but payables and payroll -# as money, so an accrual through another liability counted when it was -# booked and again when it was settled. Regression test: ledger-core -# tests/spec_party_totals.rs -# defect24_an_accrual_through_another_liability_counts_once. The shape: -# bookspec/README.md. - -id = "p3-party-accrual-counts-once" -title = "An accrual through a liability that isn't money counts once, when it is settled" -rule = """ -Money is cash, the company's cards, the members' financing and loans. -Every other liability (accrued expenses, deferred revenue) is a promise: -an entry through it moves nothing with the party until it is settled in -money. -""" -status = "CHOICE" -sources = [ - "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay is not payment (wanderbooks docs/spec/sources/helvering-v-price-1940.md)", - "DECISION (owner, 2026-10-20): money is cash, cards, the members' financing and loans (wanderbooks rules.md B-35 item 5)", -] -tags = ["parties", "accruals", "deferred-revenue", "money"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "2200" -name = "Accrued Expenses" -type = "liability" - -[[setup.accounts]] -code = "2400" -name = "Deferred Revenue" -type = "liability" - -[[setup.accounts]] -code = "4000" -name = "Revenue" -type = "revenue" - -[[setup.accounts]] -code = "6050" -name = "Domain & Hosting" -type = "expense" - -[[setup.parties]] -name = "Northwind Filings" -roles = ["vendor"] - -[[setup.parties]] -name = "Riley Chen" -roles = ["customer"] - -[[events]] -date = "2026-03-31" -description = "Hosting accrued" -parties = [{ party = "Northwind Filings" }] -lines = [ - { account = "6050", side = "debit", amount = "300.00" }, - { account = "2200", side = "credit", amount = "300.00" }, -] - -[[events]] -date = "2026-04-15" -description = "Hosting paid" -parties = [{ party = "Northwind Filings" }] -lines = [ - { account = "2200", side = "debit", amount = "300.00" }, - { account = "1000", side = "credit", amount = "300.00" }, -] - -[[events]] -date = "2026-01-02" -description = "A year paid ahead" -parties = [{ party = "Riley Chen" }] -lines = [ - { account = "1000", side = "debit", amount = "1200.00" }, - { account = "2400", side = "credit", amount = "1200.00" }, -] - -[[events]] -date = "2026-01-31" -description = "January earned" -parties = [{ party = "Riley Chen" }] -lines = [ - { account = "2400", side = "debit", amount = "100.00" }, - { account = "4000", side = "credit", amount = "100.00" }, -] - -[[expect]] -query = "party_part" -entry = 1 -party = "Northwind Filings" -values = { resolved = "300.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -# Before P3: 600.00. -[[expect]] -query = "party_totals" -party = "Northwind Filings" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "300.00", received = "0.00" } - -[[expect]] -query = "party_part" -entry = 4 -party = "Riley Chen" -values = { resolved = "100.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -# Before P3: 1,300.00. -[[expect]] -query = "party_totals" -party = "Riley Chen" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "1200.00" } diff --git a/bookspec/examples/p3-party-distribution-still-owed.toml b/bookspec/examples/p3-party-distribution-still-owed.toml deleted file mode 100644 index 2b398a6..0000000 --- a/bookspec/examples/p3-party-distribution-still-owed.toml +++ /dev/null @@ -1,201 +0,0 @@ -# P3 defect 24 in a member's entry (found in review, 2026-10-21): a -# distribution declared to a liability that isn't money counted as paid -# when declared and again when paid. A distribution's cash is the money it -# paid, its in kind the property it gave; the rest is still owed. Regression -# tests: ledger-core tests/spec_party_totals.rs -# defect24_a_distribution_declared_to_a_payable_counts_once_when_paid and -# a_distribution_of_money_and_depreciated_property_splits_by_what_moved. -# The shape: bookspec/README.md. - -id = "p3-party-distribution-still-owed" -title = "A distribution counts the money it paid and the property it gave; what is still owed counts when paid" -rule = """ -An entry naming only members that moves their capital counts what moved -it. Of a distribution, the cash is the money it paid (through the cash -accounts, a card, the members' financing or a loan), the in kind the -property it gave up (at what the books carried it), and the rest, credited -to a liability that isn't money for the entry (distributions payable, or -what the members are owed when they are named its creditors), is a -promise: it counts nothing until it is paid, then once, as paid. The -statement of members' equity counts a distribution when it is declared, -so party totals catch up with it when it is paid. -""" -status = "CHOICE" -sources = [ - "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay is not payment (wanderbooks docs/spec/sources/helvering-v-price-1940.md)", - "Treas. Reg. §1.704-1(b)(2)(iv)(b)(4)-(5): a partner's capital account falls by the money and the property distributed (docs/spec/sources/irs-capital-accounts.md)", - "DECISION (owner, 2026-10-20): a member's entry counts what moved their capital; money is cash, cards, the members' financing and loans (rules.md B-35)", - "DECISION (owner, 2026-10-20): property distributed counts at the books' carrying amount, not its fair market value (wanderbooks rules.md B-35 item 3)", -] -tags = ["parties", "members", "distributions", "promises", "in-kind", "money"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation — Equipment" -type = "asset" - -[[setup.accounts]] -code = "2150" -name = "Distributions Payable" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital — Tobin Harlow" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital — Morgan Ellis" -type = "equity" - -[[setup.accounts]] -code = "3100" -name = "Distributions — Tobin Harlow" -type = "equity" - -[[setup.parties]] -name = "Tobin Harlow" -roles = ["member"] - -[[setup.parties]] -name = "Morgan Ellis" -roles = ["member"] - -[[setup.members]] -name = "Tobin Harlow" -share = "0.5" -capital_account = "3000" -distributions_account = "3100" - -[[setup.members]] -name = "Morgan Ellis" -share = "0.5" -capital_account = "3001" - -# 1. A distribution declared to Tobin: still owed, counts nothing. -[[events]] -date = "2026-06-30" -description = "Distribution declared" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "3100", side = "debit", amount = "200.00" }, - { account = "2150", side = "credit", amount = "200.00" }, -] - -# 2. It is paid: paid, money, once. -[[events]] -date = "2026-07-15" -description = "Distribution paid" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "2150", side = "debit", amount = "200.00" }, - { account = "1000", side = "credit", amount = "200.00" }, -] - -# 3. Declared and partly paid at once: the money now, the rest when paid. -[[events]] -date = "2026-08-31" -description = "Distribution, part paid" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "3100", side = "debit", amount = "300.00" }, - { account = "1000", side = "credit", amount = "100.00" }, - { account = "2150", side = "credit", amount = "200.00" }, -] - -# 4. Equipment carried at 1,000.00 less 300.00 depreciated, with 100.00 in -# cash, distributed to Morgan: 100.00 cash, 700.00 in kind. -[[events]] -date = "2026-09-01" -description = "Equipment and cash distributed" -parties = [{ party = "Morgan Ellis" }] -lines = [ - { account = "3001", side = "debit", amount = "800.00" }, - { account = "1510", side = "debit", amount = "300.00" }, - { account = "1500", side = "credit", amount = "1000.00" }, - { account = "1000", side = "credit", amount = "100.00" }, -] - -[[expect]] -query = "party_part" -entry = 1 -party = "Tobin Harlow" -values = { resolved = "200.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 3 -party = "Tobin Harlow" -values = { resolved = "300.00", direction = "paid", cash = "100.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 4 -party = "Morgan Ellis" -values = { resolved = "800.00", direction = "paid", cash = "100.00", in_kind = "700.00" } - -# Before the review: 700.00 (event 1's 200.00 and the 200.00 still owed -# on event 3 counted as paid, and event 2 paid the first again). -[[expect]] -query = "party_totals" -party = "Tobin Harlow" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "300.00", received = "0.00" } - -# Through June: declared, not yet paid. The statement counts it already. -[[expect]] -query = "party_totals" -party = "Tobin Harlow" -basis = "all" -from = "2026-01-01" -to = "2026-06-30" -values = { paid = "0.00", received = "0.00" } - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-06-30" -values = { "Tobin Harlow.contributions" = "0.00", "Tobin Harlow.distributions" = "200.00" } - -# Before the review: 0.00 cash, 800.00 in kind. -[[expect]] -query = "party_totals" -party = "Morgan Ellis" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "100.00", received = "0.00" } - -[[expect]] -query = "party_totals" -party = "Morgan Ellis" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "800.00", received = "0.00" } - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-12-31" -values = { "Morgan Ellis.contributions" = "0.00", "Morgan Ellis.distributions" = "800.00" } diff --git a/bookspec/examples/p3-party-gift-card-is-a-promise.toml b/bookspec/examples/p3-party-gift-card-is-a-promise.toml deleted file mode 100644 index a27b5d2..0000000 --- a/bookspec/examples/p3-party-gift-card-is-a-promise.toml +++ /dev/null @@ -1,94 +0,0 @@ -# P3 defect 24 and the card rule (found in review, 2026-10-21): with no -# business card recorded, the company's cards are the liabilities whose -# names say "card", so a gift card the company sold (deferred revenue) -# read as money: received when sold and again when redeemed. Regression -# test: ledger-core tests/spec_party_totals.rs -# defect24_a_gift_card_sold_is_received_once. -# The shape: bookspec/README.md. - -id = "p3-party-gift-card-is-a-promise" -title = "A gift card the company sold is a promise, not a card it pays with" -rule = """ -The company's cards are the business cards its books record as payment -methods; while they record none, the liability accounts whose names say -"card", but not "gift card": gift cards the company sold are revenue it -owes its customers in service (deferred revenue), a promise. Selling one -is money received from the buyer; redeeming it moves no money and counts -nothing. -""" -status = "CHOICE" -sources = [ - "DECISION (owner, 2026-10-20): money is cash, cards, the members' financing and loans; every other liability is a promise, counted when settled (wanderbooks rules.md B-35 item 5)", - "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay is not payment (docs/spec/sources/helvering-v-price-1940.md)", -] -tags = ["parties", "cards", "deferred-revenue", "money"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "2050" -name = "Business Card" -type = "liability" - -[[setup.accounts]] -code = "2410" -name = "Gift Card Liability" -type = "liability" - -[[setup.accounts]] -code = "4000" -name = "Revenue" -type = "revenue" - -[[setup.parties]] -name = "Riley Chen" -roles = ["customer"] - -[[events]] -date = "2026-03-01" -description = "Gift card sold" -parties = [{ party = "Riley Chen" }] -lines = [ - { account = "1000", side = "debit", amount = "100.00" }, - { account = "2410", side = "credit", amount = "100.00" }, -] - -[[events]] -date = "2026-03-05" -description = "Gift card redeemed" -parties = [{ party = "Riley Chen" }] -lines = [ - { account = "2410", side = "debit", amount = "100.00" }, - { account = "4000", side = "credit", amount = "100.00" }, -] - -[[expect]] -query = "party_part" -entry = 1 -party = "Riley Chen" -values = { resolved = "-100.00", direction = "received", cash = "-100.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 2 -party = "Riley Chen" -values = { resolved = "100.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -# Before the review: 200.00. -[[expect]] -query = "party_totals" -party = "Riley Chen" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "100.00" } diff --git a/bookspec/examples/p3-party-members-equity-and-k1.toml b/bookspec/examples/p3-party-members-equity-and-k1.toml deleted file mode 100644 index f9b20ee..0000000 --- a/bookspec/examples/p3-party-members-equity-and-k1.toml +++ /dev/null @@ -1,219 +0,0 @@ -# P3 defect 14, settled in design (wanderbooks plan/served.md P3): party -# totals count a member's entry by what moved their capital, so basis all -# is the statement of members' equity and basis cash means what it means -# on the K-1 worksheet. Regression tests: ledger-core -# tests/spec_party_totals.rs a_members_mixed_entry_splits_as_the_k1_does, -# basis_all_is_the_members_equity_statement_and_cash_the_k1s, and the -# property member_totals_are_the_members_equity_statement. The shape: -# bookspec/README.md. - -id = "p3-party-members-equity-and-k1" -title = "A member's totals are their capital account: basis all the statement of members' equity, basis cash the K-1's cash" -rule = """ -An entry naming only members that moves their equity counts what moved -their capital: a contribution received, a distribution paid. The -property in it (asset accounts that are neither cash nor receivables) is -in kind, shared among the members in proportion to their parts, to the -cent, leftover cents by largest remainder, ties to the member listed -first; the rest is cash, as the K-1 worksheet counts it: money, or what a -member paid for the company (an expense booked straight to capital, a -debt to them converted to capital). -""" -status = "CHOICE" -sources = [ - "Treas. Reg. §1.704-1(b)(2)(iv)(b)(1)-(2), (c)(1): money and property contributed; a partnership liability a partner pays is money contributed (wanderbooks docs/spec/sources/irs-capital-accounts.md)", - "Partner's Instructions for Schedule K-1 (Form 1065), Item L: the capital contributed during the year (docs/spec/sources/irs-capital-accounts.md)", - "DECISION (owner, 2026-10-15): what a member paid for the company is cash on the K-1 (wanderbooks ledger-core k1 INVARIANT 3); DECISION (owner, 2026-10-20): a member's entry counts what moved their capital (rules.md B-35)", -] -tags = ["parties", "members", "k1", "members-equity", "in-kind", "rounding"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "largest-remainder" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital — Tobin Harlow" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital — Morgan Ellis" -type = "equity" - -[[setup.accounts]] -code = "6050" -name = "Domain & Hosting" -type = "expense" - -[[setup.parties]] -name = "Tobin Harlow" -roles = ["member"] - -[[setup.parties]] -name = "Morgan Ellis" -roles = ["member"] - -[[setup.members]] -name = "Tobin Harlow" -share = "0.5" -capital_account = "3000" - -[[setup.members]] -name = "Morgan Ellis" -share = "0.5" -capital_account = "3001" - -# 1. A joint contribution of cash and property (the K-1 worksheet's -# SCENARIO 3): 500.00 each, of it 300.00 cash and 200.00 in kind. -[[events]] -date = "2026-02-01" -description = "Joint contribution, cash and equipment" -parties = [{ party = "Morgan Ellis" }, { party = "Tobin Harlow" }] -lines = [ - { account = "1000", side = "debit", amount = "600.00" }, - { account = "1500", side = "debit", amount = "400.00" }, - { account = "3000", side = "credit", amount = "500.00" }, - { account = "3001", side = "credit", amount = "500.00" }, -] - -# 2. One cent of property shared by two equal parts: the cent goes to the -# member listed first in the company's members (Tobin), whatever the -# order the entry names them in. -[[events]] -date = "2026-02-02" -description = "A cent of property" -parties = [{ party = "Morgan Ellis" }, { party = "Tobin Harlow" }] -lines = [ - { account = "1000", side = "debit", amount = "0.99" }, - { account = "1500", side = "debit", amount = "0.01" }, - { account = "3000", side = "credit", amount = "0.50" }, - { account = "3001", side = "credit", amount = "0.50" }, -] - -# 3. An expense Morgan paid, booked straight to capital: cash. -[[events]] -date = "2026-02-03" -description = "Hosting Morgan paid" -parties = [{ party = "Morgan Ellis" }] -lines = [ - { account = "6050", side = "debit", amount = "50.00" }, - { account = "3001", side = "credit", amount = "50.00" }, -] - -# 4. What the company owed Tobin, converted to capital: cash. -[[events]] -date = "2026-02-04" -description = "Advance converted" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "2100", side = "debit", amount = "70.00" }, - { account = "3000", side = "credit", amount = "70.00" }, -] - -[[expect]] -query = "party_part" -entry = 1 -party = "Tobin Harlow" -values = { resolved = "-500.00", direction = "received", cash = "-300.00", in_kind = "-200.00" } - -[[expect]] -query = "party_part" -entry = 2 -party = "Tobin Harlow" -values = { resolved = "-0.50", direction = "received", cash = "-0.49", in_kind = "-0.01" } - -[[expect]] -query = "party_part" -entry = 2 -party = "Morgan Ellis" -values = { resolved = "-0.50", direction = "received", cash = "-0.50", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 3 -party = "Morgan Ellis" -values = { resolved = "-50.00", direction = "received", cash = "-50.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 4 -party = "Tobin Harlow" -values = { resolved = "-70.00", direction = "received", cash = "-70.00", in_kind = "0.00" } - -# Tobin: cash 300.00 + 0.49 + 70.00; in kind 200.00 + 0.01. -[[expect]] -query = "party_totals" -party = "Tobin Harlow" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "370.49" } - -[[expect]] -query = "party_totals" -party = "Tobin Harlow" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "570.50" } - -# Morgan: cash 300.00 + 0.50 + 50.00; in kind 200.00. -[[expect]] -query = "party_totals" -party = "Morgan Ellis" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "350.50" } - -[[expect]] -query = "party_totals" -party = "Morgan Ellis" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "550.50" } - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-12-31" -values = { "Tobin Harlow.contributions" = "570.50", "Tobin Harlow.distributions" = "0.00" } - -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-12-31" -values = { "Morgan Ellis.contributions" = "550.50", "Morgan Ellis.distributions" = "0.00" } - -[[expect]] -query = "k1" -from = "2026-01-01" -to = "2026-12-31" -values = { "Tobin Harlow.contributions_cash" = "370.49", "Tobin Harlow.contributions_property" = "200.01" } - -[[expect]] -query = "k1" -from = "2026-01-01" -to = "2026-12-31" -values = { "Morgan Ellis.contributions_cash" = "350.50", "Morgan Ellis.contributions_property" = "200.00" } diff --git a/bookspec/examples/p3-party-own-liability-never-pays.toml b/bookspec/examples/p3-party-own-liability-never-pays.toml deleted file mode 100644 index e08a90b..0000000 --- a/bookspec/examples/p3-party-own-liability-never-pays.toml +++ /dev/null @@ -1,240 +0,0 @@ -# P3 defect 24 and wanderbooks rules.md B-35 item 6: a credit to a -# liability the company owes a party itself never counts as paying that -# party. Regression test: ledger-core tests/spec_party_totals.rs -# defect24_a_credit_to_a_partys_own_liability_never_pays_it. The shape: -# bookspec/README.md. - -id = "p3-party-own-liability-never-pays" -title = "A credit to what the company owes a party itself never pays it" -rule = """ -A party may be named the creditor of the liabilities the company owes it -itself: a card's issuer, a loan's lender, each member for what the -members advanced. For an entry naming it, a credit to one is a promise, -not a payment: a fee on the issuer's own card, equipment bought on the -seller's own note, a distribution credited to what the members are owed. -It counts when it is paid. Property a member contributes subject to a -loan counts at its value net of the loan, in kind. -""" -status = "CHOICE" -sources = [ - "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay, even a secured one, is not payment (wanderbooks docs/spec/sources/helvering-v-price-1940.md)", - "Treas. Reg. §1.704-1(b)(2)(iv)(b)(2): property contributed, net of liabilities the partnership takes it subject to (docs/spec/sources/irs-capital-accounts.md)", - "DECISION (owner, 2026-10-20): a liability credit never counts as paying that liability's own creditor (rules.md B-35)", -] -tags = ["parties", "creditors", "cards", "notes", "distributions", "members"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] -financing_accounts = ["2100", "2500"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "2050" -name = "Business Card" -type = "liability" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "2500" -name = "Note Payable" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital — Tobin Harlow" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital — Morgan Ellis" -type = "equity" - -[[setup.accounts]] -code = "6400" -name = "Bank Fees" -type = "expense" - -[[setup.parties]] -name = "Harbor Bank" -roles = ["financial_institution"] -creditor_of = ["2050"] - -[[setup.parties]] -name = "Lakeside Supply" -roles = [] -creditor_of = ["2500"] - -[[setup.parties]] -name = "Tobin Harlow" -roles = ["member"] -creditor_of = ["2100"] - -[[setup.parties]] -name = "Morgan Ellis" -roles = ["member"] -creditor_of = ["2100"] - -[[setup.members]] -name = "Tobin Harlow" -share = "0.5" -capital_account = "3000" - -[[setup.members]] -name = "Morgan Ellis" -share = "0.5" -capital_account = "3001" - -# 1. The issuer's fee on its own card: neither. -[[events]] -date = "2026-05-01" -description = "Card fee" -parties = [{ party = "Harbor Bank" }] -lines = [ - { account = "6400", side = "debit", amount = "25.00" }, - { account = "2050", side = "credit", amount = "25.00" }, -] - -# 2. The card paid off: paid to the issuer. -[[events]] -date = "2026-05-20" -description = "Card payment" -parties = [{ party = "Harbor Bank" }] -lines = [ - { account = "2050", side = "debit", amount = "125.00" }, - { account = "1000", side = "credit", amount = "125.00" }, -] - -# 3. A distribution to Tobin credited to what the members are owed: -# still owed, counts as nothing. -[[events]] -date = "2026-06-30" -description = "Distribution declared" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "3000", side = "debit", amount = "200.00" }, - { account = "2100", side = "credit", amount = "200.00" }, -] - -# 4. It is paid: paid, money. -[[events]] -date = "2026-07-15" -description = "Distribution paid" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "2100", side = "debit", amount = "200.00" }, - { account = "1000", side = "credit", amount = "200.00" }, -] - -# 5. Equipment on the seller's own note: neither. -[[events]] -date = "2026-08-01" -description = "Equipment on a note" -parties = [{ party = "Lakeside Supply" }] -lines = [ - { account = "1500", side = "debit", amount = "900.00" }, - { account = "2500", side = "credit", amount = "900.00" }, -] - -# 6. A payment on the note: paid. -[[events]] -date = "2026-09-01" -description = "Note payment" -parties = [{ party = "Lakeside Supply" }] -lines = [ - { account = "2500", side = "debit", amount = "300.00" }, - { account = "1000", side = "credit", amount = "300.00" }, -] - -# 7. Equipment worth 1,000.00 Morgan contributes subject to 200.00 owed -# on the note: 800.00 received, in kind (before P3: "paid 200.00"). -[[events]] -date = "2026-09-15" -description = "Equipment contributed with its loan" -parties = [{ party = "Morgan Ellis" }] -lines = [ - { account = "1500", side = "debit", amount = "1000.00" }, - { account = "2500", side = "credit", amount = "200.00" }, - { account = "3001", side = "credit", amount = "800.00" }, -] - -[[expect]] -query = "party_part" -entry = 1 -party = "Harbor Bank" -values = { resolved = "-25.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -# Before naming the issuer the card's creditor: 150.00. -[[expect]] -query = "party_totals" -party = "Harbor Bank" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "125.00", received = "0.00" } - -[[expect]] -query = "party_part" -entry = 3 -party = "Tobin Harlow" -values = { resolved = "200.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -[[expect]] -query = "party_totals" -party = "Tobin Harlow" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "200.00", received = "0.00" } - -[[expect]] -query = "party_part" -entry = 5 -party = "Lakeside Supply" -values = { resolved = "-900.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -[[expect]] -query = "party_totals" -party = "Lakeside Supply" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "300.00", received = "0.00" } - -[[expect]] -query = "party_part" -entry = 7 -party = "Morgan Ellis" -values = { resolved = "-800.00", direction = "received", cash = "0.00", in_kind = "-800.00" } - -[[expect]] -query = "party_totals" -party = "Morgan Ellis" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "0.00" } - -[[expect]] -query = "party_totals" -party = "Morgan Ellis" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "800.00" } diff --git a/bookspec/examples/p3-party-reversed-card-payment.toml b/bookspec/examples/p3-party-reversed-card-payment.toml deleted file mode 100644 index 45c91c2..0000000 --- a/bookspec/examples/p3-party-reversed-card-payment.toml +++ /dev/null @@ -1,100 +0,0 @@ -# P3 defect 14, the second bug found in design (wanderbooks P3 brief -# §1.4): a member pays the card from their own funds; reversing it read -# as a second payment. Regression test: ledger-core -# tests/spec_party_totals.rs -# defect14_a_reversed_member_card_payment_nets_to_zero. The shape: -# bookspec/README.md (`after = N` asks a query after event N). - -id = "p3-party-reversed-card-payment" -title = "A reversed payment of one liability by another nets to zero" -rule = """ -A reversal's part for each party is its original's negated, never read -from the reversal's own lines. A member paying the company's card from -their own funds (Dr card / Cr due to members), tagged with the card's -issuer, is paid to the issuer; its reversal takes that back, so the pair -nets to zero; reversing the reversal pays it again. -""" -status = "CHOICE" -sources = [ - "Pacioli, Summa (1494), Geijsbeek tr.: a mistake is corrected by the opposite entry (wanderbooks docs/spec/sources/pacioli-1494.md, rules.md B-9)", - "DECISION D-42 (owner, 2026-10-20): what a member pays for the company through the members' financing is money: cash borrowed and spent (wanderbooks plan/served.md; rules.md B-14 rule 11, B-35 items 5 and 8)", -] -tags = ["parties", "reversals", "liabilities"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "2050" -name = "Business Card" -type = "liability" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.parties]] -name = "Harbor Bank" -roles = ["financial_institution"] - -[[events]] -date = "2026-06-01" -description = "A member pays the card from their own funds" -parties = [{ party = "Harbor Bank" }] -lines = [ - { account = "2050", side = "debit", amount = "100.00" }, - { account = "2100", side = "credit", amount = "100.00" }, -] - -[[events]] -date = "2026-06-02" -description = "Reversed" -reverses = 1 - -[[events]] -date = "2026-06-03" -description = "The reversal reversed" -reverses = 2 - -[[expect]] -query = "party_part" -entry = 1 -party = "Harbor Bank" -values = { resolved = "100.00", direction = "paid", cash = "100.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 2 -party = "Harbor Bank" -values = { resolved = "-100.00", direction = "received", cash = "-100.00", in_kind = "0.00" } - -# After the reversal: nothing paid (before P3: 200.00, the reversal read -# as a second payment). -[[expect]] -query = "party_totals" -after = 2 -party = "Harbor Bank" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "0.00", received = "0.00" } - -[[expect]] -query = "party_totals" -after = 3 -party = "Harbor Bank" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "100.00", received = "0.00" } diff --git a/bookspec/examples/p3-party-signs-and-reversals.toml b/bookspec/examples/p3-party-signs-and-reversals.toml deleted file mode 100644 index 889a21c..0000000 --- a/bookspec/examples/p3-party-signs-and-reversals.toml +++ /dev/null @@ -1,292 +0,0 @@ -# P3 defect 14 (wanderbooks plan/served.md P3; audit findings F36, F37): -# a party's part of an entry is signed as a line on the party's own -# account, with its direction and what of it was money or property; a -# reversal is the exact opposite of what it reverses; party totals take a -# basis. Regression test: ledger-core tests/spec_party_totals.rs -# defect14_shares_take_the_partys_side_and_reversals_flip. The shape: -# bookspec/README.md. - -id = "p3-party-signs-and-reversals" -title = "A party's part of an entry is signed as its own account would show it, and a reversal is the exact opposite" -rule = """ -A party's part of an entry is debit-positive, as a line on the party's own -account would be: positive when the company paid it or gave it value, -negative when the company received from it. Each part has a direction -(paid, received, neither) and a cash and an in-kind amount. An entry -naming only members that moves equity counts what moved their capital, -the property in it in kind. A reversal's part is exactly its original's -negated. A bill or invoice moves nothing with the party yet (neither). -Party totals on basis cash count the cash parts, on basis all the cash -and in-kind parts, each on its root original's side. -""" -status = "CHOICE" -sources = [ - "Pacioli, Summa (1494), Geijsbeek tr., ch. 36: debit is what owes, credit what is owed (wanderbooks docs/spec/sources/pacioli-1494.md)", - "Treas. Reg. §1.704-1(b)(2)(iv)(b)(1)-(2), (4)-(5): a partner's capital account rises by money and property contributed, falls by money and property distributed (docs/spec/sources/irs-capital-accounts.md)", - "Helvering v. Price, 309 U.S. 409, 412-413 (1940): a promise to pay is not payment (docs/spec/sources/helvering-v-price-1940.md)", - "DECISION (owner, 2026-10-20): a member's entry counts what moved their capital (wanderbooks rules.md B-35)", - "DECISION D-42 (owner, 2026-10-20): what a member pays for the company through the members' financing is money: cash borrowed and spent (wanderbooks plan/served.md; rules.md B-14 rule 11, B-35 items 5 and 8)", -] -tags = ["parties", "signs", "reversals", "members", "in-kind", "basis"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] -financing_accounts = ["2100"] - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.accounts]] -code = "1200" -name = "Accounts Receivable" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "2000" -name = "Accounts Payable" -type = "liability" - -[[setup.accounts]] -code = "2100" -name = "Due to Members" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital — Tobin Harlow" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital — Morgan Ellis" -type = "equity" - -[[setup.accounts]] -code = "3999" -name = "Retained Earnings" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Revenue" -type = "revenue" - -[[setup.accounts]] -code = "6000" -name = "Organizational Costs" -type = "expense" - -[[setup.parties]] -name = "Tobin Harlow" -roles = ["member"] - -[[setup.parties]] -name = "Morgan Ellis" -roles = ["member"] - -[[setup.parties]] -name = "Northwind Filings" -roles = ["vendor"] - -[[setup.parties]] -name = "Riley Chen" -roles = ["customer"] - -[[setup.members]] -name = "Tobin Harlow" -share = "0.5" -capital_account = "3000" - -[[setup.members]] -name = "Morgan Ellis" -share = "0.5" -capital_account = "3001" - -# 1. Tobin pays the filing fee for the company (through the members' -# financing account), tagged with the vendor: paid to the vendor, money. -[[events]] -date = "2026-10-04" -description = "Filing fee, paid by Tobin" -parties = [{ party = "Northwind Filings" }] -lines = [ - { account = "6000", side = "debit", amount = "64.00" }, - { account = "2100", side = "credit", amount = "64.00" }, -] - -# 2. Tobin's cash contribution: received, money. -[[events]] -date = "2026-10-05" -description = "Tobin's contribution" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "1000", side = "debit", amount = "400.00" }, - { account = "3000", side = "credit", amount = "400.00" }, -] - -# 3. Reimbursing Tobin for the fee: paid, money (cash paying down what -# the company owed him). -[[events]] -date = "2026-10-08" -description = "Reimbursement" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "2100", side = "debit", amount = "64.00" }, - { account = "1000", side = "credit", amount = "64.00" }, -] - -# 4. A laptop Tobin contributes: received, in kind. -[[events]] -date = "2026-10-12" -description = "Laptop contributed" -parties = [{ party = "Tobin Harlow" }] -lines = [ - { account = "1500", side = "debit", amount = "1480.00" }, - { account = "3000", side = "credit", amount = "1480.00" }, -] - -# 5. Its reversal: the exact opposite, paid, in kind. -[[events]] -date = "2026-10-13" -description = "Laptop contribution reversed" -reverses = 4 - -# 6. The laptop again, half each, by amount. -[[events]] -date = "2026-10-14" -description = "Laptop contributed jointly" -parties = [{ party = "Tobin Harlow", amount = "740.00" }, { party = "Morgan Ellis", amount = "740.00" }] -lines = [ - { account = "1500", side = "debit", amount = "1480.00" }, - { account = "3000", side = "credit", amount = "740.00" }, - { account = "3001", side = "credit", amount = "740.00" }, -] - -# 7. A bill from the vendor (credit to payables, its own account): neither. -[[events]] -date = "2026-10-20" -description = "Bill" -older = true -parties = [{ party = "Northwind Filings" }] -lines = [ - { account = "6000", side = "debit", amount = "80.00" }, - { account = "2000", side = "credit", amount = "80.00" }, -] - -# 8. The bill reversed: the exact opposite, still neither. -[[events]] -date = "2026-10-21" -description = "Bill reversed" -reverses = 7 - -# 9. An invoice to the customer (a debit to receivables): neither. -[[events]] -date = "2026-10-20" -description = "Invoice" -older = true -parties = [{ party = "Riley Chen" }] -lines = [ - { account = "1200", side = "debit", amount = "120.00" }, - { account = "4000", side = "credit", amount = "120.00" }, -] - -[[expect]] -query = "party_part" -entry = 1 -party = "Northwind Filings" -values = { resolved = "64.00", direction = "paid", cash = "64.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 2 -party = "Tobin Harlow" -values = { resolved = "-400.00", direction = "received", cash = "-400.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 3 -party = "Tobin Harlow" -values = { resolved = "64.00", direction = "paid", cash = "64.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 4 -party = "Tobin Harlow" -values = { resolved = "-1480.00", direction = "received", cash = "0.00", in_kind = "-1480.00" } - -[[expect]] -query = "party_part" -entry = 5 -party = "Tobin Harlow" -values = { resolved = "1480.00", direction = "paid", cash = "0.00", in_kind = "1480.00" } - -[[expect]] -query = "party_part" -entry = 6 -party = "Morgan Ellis" -values = { resolved = "-740.00", direction = "received", cash = "0.00", in_kind = "-740.00" } - -[[expect]] -query = "party_part" -entry = 7 -party = "Northwind Filings" -values = { resolved = "-80.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 8 -party = "Northwind Filings" -values = { resolved = "80.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -[[expect]] -query = "party_part" -entry = 9 -party = "Riley Chen" -values = { resolved = "120.00", direction = "neither", cash = "0.00", in_kind = "0.00" } - -# Tobin: cash in 400.00 (event 2), paid back 64.00 (event 3); in kind -# 1,480.00 − 1,480.00 (events 4 and 5 net) + 740.00 (event 6). -[[expect]] -query = "party_totals" -party = "Tobin Harlow" -basis = "cash" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "64.00", received = "400.00" } - -[[expect]] -query = "party_totals" -party = "Tobin Harlow" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "64.00", received = "1140.00" } - -# The vendor: the fee a member paid for the company; the bill and its -# reversal count nothing. -[[expect]] -query = "party_totals" -party = "Northwind Filings" -basis = "all" -from = "2026-01-01" -to = "2026-12-31" -values = { paid = "64.00", received = "0.00" } - -# Basis all over Tobin's equity entries is the statement of members' -# equity: contributions 400.00 + 740.00 (events 4 and 5 cancel). -[[expect]] -query = "members_equity" -from = "2026-01-01" -to = "2026-12-31" -values = { "Tobin Harlow.contributions" = "1140.00", "Tobin Harlow.distributions" = "0.00" } - diff --git a/bookspec/examples/p3-party-tin-last4.toml b/bookspec/examples/p3-party-tin-last4.toml deleted file mode 100644 index 289dbef..0000000 --- a/bookspec/examples/p3-party-tin-last4.toml +++ /dev/null @@ -1,86 +0,0 @@ -# P3 defect 11 (audit finding F28): the directory showed a member's TIN as -# missing because it read W-9 facts only. Regression tests: ledger-core -# tests/spec_party_totals.rs -# defect11_a_partys_tin_last4_is_its_tin_facts_else_its_w9s; ledger-cli -# tests/spec_vault.rs defect11_the_directory_shows_a_members_tin_last4. -# The shape: bookspec/README.md. - -id = "p3-party-tin-last4" -title = "A party's TIN last four is its TIN facts', else its W-9's" -rule = """ -The books keep a taxpayer identification number truncated to its last -four digits. A party's last four is its TIN facts' (a member's SSN for -the K-1), else its W-9 facts'; the two never disagree. A TIN purged from -the vault keeps its last four in the books. A party with neither has -none. -""" -status = "CHOICE" -sources = [ - "Treas. Reg. §301.6109-4(a): a truncated TIN shows the last four digits (wanderbooks docs/spec/sources/irs-truncated-tin.md)", - "DECISION (owner, 2026-10-15): a party's TIN facts and W-9 facts never disagree (wanderbooks ledger-core parties INVARIANT 2a)", -] -tags = ["parties", "tin", "w9", "k1"] -# Nothing is posted: the facts are the directory's. -events = [] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Checking" -type = "asset" - -[[setup.parties]] -name = "Tobin Harlow" -roles = ["member"] -tin_last4 = "4321" - -[[setup.parties]] -name = "Riley Chen" -roles = ["contractor"] -w9_tin_last4 = "8765" - -[[setup.parties]] -name = "Morgan Ellis" -roles = ["member", "contractor"] -tin_last4 = "1111" -w9_tin_last4 = "1111" - -[[setup.parties]] -name = "Lakeside Supply" -roles = [] - -[[setup.parties]] -name = "Avery Quinn" -roles = ["member"] -tin_last4 = "2468" -tin_purged = true - -[[expect]] -query = "party_tin_last4" -party = "Tobin Harlow" -values = { tin_last4 = "4321" } - -[[expect]] -query = "party_tin_last4" -party = "Riley Chen" -values = { tin_last4 = "8765" } - -[[expect]] -query = "party_tin_last4" -party = "Morgan Ellis" -values = { tin_last4 = "1111" } - -[[expect]] -query = "party_tin_last4" -party = "Lakeside Supply" -values = { tin_last4 = "" } - -[[expect]] -query = "party_tin_last4" -party = "Avery Quinn" -values = { tin_last4 = "2468" } diff --git a/bookspec/examples/partnership-k1-tax-basis-capital.toml b/bookspec/examples/partnership-k1-tax-basis-capital.toml deleted file mode 100644 index be92753..0000000 --- a/bookspec/examples/partnership-k1-tax-basis-capital.toml +++ /dev/null @@ -1,173 +0,0 @@ -# P3 (D-41): the K-1 worksheet reports tax-basis capital beside book capital. -id = "partnership-k1-tax-basis-capital" -title = "Schedule K-1 Item L on the tax basis: property contributed at its adjusted basis" -rule = "Each partner's capital account is reported on the tax basis: capital contributed is cash plus the adjusted tax basis of property contributed; the share of income is as figured for tax; distributions are cash plus the adjusted tax basis of property distributed. Book capital (property at fair market value) differs by the partner's share of the built-in gain or loss at contribution, and by every book-tax difference after; a figure the books cannot know (tax depreciation) is left to the preparer, never guessed." -status = "AUTHORITY" -sources = [ - "Instructions for Form 1065 (2025), Item L, Tax-basis method: Capital contributed during the year; Current year net income (loss); Withdrawals and distributions", - "Instructions for Form 1065 (2020), Item L: 'you must use the tax basis method this year'", - "26 U.S.C. §723", - "Treas. Reg. §1.704-3(a)(3)(ii)", - "DECISION D-41 (owner, 2026-10-20): the K-1 worksheet reports tax-basis capital beside the book figures (wanderbooks plan/served.md; rules.md B-36)", -] -tags = ["partnerships", "k1", "tax-basis", "capital"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "largest-remainder" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital, Tobin Harlow" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital, Morgan Ellis" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.members]] -name = "Tobin Harlow" -capital_account = "3000" -share = "0.5" - -[[setup.members]] -name = "Morgan Ellis" -capital_account = "3001" -share = "0.5" - -# Two devices the members contribute jointly from personal use, 24-month -# lives: a laptop worth 1,440.00 (adjusted basis 1,688.40) and a camera -# worth 1,300.00 (adjusted basis 1,080.00). -[[setup.assets]] -id = "laptop" -description = "Laptop" -cost = "1440.00" -salvage = "0.00" -life_months = 24 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 2 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1688.40", fmv_at_contribution = "1440.00", sec704c_method = "traditional" } - -[[setup.assets]] -id = "camera" -description = "Camera" -cost = "1300.00" -salvage = "0.00" -life_months = 24 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 2 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "1080.00", fmv_at_contribution = "1300.00", sec704c_method = "traditional" } - -[[events]] -date = "2026-01-01" -description = "Cash contributed" -lines = [ - { account = "1000", side = "debit", amount = "2000.00" }, - { account = "3000", side = "credit", amount = "1000.00" }, - { account = "3001", side = "credit", amount = "1000.00" }, -] - -[[events]] -date = "2026-01-01" -description = "Devices contributed jointly, at their values" -lines = [ - { account = "1500", side = "debit", amount = "1440.00" }, - { account = "1500", side = "debit", amount = "1300.00" }, - { account = "3000", side = "credit", amount = "1370.00" }, - { account = "3001", side = "credit", amount = "1370.00" }, -] - -[[events]] -date = "2026-02-01" -description = "Sale" -lines = [ - { account = "1000", side = "debit", amount = "600.00" }, - { account = "4000", side = "credit", amount = "600.00" }, -] - -# December's runs catch the year up (rules.md B-15): 1,440.00 x 12/24 = -# 720.00 and 1,300.00 x 12/24 = 650.00, dated Dec 31. -[[events]] -run_depreciation = { asset = "laptop", period = "2026-12" } - -[[events]] -run_depreciation = { asset = "camera", period = "2026-12" } - -[[events]] -date = "2026-12-15" -description = "Distributions" -lines = [ - { account = "3000", side = "debit", amount = "300.00" }, - { account = "3001", side = "debit", amount = "300.00" }, - { account = "1000", side = "credit", amount = "600.00" }, -] - -# Hand check, book, each: 1,000.00 cash + 1,370.00 property + (600.00 - -# 720.00 - 650.00) / 2 = -385.00 - 300.00 = 1,685.00. -[[expect]] -query = "k1" -from = "2026-01-01" -to = "2026-12-31" -values = { "Tobin Harlow.beginning" = "0.00", "Tobin Harlow.contributions_cash" = "1000.00", "Tobin Harlow.contributions_property" = "1370.00", "Tobin Harlow.net_income" = "-385.00", "Tobin Harlow.distributions" = "300.00", "Tobin Harlow.ending" = "1685.00" } - -# Tax, each: property at its §723 basis, (1,688.40 + 1,080.00) / 2 = -# 1,384.20; income open (tax depreciation is the preparer's), known so far -# -385.00; ending known so far 1,000.00 + 1,384.20 - 385.00 - 300.00 = -# 1,699.20. -[[expect]] -query = "k1_tax_basis" -from = "2026-01-01" -to = "2026-12-31" -values = { "Tobin Harlow.beginning" = "0.00", "Tobin Harlow.contributions_cash" = "1000.00", "Tobin Harlow.contributions_property" = "1384.20", "Tobin Harlow.net_income" = "open", "Tobin Harlow.known_net_income" = "-385.00", "Tobin Harlow.distributions" = "300.00", "Tobin Harlow.ending" = "open", "Tobin Harlow.known_ending" = "1699.20" } - -# Book less tax: the built-in gain share, ((1,440.00 - 1,688.40) + -# (1,300.00 - 1,080.00)) / 2 = (-248.40 + 220.00) / 2 = -14.20 = -# 1,685.00 - 1,699.20. -[[expect]] -query = "k1_reconciliation" -from = "2026-01-01" -to = "2026-12-31" -values = { "Tobin Harlow.beginning" = "0.00", "Tobin Harlow.built_in_gain_704c" = "-14.20", "Tobin Harlow.property_without_basis" = "0.00", "Tobin Harlow.property_distributed" = "0.00", "Tobin Harlow.total" = "-14.20" } - -[[expect]] -query = "k1_tax_basis" -from = "2026-01-01" -to = "2026-12-31" -values = { "Morgan Ellis.contributions_property" = "1384.20", "Morgan Ellis.known_ending" = "1699.20" } diff --git a/bookspec/examples/partnership-k1-tax-basis-odd-cents.toml b/bookspec/examples/partnership-k1-tax-basis-odd-cents.toml deleted file mode 100644 index ae1f08c..0000000 --- a/bookspec/examples/partnership-k1-tax-basis-odd-cents.toml +++ /dev/null @@ -1,167 +0,0 @@ -# B-36 with D-38: the tax-basis K-1's odd cents run cumulatively. -id = "partnership-k1-tax-basis-odd-cents" -title = "Tax-basis capital: odd cents of contributed property's basis split cumulatively" -rule = "Within each contribution, the property's built-in gain or loss (value less adjusted tax basis) is shared among the members as its book value is; to the cent, each member's running share from the books' first entry MUST be the largest-remainder rounding of their exact running share (ties to the member listed first), less what earlier entries gave them, so no member's share is ever a cent from exact and tied odd cents alternate instead of piling up on one member." -status = "CHOICE" -sources = [ - "Instructions for Form 1065 (2025), Item L, Tax-basis method: Capital contributed during the year", - "26 U.S.C. §723", - "Treas. Reg. §1.704-1(b)(2)(iv)(b)", - "DECISION D-38 (owner, 2026-10-20): odd cents everywhere alike, cumulative largest remainder; applied to the tax-basis K-1 by the owner, 2026-10-22 (wanderbooks rules.md B-36, B-13)", -] -tags = ["partnerships", "k1", "tax-basis", "rounding"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "largest-remainder" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" - -[[setup.accounts]] -code = "1510" -name = "Accumulated Depreciation" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital, Tobin Harlow" -type = "equity" - -[[setup.accounts]] -code = "3001" -name = "Capital, Morgan Ellis" -type = "equity" - -[[setup.accounts]] -code = "6200" -name = "Depreciation Expense" -type = "expense" - -[[setup.members]] -name = "Tobin Harlow" -capital_account = "3000" -share = "0.5" - -[[setup.members]] -name = "Morgan Ellis" -capital_account = "3001" -share = "0.5" - -# Three items the members contribute jointly, each from personal use and -# each worth less than its adjusted basis by an odd number of cents. -[[setup.assets]] -id = "monitor" -description = "Monitor" -cost = "400.00" -salvage = "0.00" -life_months = 24 -in_service = "2026-01-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 1 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "412.37", fmv_at_contribution = "400.00", sec704c_method = "traditional" } - -[[setup.assets]] -id = "chair" -description = "Chair" -cost = "250.00" -salvage = "0.00" -life_months = 24 -in_service = "2026-06-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 2 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "263.41", fmv_at_contribution = "250.00", sec704c_method = "traditional" } - -[[setup.assets]] -id = "desk" -description = "Desk" -cost = "600.00" -salvage = "0.00" -life_months = 24 -in_service = "2026-11-01" -method = "straight_line" -cost_account = "1500" -accumulated_account = "1510" -expense_account = "6200" -acquired_by = 3 -basis = { rule = "personal_use_conversion", contributor_adjusted_basis = "655.91", fmv_at_contribution = "600.00", sec704c_method = "traditional" } - -[[events]] -date = "2026-01-01" -description = "Monitor contributed jointly, at its value" -lines = [ - { account = "1500", side = "debit", amount = "400.00" }, - { account = "3000", side = "credit", amount = "200.00" }, - { account = "3001", side = "credit", amount = "200.00" }, -] - -[[events]] -date = "2026-06-01" -description = "Chair contributed jointly, at its value" -lines = [ - { account = "1500", side = "debit", amount = "250.00" }, - { account = "3000", side = "credit", amount = "125.00" }, - { account = "3001", side = "credit", amount = "125.00" }, -] - -[[events]] -date = "2026-11-01" -description = "Desk contributed jointly, at its value" -lines = [ - { account = "1500", side = "debit", amount = "600.00" }, - { account = "3000", side = "credit", amount = "300.00" }, - { account = "3001", side = "credit", amount = "300.00" }, -] - -# By hand, the built-in loss shared as it runs (exact shares in brackets): -# Monitor, -12.37: running -12.37 (-6.185 each); rounded toward zero -# -6.18 each, a cent short, the remainders tie, the cent to Tobin: -# Tobin -6.19, Morgan -6.18. -# Chair, -13.41: running -25.78 (-12.89 each, exact): Tobin -12.89 less -# -6.19 given = -6.70; Morgan -12.89 less -6.18 = -6.71. -# Desk, -55.91: running -81.69 (-40.845 each): tied again, to Tobin: -# Tobin -40.85 less -12.89 = -27.96; Morgan -40.84 less -12.89 = -# -27.95. -# Year: built-in loss Tobin -40.85, Morgan -40.84; book property 625.00 -# each; tax property 625.00 + 40.85 = 665.85 and 625.00 + 40.84 = 665.84, -# a cent apart. Shared entry by entry (each entry's tied cent to Tobin) -# it was -40.86 / -40.83: 665.86 against 665.83, three cents apart. -[[expect]] -query = "k1_tax_basis" -from = "2026-01-01" -to = "2026-12-31" -values = { "Tobin Harlow.contributions_property" = "665.85", "Tobin Harlow.known_ending" = "665.85", "Morgan Ellis.contributions_property" = "665.84", "Morgan Ellis.known_ending" = "665.84" } - -[[expect]] -query = "k1_reconciliation" -from = "2026-01-01" -to = "2026-12-31" -values = { "Tobin Harlow.beginning" = "0.00", "Tobin Harlow.built_in_gain_704c" = "-40.85", "Tobin Harlow.property_without_basis" = "0.00", "Tobin Harlow.property_distributed" = "0.00", "Tobin Harlow.total" = "-40.85", "Morgan Ellis.beginning" = "0.00", "Morgan Ellis.built_in_gain_704c" = "-40.84", "Morgan Ellis.property_without_basis" = "0.00", "Morgan Ellis.property_distributed" = "0.00", "Morgan Ellis.total" = "-40.84" } - -# From June the count runs on from January: the beginning is January to -# May's ending, open (the monitor's tax depreciation is the preparer's), -# known so far Tobin 200.00 + 6.19 = 206.19, Morgan 206.18; the span -# takes the chair's and the desk's parts, -6.70 - 27.96 = -34.66 and -# -6.71 - 27.95 = -34.66: tax property 425.00 + 34.66 = 459.66 each, the -# ending the year's. -[[expect]] -query = "k1_tax_basis" -from = "2026-06-01" -to = "2026-12-31" -values = { "Tobin Harlow.beginning" = "open", "Tobin Harlow.known_beginning" = "206.19", "Tobin Harlow.contributions_property" = "459.66", "Tobin Harlow.known_ending" = "665.85", "Morgan Ellis.beginning" = "open", "Morgan Ellis.known_beginning" = "206.18", "Morgan Ellis.contributions_property" = "459.66", "Morgan Ellis.known_ending" = "665.84" } diff --git a/bookspec/examples/period-a-close-sweeps-the-year-into-capital.toml b/bookspec/examples/period-a-close-sweeps-the-year-into-capital.toml deleted file mode 100644 index 32a0042..0000000 --- a/bookspec/examples/period-a-close-sweeps-the-year-into-capital.toml +++ /dev/null @@ -1,108 +0,0 @@ -id = "period-a-close-sweeps-the-year-into-capital" -title = "A close zeroes revenue and expenses into equity, and the closed period takes no new postings" -rule = "A close MUST zero every revenue and expense account through its last day and credit equity with exactly the net income; a period once closed MUST take no new postings." -status = "AUTHORITY" -sources = [ - "Pacioli, Summa (1494), ch. 34: \"you shall close this account into the capital account\" (docs/spec/sources/pacioli-1494.md)", - "OpenStax, Principles of Accounting, Vol. 1, 5.1: temporary accounts are closed at the end of each period (docs/spec/rules.md B-11)", - "CONVENTION (docs/spec/rules.md B-12): a closed period is locked, which follows from posted-is-forever", -] -tags = ["accrual", "book", "us", "period", "close"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "2000" -name = "Loan" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" -tax_category = "gross_receipts" - -[[setup.accounts]] -code = "6000" -name = "Rent" -type = "expense" -tax_category = "rent_property" - -[[events]] -date = "2026-01-02" -description = "Members put in cash" -lines = [ - { account = "1000", side = "debit", amount = "5000.00" }, - { account = "3000", side = "credit", amount = "5000.00" }, -] - -[[events]] -date = "2026-02-10" -description = "A sale" -lines = [ - { account = "1000", side = "debit", amount = "900.00" }, - { account = "4000", side = "credit", amount = "900.00" }, -] - -[[events]] -date = "2026-03-10" -description = "Rent" -lines = [ - { account = "6000", side = "debit", amount = "350.00" }, - { account = "1000", side = "credit", amount = "350.00" }, -] - -# Close January 1 through March 31 into capital. -[[events]] -date = "2026-03-31" -description = "Close the quarter" -close = { start = "2026-01-01", to = "retained_earnings" } - -# March 31 is closed: an entry dated inside the quarter is refused. -[[events]] -date = "2026-03-15" -description = "A late sale" -refused = "period_closed" -lines = [ - { account = "1000", side = "debit", amount = "50.00" }, - { account = "4000", side = "credit", amount = "50.00" }, -] - -# By hand: net income = 900.00 - 350.00 = 550.00. Revenue and expense are -# zero; the 550.00 lands in Retained Earnings (3999, the account the books -# create for the close), so equity is 5000.00 + 550.00 = 5550.00; cash is -# 5000 + 900 - 350 = 5550.00, so assets equal equity. -[[expect]] -query = "balances" -as_of = "2026-12-31" -[expect.values] -"1000" = "5550.00" -"3000" = "5000.00" -"3999" = "550.00" -"4000" = "0.00" -"6000" = "0.00" - -# The statement for the quarter excludes the close: the year's income is -# still reported. -[[expect]] -query = "profit_and_loss" -from = "2026-01-01" -to = "2026-03-31" -[expect.values] -"4000" = "900.00" -"6000" = "350.00" -net_income = "550.00" diff --git a/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml b/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml deleted file mode 100644 index dfccb7c..0000000 --- a/bookspec/examples/projects-invoice-and-bill-lines-count-by-project.toml +++ /dev/null @@ -1,184 +0,0 @@ -# An invoice's or bill's lines count toward the projects they name. -# -# wanderbooks: rules.md B-37 item 8 (journal feature -# `document_line_projects`); regression tests in ledger-core -# `tests/spec_document_projects.rs`, ledger-store -# `tests/document_projects.rs`, ledger-cli `tests/spec_projects.rs` -# (`invoice_and_bill_lines_count_toward_their_projects`). -# -# Expected values worked by hand. Invoice 1 bills 3,000.00 + 500.00 of -# Zeta's design (3,500.00), 200.00 of Harbor's hosting and an untagged -# 100.00 rush fee: 3,800.00, all Revenue (4000), the receivable untagged. -# Bill 1 is 1,200.00 of Zeta's illustration and 50.00 of untagged -# postage: 1,250.00 of General Expenses (6999). Bill 2, 300.00, names no -# project. The customer's payment and the bill's payment move cash and -# the receivable or payable only: no project figure. So Zeta nets -# 3,500.00 - 1,200.00 = 2,300.00; Harbor 200.00 (both are client work, -# so neither has an unrecovered cost: on spec, a hoped-for payment is no -# receivable, B-37 item 4); the untagged lines 100.00 - 50.00 - 300.00 -# - 1,500.00 (rent) = -1,750.00; together 750.00, the P&L's net -# (3,800.00 - 1,550.00 - 1,500.00). Dana was paid 1,250.00 from the bank, -# whatever the bill's projects; the second bill is still owed. - -id = "projects-invoice-and-bill-lines-count-by-project" -title = "An invoice's or bill's lines count toward the projects they name" -rule = "Each line of an invoice or bill MAY name a project; its revenue (an invoice's) or cost (a bill's) MUST then count toward that project's figures exactly as a tagged line of a hand-built entry would, the receivable, the payable and their payments MUST count toward none, and every figure that is not a project's (the P&L, a payee's 1099 payments) MUST be what it would be with no project named." -status = "CONVENTION" -sources = [ - "Job-order costing: a job cost sheet accumulates the revenue and the direct costs identified with one job, a supplier's invoice and the job's billing among them (management-accounting convention; wanderbooks docs/spec/rules.md B-37 items 3 and 8)", - "wanderbooks docs/spec/rules.md B-11 and B-14: a period's net income is its revenue less its expenses", - "Treas. Reg. §1.6041-1(a)(1)(iv) (docs/spec/sources/irs-1099-payment-method.md): a 1099 counts payments made, not how the books classify them", -] -tags = ["projects", "classes", "job-costing", "accrual", "invoices", "bills"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" -cash_accounts = ["1000"] - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" -[[setup.accounts]] -code = "1200" -name = "Accounts Receivable" -type = "asset" -[[setup.accounts]] -code = "2000" -name = "Accounts Payable" -type = "liability" -[[setup.accounts]] -code = "4000" -name = "Revenue" -type = "revenue" -[[setup.accounts]] -code = "6200" -name = "Rent" -type = "expense" -[[setup.accounts]] -code = "6999" -name = "General Expenses" -type = "expense" - -[[setup.projects]] -name = "Zeta" -kind = "client" -[[setup.projects]] -name = "Harbor" -kind = "client" - -[[setup.payees]] -name = "Dana Illustration" -form = "1099-NEC" - -[[events]] -date = "2026-03-02" -description = "Invoice to Cedar" -invoice = { customer = "Cedar Client", lines = [ - { description = "Design", amount = "3000.00", project = "Zeta" }, - { description = "Hosting", amount = "200.00", project = "Harbor" }, - { description = "Rush fee", amount = "100.00" }, - { description = "Revisions", amount = "500.00", project = "Zeta" }, -] } - -[[events]] -date = "2026-03-09" -description = "Dana's bill" -bill = { vendor = "Dana Illustration", lines = [ - { description = "Illustration", amount = "1200.00", project = "Zeta" }, - { description = "Postage", amount = "50.00" }, -] } - -[[events]] -date = "2026-03-20" -description = "Cedar pays invoice 1" -older = true -lines = [ - { account = "1000", side = "debit", amount = "3800.00" }, - { account = "1200", side = "credit", amount = "3800.00" }, -] - -[[events]] -date = "2026-03-30" -description = "Pay Dana's bill" -older = true -payee = "Dana Illustration" -lines = [ - { account = "2000", side = "debit", amount = "1250.00" }, - { account = "1000", side = "credit", amount = "1250.00" }, -] - -[[events]] -date = "2026-03-31" -description = "Studio rent" -lines = [ - { account = "6200", side = "debit", amount = "1500.00" }, - { account = "1000", side = "credit", amount = "1500.00" }, -] - -[[events]] -date = "2026-04-02" -description = "Dana's second bill" -bill = { vendor = "Dana Illustration", amount = "300.00" } - -[[expect]] -query = "entry" -entry = 1 -[expect.values] -"1200.debit" = "3800.00" -"4000.credit" = "3800.00" - -[[expect]] -query = "entry" -entry = 2 -[expect.values] -"6999.debit" = "1250.00" -"2000.credit" = "1250.00" - -[[expect]] -query = "project" -project = "Zeta" -from = "2026-01-01" -to = "2026-12-31" -[expect.values] -revenue = "3500.00" -expenses = "1200.00" -net = "2300.00" -"to_date.revenue" = "3500.00" -"to_date.expenses" = "1200.00" -"to_date.net" = "2300.00" -unrecovered_cost = "none" - -[[expect]] -query = "project" -project = "Harbor" -from = "2026-01-01" -to = "2026-12-31" -[expect.values] -revenue = "200.00" -expenses = "0.00" -net = "200.00" -"to_date.revenue" = "200.00" -"to_date.expenses" = "0.00" -"to_date.net" = "200.00" -unrecovered_cost = "none" - -[[expect]] -query = "profit_and_loss" -from = "2026-01-01" -to = "2026-12-31" -[expect.values] -"4000" = "3800.00" -"6200" = "1500.00" -"6999" = "1550.00" -net_income = "750.00" - -[[expect]] -query = "vendor_1099_paid" -from = "2026-01-01" -to = "2026-12-31" -[expect.values] -"Dana Illustration" = "1250.00" diff --git a/bookspec/examples/projects-net-is-its-tagged-lines.toml b/bookspec/examples/projects-net-is-its-tagged-lines.toml deleted file mode 100644 index d770b0c..0000000 --- a/bookspec/examples/projects-net-is-its-tagged-lines.toml +++ /dev/null @@ -1,162 +0,0 @@ -# A project's figures are the revenue and expense lines tagged to it. -# -# wanderbooks: rules.md B-37 (projects); regression tests -# `project_figures_are_its_tagged_lines` (ledger-core tests/spec_projects.rs), -# `project_figures_match_profit_and_loss_by_class` (ledger-cli -# tests/spec_projects.rs). -# -# Expected values worked by hand: Alpha earns 5,000.00 and costs 1,800.00 -# (contractor) + 120.00 (its part of the tool) = 1,920.00; the contractor -# billed twice in March is reversed, so March nets to zero. Beta costs -# 80.00; its test devices (an asset) count nothing. Rent is no project's. -# The projects and the untagged rent add up to the books' net income: -# 3,080.00 - 80.00 - 1,500.00 = 1,500.00. - -id = "projects-net-is-its-tagged-lines" -title = "A project's net is its tagged revenue less its tagged expenses" -rule = "A project's revenue and expenses for a period MUST be the revenue and expense lines tagged to it, dated in the period, closing entries left out; a tagged balance-sheet line MUST count nothing; a shared cost counts only as split across lines; and the projects with the untagged lines MUST add up to the period's net income." -status = "CONVENTION" -sources = [ - "Job-order costing: a job cost sheet accumulates the revenue and the direct costs identified with one job (management-accounting convention; no overhead is allocated here: wanderbooks docs/spec/rules.md B-37)", - "wanderbooks docs/spec/rules.md B-11 and B-14: a period's net income is its revenue less its expenses, closing entries left out", -] -tags = ["projects", "classes", "job-costing", "accrual"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" -[[setup.accounts]] -code = "1500" -name = "Equipment" -type = "asset" -[[setup.accounts]] -code = "4000" -name = "Revenue" -type = "revenue" -[[setup.accounts]] -code = "6000" -name = "Contractors" -type = "expense" -[[setup.accounts]] -code = "6100" -name = "Software" -type = "expense" -[[setup.accounts]] -code = "6200" -name = "Rent" -type = "expense" - -[[setup.projects]] -name = "Alpha" -kind = "client" -[[setup.projects]] -name = "Beta" -kind = "internal" - -[[events]] -date = "2026-02-03" -description = "Alpha milestone paid" -lines = [ - { account = "1000", side = "debit", amount = "5000.00" }, - { account = "4000", side = "credit", amount = "5000.00", project = "Alpha" }, -] -[[events]] -date = "2026-02-10" -description = "Contractor for Alpha" -lines = [ - { account = "6000", side = "debit", amount = "1800.00", project = "Alpha" }, - { account = "1000", side = "credit", amount = "1800.00" }, -] -[[events]] -date = "2026-02-15" -description = "Design tool, split by use" -lines = [ - { account = "6100", side = "debit", amount = "120.00", project = "Alpha" }, - { account = "6100", side = "debit", amount = "80.00", project = "Beta" }, - { account = "1000", side = "credit", amount = "200.00" }, -] -[[events]] -date = "2026-02-28" -description = "Studio rent" -lines = [ - { account = "6200", side = "debit", amount = "1500.00" }, - { account = "1000", side = "credit", amount = "1500.00" }, -] -[[events]] -date = "2026-03-05" -description = "Test devices for Beta" -lines = [ - { account = "1500", side = "debit", amount = "900.00", project = "Beta" }, - { account = "1000", side = "credit", amount = "900.00" }, -] -[[events]] -date = "2026-03-12" -description = "Contractor for Alpha, billed twice" -lines = [ - { account = "6000", side = "debit", amount = "1800.00", project = "Alpha" }, - { account = "1000", side = "credit", amount = "1800.00" }, -] -[[events]] -date = "2026-03-13" -description = "Reverse entry 6" -reverses = 6 - -[[expect]] -query = "project" -project = "Alpha" -from = "2026-01-01" -to = "2026-12-31" -[expect.values] -revenue = "5000.00" -expenses = "1920.00" -net = "3080.00" -"to_date.revenue" = "5000.00" -"to_date.expenses" = "1920.00" -"to_date.net" = "3080.00" -unrecovered_cost = "none" - -[[expect]] -query = "project" -project = "Alpha" -from = "2026-03-01" -to = "2026-03-31" -[expect.values] -revenue = "0.00" -expenses = "0.00" -net = "0.00" -"to_date.revenue" = "5000.00" -"to_date.expenses" = "1920.00" -"to_date.net" = "3080.00" -unrecovered_cost = "none" - -[[expect]] -query = "project" -project = "Beta" -from = "2026-01-01" -to = "2026-12-31" -[expect.values] -revenue = "0.00" -expenses = "80.00" -net = "-80.00" -"to_date.revenue" = "0.00" -"to_date.expenses" = "80.00" -"to_date.net" = "-80.00" -unrecovered_cost = "none" - -[[expect]] -query = "profit_and_loss" -from = "2026-01-01" -to = "2026-12-31" -[expect.values] -"4000" = "5000.00" -"6000" = "1800.00" -"6100" = "200.00" -"6200" = "1500.00" -net_income = "1500.00" diff --git a/bookspec/examples/projects-on-spec-costs-are-expenses.toml b/bookspec/examples/projects-on-spec-costs-are-expenses.toml deleted file mode 100644 index cf11224..0000000 --- a/bookspec/examples/projects-on-spec-costs-are-expenses.toml +++ /dev/null @@ -1,138 +0,0 @@ -# Work on spec: its costs are expenses when incurred, and the hoped-for -# payment is income only when received; until then it is no receivable. -# -# wanderbooks: rules.md B-37 item 4; regression tests -# `unrecovered_cost_only_for_on_spec_and_never_negative` (ledger-core), -# `on_spec_project_shows_unrecovered_cost_and_others_null` (ledger-mcp). -# -# Expected values worked by hand: 1,400.00 + 60.00 = 1,460.00 of costs, -# nothing receivable (1100 stays 0.00). Repaid 1,000.00: 460.00 still -# unrecovered. Repaid 700.00 more: 1,700.00 in, 240.00 net, nothing -# unrecovered (never below zero). -# -# Both sources are quoted through Deloitte's DART roadmaps and not yet -# re-read in the ASC basic view (wanderbooks docs/spec/sources/fasb-asc.md): -# an open item. - -id = "projects-on-spec-costs-are-expenses" -title = "On-spec costs are expenses; a repayment is income when received" -rule = "The costs of work done on spec MUST be expensed as incurred, and a payment hoped for MUST NOT be recorded, as a receivable or as income, until it is received; a project's unrecovered cost MUST be its tagged expenses less its tagged revenue to date, never below zero." -status = "AUTHORITY" -sources = [ - "ASC 450-30-25-1: a contingency that might result in a gain usually is not reflected in the financial statements, since that might recognize revenue before its realization (originally FAS 5 ¶17(a))", - "ASC 340-40-25-5: costs to fulfill an anticipated contract are an asset only if, among other criteria, they are expected to be recovered (here recovery is uncertain: expensed; wanderbooks docs/spec/rules.md B-22)", -] -tags = ["projects", "on-spec", "gain-contingency", "accrual"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" -[[setup.accounts]] -code = "1100" -name = "Accounts Receivable" -type = "asset" -[[setup.accounts]] -code = "4000" -name = "Revenue" -type = "revenue" -[[setup.accounts]] -code = "6000" -name = "Contractors" -type = "expense" -[[setup.accounts]] -code = "6100" -name = "Hosting" -type = "expense" - -[[setup.projects]] -name = "Harbor app" -kind = "on_spec" - -[[events]] -date = "2026-04-02" -description = "Prototype build" -lines = [ - { account = "6000", side = "debit", amount = "1400.00", project = "Harbor app" }, - { account = "1000", side = "credit", amount = "1400.00" }, -] -[[events]] -date = "2026-04-20" -description = "Hosting for the prototype" -lines = [ - { account = "6100", side = "debit", amount = "60.00", project = "Harbor app" }, - { account = "1000", side = "credit", amount = "60.00" }, -] -[[events]] -date = "2026-06-15" -description = "Harbor repays part of the prototype" -lines = [ - { account = "1000", side = "debit", amount = "1000.00" }, - { account = "4000", side = "credit", amount = "1000.00", project = "Harbor app" }, -] -[[events]] -date = "2026-07-01" -description = "Harbor pays the rest, and more" -lines = [ - { account = "1000", side = "debit", amount = "700.00" }, - { account = "4000", side = "credit", amount = "700.00", project = "Harbor app" }, -] - -[[expect]] -query = "project" -after = 2 -project = "Harbor app" -from = "2026-01-01" -to = "2026-04-30" -[expect.values] -revenue = "0.00" -expenses = "1460.00" -net = "-1460.00" -"to_date.revenue" = "0.00" -"to_date.expenses" = "1460.00" -"to_date.net" = "-1460.00" -unrecovered_cost = "1460.00" - -[[expect]] -query = "balances" -after = 2 -as_of = "2026-04-30" -[expect.values] -"1100" = "0.00" -"6000" = "1400.00" -"6100" = "60.00" - -[[expect]] -query = "project" -after = 3 -project = "Harbor app" -from = "2026-01-01" -to = "2026-06-30" -[expect.values] -revenue = "1000.00" -expenses = "1460.00" -net = "-460.00" -"to_date.revenue" = "1000.00" -"to_date.expenses" = "1460.00" -"to_date.net" = "-460.00" -unrecovered_cost = "460.00" - -[[expect]] -query = "project" -project = "Harbor app" -from = "2026-01-01" -to = "2026-12-31" -[expect.values] -revenue = "1700.00" -expenses = "1460.00" -net = "240.00" -"to_date.revenue" = "1700.00" -"to_date.expenses" = "1460.00" -"to_date.net" = "240.00" -unrecovered_cost = "0.00" diff --git a/bookspec/examples/rename-changes-no-amount.toml b/bookspec/examples/rename-changes-no-amount.toml deleted file mode 100644 index 4176f49..0000000 --- a/bookspec/examples/rename-changes-no-amount.toml +++ /dev/null @@ -1,149 +0,0 @@ -# Renaming an account changes its label, never an amount. -# -# wanderbooks: P3 defect 15 (an account could not be renamed); rules.md -# B-8, B-31; regression tests `rename_account_changes_only_the_name_defect_15` -# and `prop_rename_changes_no_figure` (ledger-store tests/store.rs), -# `rename_account_end_to_end_defect_15` (ledger-cli tests/mcp_journeys.rs). -# The journal's record of the old name beside the new (`account.renamed`) -# is the store's, checked there. -# -# Expected values (bookspec/README.md): the same trial balance before the -# rename (`after = 2`) and after it; `account_names` gives each account's -# name as every report prints it, past periods included; each -# `rename_account` is tried on a copy of the books, after the rename. - -id = "rename-changes-no-amount" -title = "Renaming an account changes its label, never an amount" -rule = "Entries name accounts by identity, not by name, so renaming an account MUST change no posted entry, line, balance or statement amount, and every report, past periods included, MUST print the new name. A new name MUST be refused when it is blank, the name the account has, one another account under the same parent has (ignoring capitals and surrounding space), one that holds a run of nine digits, or not one line of at most 200 characters." -status = "CHOICE" -sources = [ - "Pacioli, Summa de arithmetica (1494), ch. 36: \"You should not write backward, but always forward\" (posted is forever: AUTHORITY; docs/spec/sources/pacioli-1494.md)", - "Rev. Proc. 98-25 §5.01: \"the relationship (i.e., audit trail): (a) between the total of the amounts in the taxpayer's machine-sensible records by account and the account totals in the taxpayer's books\" (an amount never moves with a name: AUTHORITY; docs/spec/sources/irs-records.md)", - "Intuit IIF Import Kit, !ACCNT: accounts are identified by NAME, so two siblings of one name would be one account in QuickBooks (CONVENTION; docs/spec/sources/intuit-iif.md)", - "DECISION, the owner, 2026-10-20 (wanderbooks plan/served.md P3, \"Settled in design\"): the name rules (wanderbooks docs/spec/rules.md B-31)", -] -tags = ["chart", "rename", "audit-trail", "posted-is-forever"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6000" -name = "Operating" -type = "expense" -header = true - -[[setup.accounts]] -code = "6150" -name = "Software" -type = "expense" -parent = "6000" - -[[setup.accounts]] -code = "6160" -name = "Hosting" -type = "expense" -parent = "6000" - -# Entry 1. -[[events]] -date = "2025-11-04" -description = "Capital contributed" -lines = [ - { account = "1000", side = "debit", amount = "800.00" }, - { account = "3000", side = "credit", amount = "800.00" }, -] - -# Entry 2: posted to 6150 under its old name. -[[events]] -date = "2025-11-20" -description = "Software" -lines = [ - { account = "6150", side = "debit", amount = "29.00" }, - { account = "1000", side = "credit", amount = "29.00" }, -] - -# After entry 2, the owner renames 6150. -[[changes]] -after = 2 -rename = { code = "6150", name = "Software subscriptions" } - -# Before the rename. -[[expect]] -query = "account_names" -after = 2 -values = { "1000" = "Cash", "3000" = "Capital", "6000" = "Operating", "6150" = "Software", "6160" = "Hosting" } - -[[expect]] -query = "trial_balance" -as_of = "2025-11-30" -after = 2 -values = { "1000" = "771.00", "3000" = "-800.00", "6000" = "0.00", "6150" = "29.00", "6160" = "0.00" } - -# After it: every amount as before; the new name, past periods included. -[[expect]] -query = "account_names" -values = { "1000" = "Cash", "3000" = "Capital", "6000" = "Operating", "6150" = "Software subscriptions", "6160" = "Hosting" } - -[[expect]] -query = "trial_balance" -as_of = "2025-11-30" -values = { "1000" = "771.00", "3000" = "-800.00", "6000" = "0.00", "6150" = "29.00", "6160" = "0.00" } - -[[expect]] -query = "profit_and_loss" -from = "2025-11-01" -to = "2025-11-30" -values = { "6000" = "0.00", "6150" = "29.00", "6160" = "0.00", net_income = "-29.00" } - -# Refused, changing nothing. -[[expect]] -query = "rename_account" -code = "6150" -name = " " -refused = "blank" - -[[expect]] -query = "rename_account" -code = "6150" -name = " Software subscriptions " -refused = "unchanged" - -[[expect]] -query = "rename_account" -code = "6150" -name = "HOSTING" -refused = "sibling_has_it" - -[[expect]] -query = "rename_account" -code = "6150" -name = "Card 123456789" -refused = "holds_a_tin" - -[[expect]] -query = "rename_account" -code = "6150" -name = "Software\nsubscriptions" -refused = "not_one_line" - -# A name an account under another parent has is no sibling's: accepted, -# moving no amount. -[[expect]] -query = "rename_account" -code = "6150" -name = "Cash" -accepted = true diff --git a/bookspec/examples/schedule-daily-deferred-revenue.toml b/bookspec/examples/schedule-daily-deferred-revenue.toml deleted file mode 100644 index d1cdb0c..0000000 --- a/bookspec/examples/schedule-daily-deferred-revenue.toml +++ /dev/null @@ -1,75 +0,0 @@ -id = "schedule-daily-deferred-revenue" -title = "Deferred revenue is earned by the day, to the cent" -rule = "Revenue collected in advance for a service over time is recognized as each day of the service passes, one posting per calendar month, the last month taking what the others leave." -status = "CHOICE" -sources = ["ASC 606-10-25-23", "DECISION: plan/served.md D-11, D-37 (owner, 2026-10-20)"] -tags = ["schedules", "deferred-revenue", "daily", "p3-defect-9"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "2400" -name = "Deferred Revenue" -type = "liability" - -[[setup.accounts]] -code = "4000" -name = "Subscription Revenue" -type = "revenue" - -# 1,000.00 collected for service 2026-01-05 through 2026-04-04. -[[setup.schedules]] -id = "SUB" -kind = "deferred_revenue" -description = "Quarterly plan, a customer" -total = "1000.00" -start = "2026-01-05" -months = 3 -convention = "daily" -balance_account = "2400" -pl_account = "4000" - -[[events]] -date = "2026-01-05" -description = "Collected a quarter up front" -lines = [ - { account = "1000", side = "debit", amount = "1000.00" }, - { account = "2400", side = "credit", amount = "1000.00" }, -] - -[[events]] -run_schedules = { through = "2026-04-30" } - -# Hand check (worked without the code): January holds 27 of its 31 days -# (5-31); February and March are whole; April holds days 1-4 and takes the -# rest. Cumulative at each month end: 1,000 x (27/31)/3 = 290.3226 -> -# 290.32; 1,000 x (27/31 + 1)/3 = 623.6559 -> 623.66; 1,000 x (27/31 + -# 2)/3 = 956.9892 -> 956.99; then 1,000.00. Months: 290.32, 333.34, -# 333.33, 43.01. -[[expect]] -query = "schedule_plan" -schedule = "SUB" - -[expect.values] -"2026-01-31" = "290.32" -"2026-02-28" = "333.34" -"2026-03-31" = "333.33" -"2026-04-30" = "43.01" - -# Earned through March: 956.99; still owed for April 1-4: 43.01. -[[expect]] -query = "balances" -as_of = "2026-03-31" - -[expect.values] -"2400" = "43.01" -"4000" = "956.99" diff --git a/bookspec/examples/schedule-daily-small-first-and-last-month.toml b/bookspec/examples/schedule-daily-small-first-and-last-month.toml deleted file mode 100644 index a786a38..0000000 --- a/bookspec/examples/schedule-daily-small-first-and-last-month.toml +++ /dev/null @@ -1,107 +0,0 @@ -id = "schedule-daily-small-first-and-last-month" -title = "A prepaid expense spread by the day: a small first and last month around full ones" -rule = "A prepaid expense covering whole months from a mid-month start is expensed by the day: each full month an equal share, the first month its days' share, and the last month what the others leave, one posting per calendar month touched." -status = "CHOICE" -sources = ["ASC 340-10-05-4", "ASC 210-10-45-1(g)", "DECISION: plan/served.md D-11, D-37 (owner, 2026-10-20)"] -tags = ["schedules", "prepaid-expense", "daily", "p3-defect-9"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-away-from-zero" - -[[setup.accounts]] -code = "1000" -name = "Bank" -type = "asset" - -[[setup.accounts]] -code = "1300" -name = "Prepaid Expenses" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "6300" -name = "Insurance" -type = "expense" - -# 1,200.00 of insurance covering 2026-03-15 through 2027-03-14. -[[setup.schedules]] -id = "INS" -kind = "prepaid_expense" -description = "Liability insurance" -total = "1200.00" -start = "2026-03-15" -months = 12 -convention = "daily" -balance_account = "1300" -pl_account = "6300" - -[[events]] -date = "2026-03-15" -description = "Capital" -lines = [ - { account = "1000", side = "debit", amount = "1200.00" }, - { account = "3000", side = "credit", amount = "1200.00" }, -] - -[[events]] -date = "2026-03-15" -description = "Paid a year of insurance" -lines = [ - { account = "1300", side = "debit", amount = "1200.00" }, - { account = "1000", side = "credit", amount = "1200.00" }, -] - -[[events]] -run_schedules = { through = "2027-03-31" } - -# Hand check (worked without the code): 1,200.00 / 12 = 100.00 a month. -# March 2026 holds 17 of its 31 days (15-31): 100.00 x 17/31 = 54.838... -# -> 54.84. April 2026 through February 2027 are whole: 100.00 each, 11 of -# them (cumulatives 154.84 ... 1,154.84). March 2027 holds days 1-14 and -# takes what is left: 1,200.00 - 1,154.84 = 45.16 (= 100.00 x 14/31 to the -# cent). Thirteen month ends for a twelve-month policy. -[[expect]] -query = "schedule_plan" -schedule = "INS" - -[expect.values] -"2026-03-31" = "54.84" -"2026-04-30" = "100.00" -"2026-05-31" = "100.00" -"2026-06-30" = "100.00" -"2026-07-31" = "100.00" -"2026-08-31" = "100.00" -"2026-09-30" = "100.00" -"2026-10-31" = "100.00" -"2026-11-30" = "100.00" -"2026-12-31" = "100.00" -"2027-01-31" = "100.00" -"2027-02-28" = "100.00" -"2027-03-31" = "45.16" - -# Through June 2026 the books have expensed 54.84 + 3 x 100.00 = 354.84, -# and 845.16 is still prepaid. -[[expect]] -query = "balances" -as_of = "2026-06-30" - -[expect.values] -"1300" = "845.16" -"6300" = "354.84" - -# Through March 2027, all of it. -[[expect]] -query = "balances" -as_of = "2027-03-31" - -[expect.values] -"1300" = "0.00" -"6300" = "1200.00" diff --git a/bookspec/examples/schedule-equal-months-start-on-the-first.toml b/bookspec/examples/schedule-equal-months-start-on-the-first.toml deleted file mode 100644 index c4a8843..0000000 --- a/bookspec/examples/schedule-equal-months-start-on-the-first.toml +++ /dev/null @@ -1,63 +0,0 @@ -id = "schedule-equal-months-start-on-the-first" -title = "An equal-months schedule starts on the 1st of a month" -rule = "A schedule that spreads its total in equal months must start on the 1st of a month, for prepaid expenses and deferred revenue alike, so it never recognizes days it does not cover." -status = "CHOICE" -sources = ["ASC 606-10-25-23", "DECISION: plan/served.md D-37 (owner, 2026-10-20)"] -tags = ["schedules", "monthly-even", "refusal", "p3-defect-9"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -# Equal months keep the rounding schedules had before P3 (each month the -# total over the months to the cent, ties to even; the last month takes -# what the others leave): a posted month's replay re-derives its plan. -rounding = "half-even" - -[[setup.accounts]] -code = "1300" -name = "Prepaid Expenses" -type = "asset" - -[[setup.accounts]] -code = "2400" -name = "Deferred Revenue" -type = "liability" - -[[setup.accounts]] -code = "4000" -name = "Subscription Revenue" -type = "revenue" - -[[setup.accounts]] -code = "6300" -name = "Insurance" -type = "expense" - -# Equal months from March 15: refused (it would recognize all of March's -# 100.00, 45.16 of it, March 1 to 14 at 100.00 × 14/31, before the service -# began). The refusal names the 1st. -[[events]] -register_schedule = { id = "MID", kind = "deferred_revenue", description = "Annual plan", total = "1200.00", start = "2026-03-15", months = 12, convention = "monthly_even", balance_account = "2400", pl_account = "4000" } -refused = "invalid_schedule" - -# The same for a prepaid expense: one rule for both. -[[events]] -register_schedule = { id = "MIDP", kind = "prepaid_expense", description = "Insurance", total = "1200.00", start = "2026-03-15", months = 12, convention = "monthly_even", balance_account = "1300", pl_account = "6300" } -refused = "invalid_schedule" - -# From the 1st it is taken: twelve equal months. -[[events]] -register_schedule = { id = "FIRST", kind = "deferred_revenue", description = "Annual plan", total = "1200.00", start = "2026-03-01", months = 12, convention = "monthly_even", balance_account = "2400", pl_account = "4000" } - -# Hand check (worked without the code): 1,200.00 / 12 = 100.00, every -# month, the last absorbing no rounding. -[[expect]] -query = "schedule_plan" -schedule = "FIRST" - -[expect.values] -"2026-03-31" = "100.00" -"2026-09-30" = "100.00" -"2027-02-28" = "100.00" -total = "1200.00" diff --git a/bookspec/examples/statement-the-books-balance-and-the-equation-holds.toml b/bookspec/examples/statement-the-books-balance-and-the-equation-holds.toml deleted file mode 100644 index 0e13e7c..0000000 --- a/bookspec/examples/statement-the-books-balance-and-the-equation-holds.toml +++ /dev/null @@ -1,113 +0,0 @@ -id = "statement-the-books-balance-and-the-equation-holds" -title = "Normal balances, the trial balance and the accounting equation on one small book" -rule = "An account MUST show its balance on its normal side (assets and expenses debit, liabilities, equity and revenue credit); the trial balance's debits MUST equal its credits; and assets MUST equal liabilities plus equity plus revenue less expenses." -status = "AUTHORITY" -sources = [ - "Pacioli, Summa (1494), ch. 36: the total of the credits equals the total of the debits (docs/spec/sources/pacioli-1494.md)", - "FASB Concepts Statement 8, Ch. 4, E62: equity \"is the difference between the entity's assets and its liabilities\" (docs/spec/sources/fasb-asc.md)", - "OpenStax, Principles of Accounting, Vol. 1, 3.1: normal balances (CONVENTION; docs/spec/rules.md B-5)", -] -tags = ["accrual", "book", "us", "statement", "equation"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "2000" -name = "Loan" -type = "liability" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" -tax_category = "gross_receipts" - -[[setup.accounts]] -code = "6000" -name = "Rent" -type = "expense" -tax_category = "rent_property" - -[[events]] -date = "2026-01-02" -description = "Members put in cash" -lines = [ - { account = "1000", side = "debit", amount = "5000.00" }, - { account = "3000", side = "credit", amount = "5000.00" }, -] - -[[events]] -date = "2026-01-03" -description = "A loan" -lines = [ - { account = "1000", side = "debit", amount = "2000.00" }, - { account = "2000", side = "credit", amount = "2000.00" }, -] - -[[events]] -date = "2026-01-10" -description = "A sale" -lines = [ - { account = "1000", side = "debit", amount = "750.50" }, - { account = "4000", side = "credit", amount = "750.50" }, -] - -[[events]] -date = "2026-01-15" -description = "Rent" -lines = [ - { account = "6000", side = "debit", amount = "1200.25" }, - { account = "1000", side = "credit", amount = "1200.25" }, -] - -# By hand. Cash: 5000.00 + 2000.00 + 750.50 - 1200.25 = 6550.25. -# Debit side: cash 6550.25 + rent 1200.25 = 7750.50. -# Credit side: loan 2000.00 + capital 5000.00 + sales 750.50 = 7750.50. -[[expect]] -query = "trial_balance" -as_of = "2026-12-31" -[expect.values] -"1000" = "6550.25" -"2000" = "-2000.00" -"3000" = "-5000.00" -"4000" = "-750.50" -"6000" = "1200.25" - -# Each on its normal side, all positive here; the equation: -# assets 6550.25 = liabilities 2000.00 + equity 5000.00 -# + (revenue 750.50 - expenses 1200.25) = 6550.25. -[[expect]] -query = "balances" -as_of = "2026-12-31" -[expect.values] -"1000" = "6550.25" -"2000" = "2000.00" -"3000" = "5000.00" -"4000" = "750.50" -"6000" = "1200.25" - -# The trial balance as of an earlier day balances too: on January 9 there -# is no sale and no rent. -[[expect]] -query = "trial_balance" -as_of = "2026-01-09" -[expect.values] -"1000" = "7000.00" -"2000" = "-2000.00" -"3000" = "-5000.00" -"4000" = "0.00" -"6000" = "0.00" diff --git a/bookspec/examples/tax-category-required.toml b/bookspec/examples/tax-category-required.toml deleted file mode 100644 index d17aca7..0000000 --- a/bookspec/examples/tax-category-required.toml +++ /dev/null @@ -1,128 +0,0 @@ -# Every revenue and expense account maps to a line of the return. -# -# wanderbooks: P3 defect 8 (an account made without a tax category went -# unnoticed); rules.md B-32; regression tests -# `add_account_warns_without_tax_category_defect_8` and -# `require_tax_category_refuses_defect_8` (ledger-cli -# tests/spec_tax_policy.rs), `prop_tax_policy_refuses_exactly` (ledger-store -# tests/store.rs), `needs_attention_flags_every_uncategorized_pl_account_defect_8` -# (ledger-gui tests/view_model.rs). -# -# Expected values (bookspec/README.md): `uncategorized_accounts` lists -# every account that needs a category and has none, by code; each command -# is tried on a copy of the books under the policy in force when it is -# asked: `warn` (the default) after entry 1, `refuse` from then on. - -id = "tax-category-required" -title = "Every revenue and expense account maps to a line of the return" -rule = "A revenue or expense account that is not a header MUST have a tax category, its link to a line of the income tax return, and every such account without one MUST be listed for the human, with activity or not. By default an account created without one is created (and listed); a company MAY refuse to create such an account without one, or to clear one it has. The accounts the books create for a bill, an invoice or a payroll run MUST NOT be refused: they are listed instead." -status = "CHOICE" -sources = [ - "IRS Publication 583 (Rev. December 2024): \"Your books must show your gross income, as well as your deductions and credits.\" (why it matters: AUTHORITY; docs/spec/sources/irs-records.md)", - "Rev. Proc. 98-25 §5.01: the relationship \"between the total of the amounts in the taxpayer's machine-sensible records by account and the taxpayer's return\" (AUTHORITY; docs/spec/sources/irs-records.md)", - "IRS Publication 583: \"Except in a few cases, the law does not require any specific kind of records.\" (so warn or refuse is the company's choice; docs/spec/sources/irs-records.md)", - "DECISION, the owner, 2026-10-20 (wanderbooks plan/served.md P3, \"Settled in design\"): warn by default, header accounts exempt (wanderbooks docs/spec/rules.md B-32)", -] -tags = ["tax", "chart", "policy"] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "4000" -name = "Sales" -type = "revenue" -tax_category = "gross_receipts" - -[[setup.accounts]] -code = "6000" -name = "Operating" -type = "expense" -header = true - -[[setup.accounts]] -code = "6150" -name = "Software" -type = "expense" -parent = "6000" - -[[setup.accounts]] -code = "6160" -name = "Hosting" -type = "expense" -parent = "6000" - -# Entry 1: Hosting used in the year before; Software never. -[[events]] -date = "2025-06-01" -description = "Hosting" -lines = [ - { account = "6160", side = "debit", amount = "12.00" }, - { account = "1000", side = "credit", amount = "12.00" }, -] - -# Entry 2, under refuse: a bill, whose expense account (General Expenses, -# 6999) the books create without a category, never refused. -[[events]] -date = "2026-02-01" -description = "Hosting, billed" -bill = { vendor = "Example Hosting Co", amount = "42.00" } - -# After entry 1 (and its expectations), the company turns to refuse. -[[changes]] -after = 1 -require_tax_category = "refuse" - -# Under warn: every account without a category listed, active or not -# (the header needs none); one created without one is created. -[[expect]] -query = "uncategorized_accounts" -after = 1 -values = ["6150 Software", "6160 Hosting"] - -[[expect]] -query = "add_account" -after = 1 -account = { code = "6170", name = "Domains", type = "expense" } -accepted = true - -# Under refuse: refused without one, created with one; a header needs -# none; a needed category can't be cleared, but can be set; the accounts -# made earlier stay as they are, and the bill's is listed. -[[expect]] -query = "add_account" -account = { code = "6180", name = "Postage", type = "expense" } -refused = "tax_category_required" - -[[expect]] -query = "add_account" -account = { code = "6180", name = "Postage", type = "expense", tax_category = "office_expense" } -accepted = true - -[[expect]] -query = "add_account" -account = { code = "6900", name = "Other", type = "expense", header = true } -accepted = true - -[[expect]] -query = "set_tax_category" -code = "4000" -refused = "tax_category_required" - -[[expect]] -query = "set_tax_category" -code = "6150" -category = "other_deductions" -accepted = true - -[[expect]] -query = "uncategorized_accounts" -values = ["6150 Software", "6160 Hosting", "6999 General Expenses"] diff --git a/bookspec/gnucash/Cargo.toml b/bookspec/gnucash/Cargo.toml deleted file mode 100644 index 3361f32..0000000 --- a/bookspec/gnucash/Cargo.toml +++ /dev/null @@ -1,18 +0,0 @@ -[package] -name = "bookspec-gnucash" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "A second engine for bookspec: GnuCash, driven through the oracle container's files." - -[dependencies] -bookspec-checker = { path = "../checker" } -serde_json = "1" -rust_decimal = "1" - -[dev-dependencies] -bookspec-runner = { path = "../runner" } - -[lints] -workspace = true diff --git a/bookspec/gnucash/src/lib.rs b/bookspec/gnucash/src/lib.rs deleted file mode 100644 index 078c9cc..0000000 --- a/bookspec/gnucash/src/lib.rs +++ /dev/null @@ -1,250 +0,0 @@ -//! GnuCash as a second bookspec engine (`plan/served.md` P13 step 5). -//! -//! The adapter keeps an example's chart and entries as the oracle's JSON -//! books (`oracle/gnucash/books.py`: accounts, entries, as-of days) and -//! asks GnuCash 5.10, in its pinned container, for each balance. GnuCash -//! is a cross-check, never the authority (`docs/oracle.md`, "Who is -//! right"): where it differs from an example, the difference is -//! adjudicated from the sources and recorded, not conformed to. -//! -//! Only what GnuCash itself answers is claimed: the balance queries -//! (`trial_balance`, `balances`), by the rules whose examples ask nothing -//! else. GnuCash never refuses an entry (it books the difference to an -//! `Imbalance` account), so a refused event is *accepted* here, as it -//! would be by a GnuCash user, and the runner reports the difference. - -use std::collections::BTreeMap; -use std::io::Write; -use std::process::{Command, Stdio}; -use std::str::FromStr; - -use bookspec_checker::{ - Area, ChangeSpec, Claims, Engine, Event, Query, Refusal, RuleId, Setup, fallback_refusal, -}; -use rust_decimal::Decimal; -use serde_json::{Value, json}; - -/// The oracle container (`just oracle` builds it from `oracle/gnucash`). -pub const IMAGE: &str = "wanderbooks-gnucash-oracle"; - -/// The rules this adapter claims: those whose examples ask only for -/// balances, which GnuCash answers itself. -pub fn claimed() -> Vec { - vec![ - RuleId::new(Area::Entry, 1), - RuleId::new(Area::Entry, 2), - RuleId::new(Area::Entry, 3), - RuleId::new(Area::Ledger, 1), - RuleId::new(Area::Ledger, 3), - RuleId::new(Area::Statement, 1), - RuleId::new(Area::Statement, 2), - ] -} - -/// One account of the book handed to GnuCash. -struct Account { - code: String, - kind: &'static str, - normal_debit: bool, -} - -/// GnuCash, as bookspec's `Engine`. -#[derive(Default)] -pub struct GnuCash { - accounts: Vec, - entries: Vec, - /// Each event's entry, by event number (1-based), for reversals. - event_entries: BTreeMap, - events: usize, -} - -impl GnuCash { - /// An empty engine. - pub fn new() -> Self { - Self::default() - } - - fn id(&self, code: &str) -> Option { - self.accounts - .iter() - .position(|a| a.code == code) - .map(|i| i + 1) - } - - fn book(&self, as_of: &str) -> Value { - let accounts: Vec = self - .accounts - .iter() - .enumerate() - .map(|(i, a)| json!({"id": i + 1, "code": a.code, "name": a.code, "type": a.kind})) - .collect(); - json!({"accounts": accounts, "entries": self.entries, "as_of": [as_of]}) - } - - /// GnuCash's own balance of every account as of a day: debits - /// positive, exact decimal strings, by account code. - fn balances(&self, as_of: &str) -> Result, Refusal> { - let mut child = Command::new("docker") - .args(["run", "--rm", "-i", IMAGE]) - .stdin(Stdio::piped()) - .stdout(Stdio::piped()) - .stderr(Stdio::null()) - .spawn() - .map_err(|e| unavailable(format!("docker: {e}")))?; - let books = json!([self.book(as_of)]); - child - .stdin - .take() - .unwrap() - .write_all(books.to_string().as_bytes()) - .map_err(|e| unavailable(format!("docker stdin: {e}")))?; - let out = child - .wait_with_output() - .map_err(|e| unavailable(format!("docker: {e}")))?; - if !out.status.success() { - return Err(unavailable( - "the GnuCash oracle failed; build it with `just oracle`".into(), - )); - } - let results: Value = serde_json::from_slice(&out.stdout) - .map_err(|e| unavailable(format!("oracle output: {e}")))?; - let at = &results[0]["balances"][as_of]; - let mut map = BTreeMap::new(); - for (i, a) in self.accounts.iter().enumerate() { - let v = at[(i + 1).to_string()] - .as_str() - .ok_or_else(|| unavailable(format!("no balance for {}", a.code)))?; - map.insert( - a.code.clone(), - Decimal::from_str(v).map_err(|e| unavailable(e.to_string()))?, - ); - } - Ok(map) - } -} - -fn unavailable(why: String) -> Refusal { - fallback_refusal( - RuleId::new(Area::Ledger, 1), - format!("the GnuCash oracle: {why}"), - ) -} - -fn not_supported(what: &str) -> Refusal { - fallback_refusal( - RuleId::new(Area::Ledger, 1), - format!("the GnuCash adapter does not take {what}"), - ) -} - -impl Engine for GnuCash { - fn load(&mut self, setup: &Setup) -> Result<(), Refusal> { - for a in &setup.accounts { - let (kind, normal_debit) = match a.kind.as_str() { - "asset" => ("Asset", true), - "liability" => ("Liability", false), - "equity" => ("Equity", false), - "revenue" => ("Revenue", false), - "expense" => ("Expense", true), - other => return Err(not_supported(&format!("account type {other}"))), - }; - self.accounts.push(Account { - code: a.code.clone(), - kind, - normal_debit, - }); - } - if setup.currency != "USD" { - return Err(not_supported("a currency other than USD")); - } - Ok(()) - } - - fn apply(&mut self, event: &Event) -> Result<(), Refusal> { - self.events += 1; - let n = self.events; - let date = event - .date - .clone() - .ok_or_else(|| not_supported("an undated event"))?; - let description = event.description.clone().unwrap_or_default(); - if !event.lines.is_empty() { - let mut lines = Vec::new(); - for l in &event.lines { - let account = self - .id(&l.account) - .ok_or_else(|| not_supported("an unknown account"))?; - lines.push(json!({"account": account, "side": l.side, "amount": l.amount})); - } - self.entries - .push(json!({"date": date, "description": description, "lines": lines})); - self.event_entries.insert(n, self.entries.len() - 1); - return Ok(()); - } - if let Some(of) = event.reverses { - // GnuCash has no reversal link; a user reverses by writing the - // opposite transaction, which is what is done here. - let idx = *self - .event_entries - .get(&of) - .ok_or_else(|| not_supported("a reversal of an event that posted nothing"))?; - let original = self.entries[idx].clone(); - let flipped: Vec = original["lines"] - .as_array() - .unwrap() - .iter() - .map(|l| { - let side = if l["side"] == "debit" { - "credit" - } else { - "debit" - }; - json!({"account": l["account"], "side": side, "amount": l["amount"]}) - }) - .collect(); - self.entries - .push(json!({"date": date, "description": description, "lines": flipped})); - self.event_entries.insert(n, self.entries.len() - 1); - return Ok(()); - } - Err(not_supported("this kind of event")) - } - - fn query(&self, q: &Query) -> Result { - let as_of = q - .as_of - .clone() - .ok_or_else(|| not_supported("a query without as_of"))?; - match q.name.as_str() { - "trial_balance" | "balances" => { - let balances = self.balances(&as_of)?; - let mut out = serde_json::Map::new(); - for a in &self.accounts { - let b = balances[&a.code]; - let mut shown = if q.name == "balances" && !a.normal_debit { - -b - } else { - b - }; - if shown.is_zero() { - shown = Decimal::ZERO; - } - if shown.scale() < 2 { - shown.rescale(2); - } - out.insert(a.code.clone(), Value::String(shown.to_string())); - } - Ok(Value::Object(out)) - } - other => Err(not_supported(&format!("the {other} query"))), - } - } - - fn claims(&self) -> Claims { - Claims::new(claimed()) - } - - fn change(&mut self, _change: &ChangeSpec) -> Result<(), Refusal> { - Err(not_supported("a change")) - } -} diff --git a/bookspec/gnucash/tests/gnucash.rs b/bookspec/gnucash/tests/gnucash.rs deleted file mode 100644 index 9db7d2b..0000000 --- a/bookspec/gnucash/tests/gnucash.rs +++ /dev/null @@ -1,137 +0,0 @@ -//! GnuCash, run through bookspec's examples (`just oracle` builds the -//! container). Needs Docker, so the test is ignored by default: -//! `cargo test -p bookspec-gnucash -- --ignored --nocapture`. - -use std::path::{Path, PathBuf}; - -use bookspec_checker::{Example, rules}; -use bookspec_gnucash::{GnuCash, claimed}; - -fn examples() -> Vec { - let dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("../examples"); - let mut files: Vec = std::fs::read_dir(dir) - .unwrap() - .map(|e| e.unwrap().path()) - .filter(|p| p.extension().is_some_and(|x| x == "toml")) - .collect(); - files.sort(); - files - .iter() - .map(|f| Example::from_toml(&std::fs::read_to_string(f).unwrap()).unwrap()) - .collect() -} - -/// The differences GnuCash shows, each settled from the sources -/// (`bookspec/README.md`, "GnuCash, the second engine"). A new difference, -/// or one that goes away, fails this test until it is adjudicated again. -const ADJUDICATED: [(&str, &str); 3] = [ - ( - "entry-an-unbalanced-entry-is-refused", - "event 2: accepted, expected refused: unbalanced", - ), - ( - "entry-a-line-amount-is-positive", - "event 1: accepted, expected refused: negative_amount", - ), - ( - "ledger-a-fraction-of-a-cent-is-refused", - "event 1: accepted, expected refused: finer_than_minor_unit", - ), -]; - -fn run_claimed() -> bookspec_runner::Report { - let claimed = claimed(); - let mut wanted = std::collections::BTreeSet::new(); - for r in rules().iter().filter(|r| claimed.contains(&r.id)) { - wanted.extend(r.examples.iter().copied()); - } - let examples: Vec = examples() - .into_iter() - .filter(|e| wanted.contains(e.id.as_str())) - .collect(); - bookspec_runner::run(&examples, rules(), |_| GnuCash::new()) -} - -#[test] -#[ignore = "needs Docker and the GnuCash image: `just oracle`"] -fn gnucash_differs_only_where_it_was_adjudicated() { - let report = run_claimed(); - let found: Vec<(&str, &str)> = report - .examples - .iter() - .filter_map(|e| e.failure.as_deref().map(|f| (e.id.as_str(), f))) - .collect(); - let mut want: Vec<(&str, &str)> = ADJUDICATED.to_vec(); - want.sort(); - let mut got = found.clone(); - got.sort(); - assert_eq!(got, want, "GnuCash's differences changed: adjudicate again"); - // The rules that every example passes are passed. - for rule in [ - "BS-entry-3", - "BS-ledger-3", - "BS-statement-1", - "BS-statement-2", - ] { - let id = bookspec_checker::RuleId::parse(rule).unwrap(); - assert_eq!(report.rules[&id], bookspec_runner::Outcome::Pass, "{rule}"); - } -} - -/// The evidence the adjudications rest on, from GnuCash's own answers: -/// what it did with the entry it accepted. -#[test] -#[ignore = "needs Docker and the GnuCash image: `just oracle`"] -fn what_gnucash_did_with_the_entries_the_specification_refuses() { - use bookspec_checker::Engine; - use std::str::FromStr; - let by_id = |id: &str| examples().into_iter().find(|e| e.id == id).unwrap(); - let net = |id: &str, event: usize| -> rust_decimal::Decimal { - let ex = by_id(id); - let mut g = GnuCash::new(); - g.load(&ex.setup).unwrap(); - for e in ex.events.iter().take(event) { - g.apply(e).expect("GnuCash accepts it"); - } - let q = ex - .expect - .iter() - .find(|x| x.query == "trial_balance") - .unwrap() - .query(); - let answer = g.query(&q).unwrap(); - answer - .as_object() - .unwrap() - .values() - .map(|v| rust_decimal::Decimal::from_str(v.as_str().unwrap()).unwrap()) - .sum() - }; - // Debits 100.00 against credits 90.00: the accounts GnuCash lists no - // longer net to zero; ten dollars sit where GnuCash parked them, in an - // Imbalance account of its own making. - assert_eq!( - net("entry-an-unbalanced-entry-is-refused", 2).to_string(), - "10.00" - ); - // A credit of minus 100.00 is booked as a debit of 100.00: both lines - // are debits, and 200.00 sits in the Imbalance account. - assert_eq!( - net("entry-a-line-amount-is-positive", 1).to_string(), - "200.00" - ); - // 10.005 on each side is rounded to the cent, silently: GnuCash's USD - // balance reads 10.01 (the choice rules.md B-3 records the owner took - // the other way, after the first cross-check found it). - let ex = by_id("ledger-a-fraction-of-a-cent-is-refused"); - let mut g = GnuCash::new(); - g.load(&ex.setup).unwrap(); - g.apply(&ex.events[0]).unwrap(); - let q = ex - .expect - .iter() - .find(|x| x.query == "trial_balance") - .unwrap() - .query(); - assert_eq!(g.query(&q).unwrap()["1000"], "10.01"); -} diff --git a/bookspec/lean/.gitignore b/bookspec/lean/.gitignore deleted file mode 100644 index 01f8cdb..0000000 --- a/bookspec/lean/.gitignore +++ /dev/null @@ -1 +0,0 @@ -.lake/ diff --git a/bookspec/lean/Audit.lean b/bookspec/lean/Audit.lean deleted file mode 100644 index d528d73..0000000 --- a/bookspec/lean/Audit.lean +++ /dev/null @@ -1,14 +0,0 @@ -import Bookspec - -/-! Each of the five laws' axioms are printed, and must be only Lean's -standard three (`propext`, `Quot.sound`, `Classical.choice`) or fewer. No -`sorryAx`, nothing assumed. `bookspec/checker/tests/lean.rs` reads this -file's output shape; run it with `lake env lean Audit.lean`. -/ - -open Bookspec - -#print axioms law_debits_equal_credits -#print axioms law_accounting_equation -#print axioms law_trial_balance -#print axioms law_net_income_articulates -#print axioms law_cash_flow_is_change_in_cash diff --git a/bookspec/lean/Bookspec.lean b/bookspec/lean/Bookspec.lean deleted file mode 100644 index 14fd6c2..0000000 --- a/bookspec/lean/Bookspec.lean +++ /dev/null @@ -1,2 +0,0 @@ -import Bookspec.Laws -import Bookspec.Ledger diff --git a/bookspec/lean/Bookspec/Basic.lean b/bookspec/lean/Bookspec/Basic.lean deleted file mode 100644 index ce4becd..0000000 --- a/bookspec/lean/Bookspec/Basic.lean +++ /dev/null @@ -1,165 +0,0 @@ -/-! -# Lines, sides, and nets - -The vocabulary of double entry. Money is `Int`: an exact count of the -smallest unit the books use. Any finite set of exact decimals (what the -engine stores) scales to integers without loss, and every law here is a -law of addition, so `Int` is a faithful model. - -Mirrors `crates/ledger-core/src/kernel.rs` (`flip`, `signed`, `net`, -`totals`) and `account.rs` (`Side`, `AccountType`, `normal_side`). --/ - -namespace Bookspec - -/-- Debit or credit. -/ -inductive Side where - | debit - | credit - deriving DecidableEq, Repr - -/-- The other side (`kernel::flip`). -/ -def Side.flip : Side → Side - | .debit => .credit - | .credit => .debit - -@[simp] theorem Side.flip_flip (s : Side) : s.flip.flip = s := by - cases s <;> rfl - -theorem Side.flip_ne (s : Side) : s.flip ≠ s := by - cases s <;> simp [Side.flip] - -/-- The five account types. -/ -inductive AccountType where - | asset - | liability - | equity - | revenue - | expense - deriving DecidableEq, Repr - -/-- The side that increases an account (`AccountType::normal_side`). -/ -def AccountType.normal : AccountType → Side - | .asset | .expense => .debit - | .liability | .equity | .revenue => .credit - -/-- A line's signed amount: `+a` for a debit, `-a` for a credit -(`kernel::signed`). -/ -def signed : Side → Int → Int - | .debit, a => a - | .credit, a => -a - -@[simp] theorem signed_debit (a : Int) : signed .debit a = a := rfl -@[simp] theorem signed_credit (a : Int) : signed .credit a = -a := rfl - -theorem signed_flip (s : Side) (a : Int) : signed s.flip a = -signed s a := by - cases s <;> simp [Side.flip] - -/-- One journal line. -/ -structure Line where - account : Nat - side : Side - amount : Int - deriving DecidableEq, Repr - -/-- A line's signed amount. -/ -def Line.signed (l : Line) : Int := Bookspec.signed l.side l.amount - -/-- The net of some lines: debits minus credits (`kernel::net`). -/ -def net : List Line → Int - | [] => 0 - | l :: ls => l.signed + net ls - -/-- Total debits. -/ -def debits : List Line → Int - | [] => 0 - | l :: ls => (if l.side = .debit then l.amount else 0) + debits ls - -/-- Total credits. -/ -def credits : List Line → Int - | [] => 0 - | l :: ls => (if l.side = .credit then l.amount else 0) + credits ls - -@[simp] theorem net_nil : net [] = 0 := rfl -@[simp] theorem net_cons (l : Line) (ls : List Line) : net (l :: ls) = l.signed + net ls := rfl - -theorem net_append (xs ys : List Line) : net (xs ++ ys) = net xs + net ys := by - induction xs with - | nil => simp - | cons x xs ih => simp only [List.cons_append, net_cons, ih]; omega - -/-- The net is debits minus credits. -/ -theorem net_eq_debits_sub_credits (ls : List Line) : net ls = debits ls - credits ls := by - induction ls with - | nil => rfl - | cons l ls ih => - obtain ⟨a, s, m⟩ := l - cases s <;> simp [net, debits, credits, Line.signed, signed, ih] <;> omega - -/-- A sum over a list of account codes. -/ -def sumOver (f : Nat → Int) : List Nat → Int - | [] => 0 - | a :: as => f a + sumOver f as - -@[simp] theorem sumOver_nil (f : Nat → Int) : sumOver f [] = 0 := rfl -@[simp] theorem sumOver_cons (f : Nat → Int) (a : Nat) (as : List Nat) : - sumOver f (a :: as) = f a + sumOver f as := rfl - -theorem sumOver_add (f g : Nat → Int) (as : List Nat) : - sumOver (fun a => f a + g a) as = sumOver f as + sumOver g as := by - induction as with - | nil => rfl - | cons a as ih => simp only [sumOver_cons, ih]; omega - -theorem sumOver_neg (f : Nat → Int) (as : List Nat) : - sumOver (fun a => -f a) as = -sumOver f as := by - induction as with - | nil => rfl - | cons a as ih => simp only [sumOver_cons, ih]; omega - -theorem sumOver_zero (as : List Nat) : sumOver (fun _ => 0) as = 0 := by - induction as with - | nil => rfl - | cons a as ih => simp [ih] - -theorem sumOver_congr (f g : Nat → Int) (as : List Nat) (h : ∀ a ∈ as, f a = g a) : - sumOver f as = sumOver g as := by - induction as with - | nil => rfl - | cons a as ih => - simp only [sumOver_cons] - rw [h a (by simp), ih (fun b hb => h b (by simp [hb]))] - -/-- No code repeats (a chart's codes are unique). -/ -def Distinct : List Nat → Prop - | [] => True - | a :: as => a ∉ as ∧ Distinct as - -/-- Summing an indicator over codes that do not include `k` gives zero. -/ -theorem sumOver_indicator_absent (k : Nat) (x : Int) (as : List Nat) (h : k ∉ as) : - sumOver (fun a => if k = a then x else 0) as = 0 := by - induction as with - | nil => rfl - | cons a as ih => - have hka : k ≠ a := fun e => h (e ▸ List.mem_cons_self ..) - have hk : k ∉ as := fun hm => h (List.mem_cons_of_mem _ hm) - simp [hka, ih hk] - -/-- Summing an indicator over distinct codes that include `k` picks out -exactly one term. -/ -theorem sumOver_indicator (k : Nat) (x : Int) (as : List Nat) (hd : Distinct as) - (hk : k ∈ as) : sumOver (fun a => if k = a then x else 0) as = x := by - induction as with - | nil => simp at hk - | cons a as ih => - obtain ⟨ha, hd⟩ := hd - by_cases e : k = a - · subst e - simp [sumOver_indicator_absent k x as ha] - · have : k ∈ as := by - cases hk with - | head => exact absurd rfl e - | tail _ h => exact h - simp [e, ih hd this] - -end Bookspec diff --git a/bookspec/lean/Bookspec/Closing.lean b/bookspec/lean/Bookspec/Closing.lean deleted file mode 100644 index c9ffca5..0000000 --- a/bookspec/lean/Bookspec/Closing.lean +++ /dev/null @@ -1,451 +0,0 @@ -import Bookspec.Statements - -/-! -# Closing a period, and splitting the net - -Mirrors `kernel::closing_line`, `kernel::credit_line`, `kernel::plug_last`, -`periods::allocate`, and `Ledger::close_period_to` (income closing, net to -one equity account or split among members). --/ - -namespace Bookspec - -/-- The line that zeroes an account with movement `m`: the opposite side, -same amount; nothing for zero (`kernel::closing_line`). It is also the -line that credits `m` to an account (`kernel::credit_line`). -/ -def closingLine (a : Nat) (m : Int) : List Line := - if 0 < m then [⟨a, .credit, m⟩] else if m < 0 then [⟨a, .debit, -m⟩] else [] - -theorem net_closingLine (a : Nat) (m : Int) : net (closingLine a m) = -m := by - unfold closingLine - split - · simp [Line.signed] - · split <;> simp [Line.signed] <;> omega - -theorem movement_closingLine (b a : Nat) (m : Int) : - movement b (closingLine a m) = if b = a then -m else 0 := by - by_cases e : b = a - · subst e - rcases (by omega : 0 < m ∨ m < 0 ∨ m = 0) with h | h | h - · simp [closingLine, h, Line.signed] - · have : ¬ 0 < m := by omega - simp [closingLine, h, this, Line.signed] - · simp [closingLine, h] - · have e' : ¬ a = b := fun x => e x.symm - unfold closingLine - split <;> (try split) <;> simp [e'] - -theorem closingLine_pos (a : Nat) (m : Int) : ∀ l ∈ closingLine a m, 0 < l.amount := by - unfold closingLine - split - · simp; omega - · split - · simp; omega - · simp - -theorem closingLine_account (a : Nat) (m : Int) : ∀ l ∈ closingLine a m, l.account = a := by - unfold closingLine - split - · simp - · split <;> simp - -theorem closingLine_eq_nil (a : Nat) (m : Int) : closingLine a m = [] ↔ m = 0 := by - unfold closingLine - split - · simp; omega - · split - · simp; omega - · simp; omega - -/-- The closing lines for some accounts: one per account with a nonzero -movement. -/ -def closings (as : List Nat) (ls : List Line) : List Line := - match as with - | [] => [] - | a :: as => closingLine a (movement a ls) ++ closings as ls - -theorem net_closings (as : List Nat) (ls : List Line) : - net (closings as ls) = -sumOver (fun a => movement a ls) as := by - induction as with - | nil => rfl - | cons a as ih => simp only [closings, net_append, net_closingLine, ih, sumOver_cons]; omega - -theorem movement_closings (b : Nat) (as : List Nat) (ls : List Line) (hd : Distinct as) : - movement b (closings as ls) = if b ∈ as then -movement b ls else 0 := by - induction as with - | nil => simp [closings] - | cons a as ih => - obtain ⟨ha, hd⟩ := hd - simp only [closings, movement_append, movement_closingLine, ih hd] - by_cases e : b = a - · subst e; simp [ha] - · simp [e] - -theorem closings_pos (as : List Nat) (ls : List Line) : ∀ l ∈ closings as ls, 0 < l.amount := by - induction as with - | nil => simp [closings] - | cons a as ih => - intro l hl - simp only [closings, List.mem_append] at hl - cases hl with - | inl h => exact closingLine_pos _ _ l h - | inr h => exact ih l h - -theorem closings_account (as : List Nat) (ls : List Line) : ∀ l ∈ closings as ls, l.account ∈ as := by - induction as with - | nil => simp [closings] - | cons a as ih => - intro l hl - simp only [closings, List.mem_append] at hl - cases hl with - | inl h => rw [closingLine_account _ _ l h]; simp - | inr h => exact List.mem_cons_of_mem _ (ih l h) - -/-- Nothing to close means every closed account was already at zero. -/ -theorem closings_eq_nil (as : List Nat) (ls : List Line) (h : closings as ls = []) : - ∀ a ∈ as, movement a ls = 0 := by - induction as with - | nil => simp - | cons a as ih => - simp only [closings, List.append_eq_nil_iff] at h - intro b hb - cases hb with - | head => exact (closingLine_eq_nil _ _).1 h.1 - | tail _ hb => exact ih h.2 b hb - -/-! ## The net to equity -/ - -/-- The sum of some amounts. -/ -def total : List Int → Int - | [] => 0 - | x :: xs => x + total xs - -/-- Lines crediting each target its part (`kernel::credit_line` per -member). -/ -def equityLines : List (Nat × Int) → List Line - | [] => [] - | (t, c) :: ts => closingLine t c ++ equityLines ts - -theorem net_equityLines (ts : List (Nat × Int)) : - net (equityLines ts) = -total (ts.map Prod.snd) := by - induction ts with - | nil => rfl - | cons x ts ih => - obtain ⟨t, c⟩ := x - simp only [equityLines, net_append, net_closingLine, ih, List.map_cons, total]; omega - -theorem movement_equityLines_absent (b : Nat) (ts : List (Nat × Int)) - (h : ∀ x ∈ ts, x.1 ≠ b) : movement b (equityLines ts) = 0 := by - induction ts with - | nil => rfl - | cons x ts ih => - obtain ⟨t, c⟩ := x - have ht : b ≠ t := fun e => h (t, c) (by simp) e.symm - simp only [equityLines, movement_append, movement_closingLine, ht, ite_false, - ih (fun y hy => h y (List.mem_cons_of_mem _ hy))] - rfl - -theorem equityLines_pos (ts : List (Nat × Int)) : ∀ l ∈ equityLines ts, 0 < l.amount := by - induction ts with - | nil => simp [equityLines] - | cons x ts ih => - obtain ⟨t, c⟩ := x - intro l hl - simp only [equityLines, List.mem_append] at hl - cases hl with - | inl h => exact closingLine_pos _ _ l h - | inr h => exact ih l h - -theorem equityLines_account (ts : List (Nat × Int)) : - ∀ l ∈ equityLines ts, ∃ x ∈ ts, l.account = x.1 := by - induction ts with - | nil => simp [equityLines] - | cons x ts ih => - obtain ⟨t, c⟩ := x - intro l hl - simp only [equityLines, List.mem_append] at hl - cases hl with - | inl h => exact ⟨(t, c), by simp, closingLine_account _ _ l h⟩ - | inr h => - obtain ⟨y, hy, e⟩ := ih l h - exact ⟨y, List.mem_cons_of_mem _ hy, e⟩ - -theorem equityLines_eq_nil (ts : List (Nat × Int)) (h : equityLines ts = []) : - total (ts.map Prod.snd) = 0 := by - induction ts with - | nil => rfl - | cons x ts ih => - obtain ⟨t, c⟩ := x - simp only [equityLines, List.append_eq_nil_iff] at h - simp only [List.map_cons, total, (closingLine_eq_nil _ _).1 h.1, ih h.2]; rfl - -/-- The closing entry: zero the closed accounts, credit the net to the -targets. -/ -def closingEntry (as : List Nat) (ts : List (Nat × Int)) (ls : List Line) : List Line := - closings as ls ++ equityLines ts - -/-- **A closing entry balances** when the targets' parts sum to the net -of the closing lines (the net income), however that net was split. -/ -theorem closingEntry_net (as : List Nat) (ts : List (Nat × Int)) (ls : List Line) - (hsum : total (ts.map Prod.snd) = net (closings as ls)) : - net (closingEntry as ts ls) = 0 := by - rw [closingEntry, net_append, net_equityLines, hsum]; omega - -/-- **A closing entry zeroes every account it closes.** -/ -theorem closingEntry_zeroes (as : List Nat) (ts : List (Nat × Int)) (ls : List Line) - (hd : Distinct as) (b : Nat) (hb : b ∈ as) (ht : ∀ x ∈ ts, x.1 ≠ b) : - movement b (ls ++ closingEntry as ts ls) = 0 := by - rw [movement_append, closingEntry, movement_append, movement_closings b as ls hd, - movement_equityLines_absent b ts ht] - simp only [hb, ite_true]; omega - -/-- **A non-empty closing entry is acceptable**: two or more lines, every -amount positive, debits equal to credits. -/ -theorem closingEntry_accepted (as : List Nat) (ts : List (Nat × Int)) (ls : List Line) - (hsum : total (ts.map Prod.snd) = net (closings as ls)) (hne : closings as ls ≠ []) : - Accepted (closingEntry as ts ls) := by - refine ⟨?_, ?_, ?_⟩ - · rw [closingEntry, List.length_append] - match hc : closings as ls with - | [] => exact absurd hc hne - | [l] => - have hl : 0 < l.amount := closings_pos as ls l (by rw [hc]; simp) - have hnet : net (closings as ls) ≠ 0 := by - rw [hc]; obtain ⟨a, s, m⟩ := l - cases s <;> simp [Line.signed] at hl ⊢ <;> omega - have : equityLines ts ≠ [] := fun h => hnet (hsum ▸ equityLines_eq_nil ts h) - have : 1 ≤ (equityLines ts).length := by - cases h : equityLines ts with - | nil => exact absurd h this - | cons _ _ => simp - simp; omega - | _ :: _ :: _ => simp; omega - · intro l hl - simp only [closingEntry, List.mem_append] at hl - cases hl with - | inl h => exact closings_pos as ls l h - | inr h => exact equityLines_pos ts l h - · have := closingEntry_net as ts ls hsum - rw [net_eq_debits_sub_credits] at this; omega - -/-! ## Net income -/ - -/-- The chart's revenue and expense codes: what a close zeroes. -/ -def nominalCodes : Chart → List Nat - | [] => [] - | (a, t) :: c => if t = .revenue ∨ t = .expense then a :: nominalCodes c else nominalCodes c - -theorem nominalCodes_sub (c : Chart) : ∀ a ∈ nominalCodes c, a ∈ codes c := by - induction c with - | nil => simp [nominalCodes] - | cons x c ih => - obtain ⟨a, t⟩ := x - intro b hb - simp only [nominalCodes] at hb - split at hb - · cases hb with - | head => simp - | tail _ h => exact List.mem_cons_of_mem _ (ih b h) - · exact List.mem_cons_of_mem _ (ih b hb) - -theorem nominalCodes_distinct (c : Chart) (hd : Distinct (codes c)) : Distinct (nominalCodes c) := by - induction c with - | nil => trivial - | cons x c ih => - obtain ⟨a, t⟩ := x - obtain ⟨ha, hd⟩ := hd - simp only [nominalCodes] - split - · exact ⟨fun h => ha (nominalCodes_sub c a h), ih hd⟩ - · exact ih hd - -/-- **The net a close credits to equity is the period's net income**: -revenue balances minus expense balances. -/ -theorem net_closings_is_net_income (c : Chart) (ls : List Line) : - net (closings (nominalCodes c) ls) = typeTotal .revenue ls c - typeTotal .expense ls c := by - rw [net_closings] - induction c with - | nil => rfl - | cons x c ih => - obtain ⟨a, t⟩ := x - cases t <;> simp [nominalCodes, typeTotal, balance, AccountType.normal, signed] at ih ⊢ <;> omega - -/-! ## Splitting among members -/ - -/-- Make rounded parts sum to the whole: the last becomes the whole minus -the others (`kernel::plug_last`). -/ -def plugLast (whole : Int) : List Int → List Int - | [] => [] - | [_] => [whole] - | p :: q :: rest => p :: plugLast (whole - p) (q :: rest) - -/-- **An allocation's parts sum to the whole, exactly**, however the -other parts were rounded. -/ -theorem plugLast_total (whole : Int) (ps : List Int) (h : ps ≠ []) : - total (plugLast whole ps) = whole := by - induction ps generalizing whole with - | nil => exact absurd rfl h - | cons p rest ih => - match rest, ih with - | [], _ => show total [whole] = whole; simp [total] - | q :: rest, ih => - simp only [plugLast, total] - have := ih (whole - p) (by simp) - omega - -theorem plugLast_length (whole : Int) (ps : List Int) : (plugLast whole ps).length = ps.length := by - induction ps generalizing whole with - | nil => rfl - | cons p rest ih => - match rest, ih with - | [], _ => rfl - | q :: rest, ih => simp only [plugLast, List.length_cons]; rw [ih]; rfl - -/-- Only the last part changes. -/ -theorem plugLast_keeps (whole : Int) (ps : List Int) : - (plugLast whole ps).dropLast = ps.dropLast := by - induction ps generalizing whole with - | nil => rfl - | cons p rest ih => - match rest, ih with - | [], _ => rfl - | q :: rest, ih => - have hne : plugLast (whole - p) (q :: rest) ≠ [] := by - intro h - have := plugLast_length (whole - p) (q :: rest) - rw [h] at this; simp at this - rw [show plugLast whole (p :: q :: rest) = p :: plugLast (whole - p) (q :: rest) from rfl, - List.dropLast_cons_of_ne_nil hne, ih, List.dropLast_cons_cons] - -/-! ## Cumulative member closes - -A run of members closes under the run rule of 2026-10-13 to P3 -(`CloseTo::MembersRun`, journaled `"cumulative"`; rules.md B-13) gives each -member, at each close, their allocation of the run's total so far minus -their allocation of the total before it. `alloc` is any allocation of an -amount to members (member `i` gets `alloc x i`), such as -`periods::allocate`. -/ - -/-- What one close gives member `i`: `before` is the run's total before -it, `net` what it closes. -/ -def closePart (alloc : Int → Nat → Int) (before net : Int) (i : Nat) : Int := - alloc (before + net) i - alloc before i - -/-- What a run of closes with these nets, starting after `before`, gives -member `i` in all. -/ -def runPart (alloc : Int → Nat → Int) : Int → List Int → Nat → Int - | _, [], _ => 0 - | before, net :: rest, i => closePart alloc before net i + runPart alloc (before + net) rest i - -/-- **A run telescopes**: member `i`'s total from a run is their allocation -of everything the run closed, less their allocation of where it started. -From an empty start (`alloc 0 = 0`), it is exactly their allocation of -the run's total, so its rounding is one allocation's rounding, never a -sum of them. -/ -theorem runPart_telescopes (alloc : Int → Nat → Int) (before : Int) (nets : List Int) (i : Nat) : - runPart alloc before nets i = alloc (before + nets.sum) i - alloc before i := by - induction nets generalizing before with - | nil => simp [runPart] - | cons n rest ih => - simp only [runPart, closePart, ih, List.sum_cons] - rw [show before + n + rest.sum = before + (n + rest.sum) by omega] - omega - -/-- A sum of differences is the difference of the sums. -/ -theorem sum_map_sub (f g : Nat → Int) (ms : List Nat) : - (ms.map (fun i => f i - g i)).sum = (ms.map f).sum - (ms.map g).sum := by - induction ms with - | nil => simp - | cons m ms ih => simp only [List.map_cons, List.sum_cons, ih]; omega - -/-- **Each close hands out exactly its net** when every allocation sums -to its amount (`periods::allocate` does: `kernel::apportion`). -/ -theorem closePart_total (alloc : Int → Nat → Int) (members : List Nat) - (sums : ∀ x, (members.map (alloc x)).sum = x) (before net : Int) : - (members.map (closePart alloc before net)).sum = net := by - have h := sum_map_sub (alloc (before + net)) (alloc before) members - unfold closePart - rw [h, sums, sums] - omega - -/-! ## Members closes counted from a count's start (P3) - -The rule since P3 (`CloseTo::Members`, rules.md B-13; D-10): a close gives -member `i` their allocation of everything the count has closed (`closed` -before it, plus its `net`), less what the count's earlier closes gave them -(`given i`), whatever rule those closes were made under -(`periods::allocate_cumulative`, `kernel::apportion_cumulative`). The run -rule of 2026-10-13 (`CloseTo::MembersRun`, `closePart`) is the special case -where the earlier closes gave exactly the allocation. -/ - -/-- What a cumulative close gives member `i`. -/ -def trueUp (alloc : Int → Nat → Int) (closed net : Int) (given : Nat → Int) (i : Nat) : Int := - alloc (closed + net) i - given i - -/-- **A cumulative close hands out exactly its net**, whatever rule the -earlier closes used, as long as they handed out what they closed. -/ -theorem trueUp_total (alloc : Int → Nat → Int) (members : List Nat) - (sums : ∀ x, (members.map (alloc x)).sum = x) (closed net : Int) (given : Nat → Int) - (hg : (members.map given).sum = closed) : - (members.map (trueUp alloc closed net given)).sum = net := by - have h := sum_map_sub (alloc (closed + net)) given members - unfold trueUp - rw [h, sums, hg] - omega - -/-- **A running total is one allocation**: after the close, what the count -has given member `i` is their allocation of everything it closed, so its -rounding is one allocation's, never a sum of them. -/ -theorem trueUp_running (alloc : Int → Nat → Int) (closed net : Int) (given : Nat → Int) - (i : Nat) : given i + trueUp alloc closed net given i = alloc (closed + net) i := by - unfold trueUp - omega - -/-- The run rule is the special case `given = alloc closed`. -/ -theorem closePart_eq_trueUp (alloc : Int → Nat → Int) (before net : Int) (i : Nat) : - closePart alloc before net i = trueUp alloc before net (alloc before) i := rfl - -/-- **Bounded drift, for any history**: when every allocation is under one -unit from exact (scaled by `W`, the shares being `w i / W`), so is every -member's running total after a cumulative close, whatever the earlier -closes gave (`Apportion.allocW_drift` supplies the hypothesis). -/ -theorem cumulative_drift (alloc : Int → Nat → Int) (w : Nat → Int) (W : Int) - (hd : ∀ x i, -W < alloc x i * W - x * w i ∧ alloc x i * W - x * w i < W) - (closed net : Int) (given : Nat → Int) (i : Nat) : - -W < (given i + trueUp alloc closed net given i) * W - (closed + net) * w i ∧ - (given i + trueUp alloc closed net given i) * W - (closed + net) * w i < W := by - rw [trueUp_running] - exact hd _ _ - -/-- The statement of members' equity's share of the earnings `U` not yet -closed, as of a day: what the count's next close would give of them, its -allocation of `closed + U` less its allocation of `closed` (D-38; P3 review -R-1: never the true-up a close hasn't posted). -/ -def unclosedShare (alloc : Int → Nat → Int) (closed U : Int) (i : Nat) : Int := - alloc (closed + U) i - alloc closed i - -/-- **Closing a period moves a member's capital on a day by exactly the -true-up the close posts** (D-38, R-1): before, their capital `cap` plus -their share of the unclosed `U`; after closing `net` of it, `cap` plus the -close's part plus their share of the rest. The difference is -`alloc closed i - given i`. -/ -theorem statement_after_close (alloc : Int → Nat → Int) (cap closed net U : Int) - (given : Nat → Int) (i : Nat) : - cap + trueUp alloc closed net given i + unclosedShare alloc (closed + net) (U - net) i - = cap + unclosedShare alloc closed U i + (alloc closed i - given i) := by - unfold trueUp unclosedShare - rw [show closed + net + (U - net) = closed + U by omega] - omega - -/-- **A member's capital on a day never moves when its period is closed** -(D-38), once the count's closes gave what the cumulative rule gives -(`given = alloc closed`: every count the rule began, and every count after -its first close that posts). -/ -theorem statement_unmoved_by_close (alloc : Int → Nat → Int) (cap closed net U : Int) - (i : Nat) : - cap + trueUp alloc closed net (alloc closed) i + unclosedShare alloc (closed + net) (U - net) i - = cap + unclosedShare alloc closed U i := by - rw [statement_after_close] - omega - -end Bookspec diff --git a/bookspec/lean/Bookspec/Entry.lean b/bookspec/lean/Bookspec/Entry.lean deleted file mode 100644 index 8edb3f8..0000000 --- a/bookspec/lean/Bookspec/Entry.lean +++ /dev/null @@ -1,117 +0,0 @@ -import Bookspec.Basic - -/-! -# Entries: acceptance, movements, reversals - -Mirrors `kernel::check_lines`, `kernel::movements`, `kernel::reversal`, -and `JournalEntry::validate`. --/ - -namespace Bookspec - -/-- What the engine accepts (the arithmetic of `JournalEntry::validate`, -i.e. `kernel::check_lines`): two or more lines, every amount positive, -debits equal to credits. (Range, account, and currency checks are not -arithmetic laws and are left out of the model.) -/ -def Accepted (ls : List Line) : Prop := - 2 ≤ ls.length ∧ (∀ l ∈ ls, 0 < l.amount) ∧ debits ls = credits ls - -/-- **Every accepted entry nets to zero.** -/ -theorem Accepted.net_zero {ls : List Line} (h : Accepted ls) : net ls = 0 := by - rw [net_eq_debits_sub_credits]; have := h.2.2; omega - -/-- Net movement of account `a` over some lines. -/ -def movement (a : Nat) : List Line → Int - | [] => 0 - | l :: ls => (if l.account = a then l.signed else 0) + movement a ls - -@[simp] theorem movement_nil (a : Nat) : movement a [] = 0 := rfl -@[simp] theorem movement_cons (a : Nat) (l : Line) (ls : List Line) : - movement a (l :: ls) = (if l.account = a then l.signed else 0) + movement a ls := rfl - -theorem movement_append (a : Nat) (xs ys : List Line) : - movement a (xs ++ ys) = movement a xs + movement a ys := by - induction xs with - | nil => simp - | cons x xs ih => simp only [List.cons_append, movement_cons, ih]; omega - -/-- **Movements partition the net.** Summed over a chart's distinct codes -that cover every line's account, the per-account movements add up to the -net of the lines: grouping by account loses and invents nothing. -/ -theorem sum_movements (as : List Nat) (hd : Distinct as) : - ∀ ls : List Line, (∀ l ∈ ls, l.account ∈ as) → - sumOver (fun a => movement a ls) as = net ls := by - intro ls - induction ls with - | nil => intro _; simpa using sumOver_zero as - | cons l ls ih => - intro hcov - simp only [movement_cons, net_cons] - rw [sumOver_add, ih (fun m hm => hcov m (List.mem_cons_of_mem _ hm)), - sumOver_indicator l.account l.signed as hd (hcov l (List.mem_cons_self ..))] - -/-- So the movements of an accepted entry sum to zero. -/ -theorem Accepted.movements_sum_zero {ls : List Line} (h : Accepted ls) (as : List Nat) - (hd : Distinct as) (hcov : ∀ l ∈ ls, l.account ∈ as) : - sumOver (fun a => movement a ls) as = 0 := by - rw [sum_movements as hd ls hcov, h.net_zero] - -/-! ## Reversals -/ - -/-- A line with its side flipped. -/ -def Line.reverse (l : Line) : Line := { l with side := l.side.flip } - -/-- A reversing entry's lines (`kernel::reversal`). -/ -def reversal (ls : List Line) : List Line := ls.map Line.reverse - -theorem Line.reverse_signed (l : Line) : l.reverse.signed = -l.signed := by - simp [Line.reverse, Line.signed, signed_flip] - -theorem net_reversal (ls : List Line) : net (reversal ls) = -net ls := by - induction ls with - | nil => rfl - | cons l ls ih => - simp only [reversal, List.map_cons] at ih ⊢ - simp only [net_cons, ih, Line.reverse_signed]; omega - -theorem movement_reversal (a : Nat) (ls : List Line) : - movement a (reversal ls) = -movement a ls := by - induction ls with - | nil => rfl - | cons l ls ih => - simp only [reversal, List.map_cons] at ih ⊢ - simp only [movement_cons, ih, Line.reverse_signed] - by_cases h : l.account = a <;> simp [Line.reverse, h] <;> omega - -/-- **An entry and its reversal net every account to zero.** -/ -theorem movement_with_reversal (a : Nat) (ls : List Line) : - movement a (ls ++ reversal ls) = 0 := by - rw [movement_append, movement_reversal]; omega - -theorem debits_reversal (ls : List Line) : debits (reversal ls) = credits ls := by - induction ls with - | nil => rfl - | cons l ls ih => - simp only [reversal, List.map_cons] at ih ⊢ - obtain ⟨a, s, m⟩ := l - cases s <;> simp [debits, credits, Line.reverse, Side.flip, ih] - -theorem credits_reversal (ls : List Line) : credits (reversal ls) = debits ls := by - induction ls with - | nil => rfl - | cons l ls ih => - simp only [reversal, List.map_cons] at ih ⊢ - obtain ⟨a, s, m⟩ := l - cases s <;> simp [debits, credits, Line.reverse, Side.flip, ih] - -/-- **A reversal is accepted exactly when the original is.** -/ -theorem accepted_reversal_iff (ls : List Line) : Accepted (reversal ls) ↔ Accepted ls := by - unfold Accepted - rw [debits_reversal, credits_reversal] - have hlen : (reversal ls).length = ls.length := List.length_map .. - have hpos : (∀ l ∈ reversal ls, 0 < l.amount) ↔ (∀ l ∈ ls, 0 < l.amount) := by - simp [reversal, Line.reverse] - rw [hlen, hpos] - constructor <;> rintro ⟨a, b, c⟩ <;> exact ⟨a, b, c.symm⟩ - -end Bookspec diff --git a/bookspec/lean/Bookspec/Laws.lean b/bookspec/lean/Bookspec/Laws.lean deleted file mode 100644 index 9669a64..0000000 --- a/bookspec/lean/Bookspec/Laws.lean +++ /dev/null @@ -1,62 +0,0 @@ -import Bookspec.Ledger - -/-! -# bookspec's five laws - -The five laws of the first edition (`bookspec/spec/00-model.md`, "The five -laws"), each stated once, by name, as a theorem about *every* reachable -state of the books, ported from `verify/lean` (P13 step 4). Money is `Int`, -an exact count of the smallest unit; any finite set of exact decimals -scales to integers without loss, and every law is a law of addition. - -1. Debits equal credits (BS-entry-1). -2. Assets = Liabilities + Equity + (Revenue - Expenses) (BS-statement-2). -3. The trial balance balances (BS-statement-1). -4. Net income articulates to equity: a close credits equity with exactly - revenue less expenses (BS-period-1). -5. The cash-flow sections sum to the change in cash (BS-statement-3). --/ - -namespace Bookspec - -/-- Law 1. In every reachable state the posted lines' debits equal their -credits (`net` is debits less credits). -/ -theorem law_debits_equal_credits {L : Ledger} (r : Reachable L) : - net (allLines L.posted) = 0 := - r.books_net_zero - -/-- Law 2. The accounting equation, in every reachable state. -/ -theorem law_accounting_equation {L : Ledger} (r : Reachable L) : - let ls := allLines L.posted - typeTotal .asset ls L.chart - = typeTotal .liability ls L.chart + typeTotal .equity ls L.chart - + (typeTotal .revenue ls L.chart - typeTotal .expense ls L.chart) := - r.accounting_equation - -/-- Law 3. The trial balance balances, in every reachable state. -/ -theorem law_trial_balance {L : Ledger} (r : Reachable L) : - tbDebits (allLines L.posted) (codes L.chart) - = tbCredits (allLines L.posted) (codes L.chart) := - r.trial_balance - -/-- Law 4. A close credits equity with exactly the period's net income. -/ -theorem law_net_income_articulates (L : Ledger) (p : Period) (ts : List (Nat × Int)) - (hsum : total (ts.map Prod.snd) - = net (closings (nominalCodes L.chart) (allLines (through p.stop L.posted)))) : - let ls := allLines (through p.stop L.posted) - total (ts.map Prod.snd) = typeTotal .revenue ls L.chart - typeTotal .expense ls L.chart := - close_credits_net_income L p ts hsum - -/-- Law 5. The three sections of the statement of cash flows sum to the -change in the cash accounts. -/ -theorem law_cash_flow_is_change_in_cash {L : Ledger} (r : Reachable L) (b c : Nat) - (hbc : b ≤ c) (F : List Line → Line → Flow) (C : List Line → List Int) - (hC : ∀ e, sum3 (C e) = 0) (cash : List Nat) (hd : Distinct cash) - (hF : ∀ e l, F e l = .cash ↔ l.account ∈ cash) : - let es := (between b c L.posted).map Entry.lines - sectionTotal F C 0 es + sectionTotal F C 1 es + sectionTotal F C 2 es - = sumOver (fun a => movement a (allLines (through c L.posted))) cash - - sumOver (fun a => movement a (allLines (through b L.posted))) cash := - r.cashflow_is_change_in_cash b c hbc F C hC cash hd hF - -end Bookspec diff --git a/bookspec/lean/Bookspec/Ledger.lean b/bookspec/lean/Bookspec/Ledger.lean deleted file mode 100644 index d3ee8c7..0000000 --- a/bookspec/lean/Bookspec/Ledger.lean +++ /dev/null @@ -1,562 +0,0 @@ -import Bookspec.Closing - -/-! -# The ledger as a state machine - -The engine's command surface (`Ledger`), modelled as the steps it allows: -add an account, start a draft, post, reverse, open a period, close one. -Each step carries the checks the engine makes before it mutates. The -theorems below hold in *every* state reachable from empty books, by any -sequence of commands, of any length. - -Mirrors `Ledger::add_account`, `try_create_entry`, `post_entry`, -`reverse_entry`, `create_period`, and `close_period_to`; and, as one step -`record`, everything the books keep about how they are kept: settings, -conventions, decisions, open items and approvals (`apply_settings`, -`apply_convention`, `apply_decision`, `apply_open_item`, -`apply_approval`, and their retirements and resolutions), each a record -the step adds and nothing else; and an account's rename -(`rename_account`, `account.renamed`, rules.md B-31), since the model's -chart is codes and types and a name is no part of it. --/ - -namespace Bookspec - -/-- A journal entry: its id, its date (a day number), its lines. -/ -structure Entry where - id : Nat - date : Nat - lines : List Line - deriving DecidableEq - -/-- Every line of some entries, in order. -/ -def allLines : List Entry → List Line - | [] => [] - | e :: es => e.lines ++ allLines es - -theorem allLines_append (xs ys : List Entry) : allLines (xs ++ ys) = allLines xs ++ allLines ys := by - induction xs with - | nil => rfl - | cons x xs ih => simp [allLines, ih] - -theorem mem_allLines {l : Line} {es : List Entry} : l ∈ allLines es ↔ ∃ e ∈ es, l ∈ e.lines := by - induction es with - | nil => simp [allLines] - | cons e es ih => simp [allLines, ih] - -/-- **Any collection of balanced entries nets to zero**: all of them, those -dated through some day, those in some period, any filter at all. -/ -theorem net_allLines (es : List Entry) (h : ∀ e ∈ es, net e.lines = 0) : net (allLines es) = 0 := by - induction es with - | nil => rfl - | cons e es ih => - simp only [allLines, net_append, h e (by simp), ih (fun x hx => h x (by simp [hx]))] - rfl - -/-- An accounting period: inclusive day range, open or closed. -/ -structure Period where - id : Nat - start : Nat - stop : Nat - closed : Bool - deriving DecidableEq - -/-- Whether a day falls in a period. -/ -def Period.contains (p : Period) (d : Nat) : Bool := decide (p.start ≤ d ∧ d ≤ p.stop) - -/-- The books: chart, posted entries in posting order, drafts, periods, -and the record of how they are kept (settings, conventions, decisions, -open items, approvals), each record abstracted to a number. -/ -structure Ledger where - chart : Chart - posted : List Entry - drafts : List Entry - periods : List Period - records : List Nat - -/-- Empty books. -/ -def Ledger.empty : Ledger := ⟨[], [], [], [], []⟩ - -/-- Whether a closed period contains a day. -/ -def Ledger.closedOn (L : Ledger) (d : Nat) : Prop := - ∃ p ∈ L.periods, p.closed = true ∧ p.contains d = true - -/-- Entries dated on or before a day. -/ -def through (d : Nat) (es : List Entry) : List Entry := es.filter (fun e => decide (e.date ≤ d)) - -/-- Mark one period closed. -/ -def closeIn (p : Period) (ps : List Period) : List Period := - ps.map (fun q => if q = p then { q with closed := true } else q) - -/-- The closing entry for period `p` crediting targets `ts`, if there is -anything to close. -/ -def closingFor (L : Ledger) (p : Period) (ts : List (Nat × Int)) (id : Nat) : List Entry := - if closings (nominalCodes L.chart) (allLines (through p.stop L.posted)) = [] then [] - else [⟨id, p.stop, closingEntry (nominalCodes L.chart) ts (allLines (through p.stop L.posted))⟩] - -/-- What the engine allows. -/ -inductive Step : Ledger → Ledger → Prop - /-- `add_account`: codes are unique. -/ - | addAccount (L : Ledger) (a : Nat) (t : AccountType) (h : a ∉ codes L.chart) : - Step L { L with chart := (a, t) :: L.chart } - /-- `try_create_entry` (and lines added to it): not in a closed period. -/ - | draft (L : Ledger) (e : Entry) (h : ¬ L.closedOn e.date) : - Step L { L with drafts := e :: L.drafts } - /-- `post_entry`: a draft, acceptable, on known accounts, not in a closed - period. Appends to the log. -/ - | post (L : Ledger) (e : Entry) (hd : e ∈ L.drafts) (ha : Accepted e.lines) - (hk : ∀ l ∈ e.lines, l.account ∈ codes L.chart) (hc : ¬ L.closedOn e.date) : - Step L { L with posted := L.posted ++ [e], drafts := L.drafts.filter (· ≠ e) } - /-- `reverse_entry`: a posted entry's lines, sides flipped, posted on a - day not in a closed period. -/ - | reverse (L : Ledger) (e : Entry) (he : e ∈ L.posted) (r : Entry) - (hr : r.lines = reversal e.lines) (hc : ¬ L.closedOn r.date) : - Step L { L with posted := L.posted ++ [r] } - /-- `create_period`: periods open. -/ - | openPeriod (L : Ledger) (p : Period) (h : p.closed = false) : - Step L { L with periods := p :: L.periods } - /-- `close_period_to`: an open period with no drafts in it, whose last day - is in no other closed period, with no later period closed, crediting the - net income to equity targets (not themselves revenue or expense) in - parts that sum to it. -/ - | close (L : Ledger) (p : Period) (hp : p ∈ L.periods) (hopen : p.closed = false) - (hdrafts : ∀ d ∈ L.drafts, p.contains d.date = false) - (hother : ∀ q ∈ L.periods, q.closed = true → q.contains p.stop = false) - (hlater : ∀ q ∈ L.periods, q.closed = true → q.stop ≤ p.stop) - (ts : List (Nat × Int)) - (hts : ∀ x ∈ ts, x.1 ∈ codes L.chart ∧ x.1 ∉ nominalCodes L.chart) - (hsum : total (ts.map Prod.snd) - = net (closings (nominalCodes L.chart) (allLines (through p.stop L.posted)))) - (id : Nat) : - Step L { L with posted := L.posted ++ closingFor L p ts id, periods := closeIn p L.periods } - /-- `apply_settings`, `apply_convention`, `apply_decision`, - `apply_open_item`, `apply_approval` and their retirements and - resolutions, and the 1099 records `set_vendor_1099`, - `set_vendor_w9_flag`, `add_instrument` and `set_entry_instrument`: a - record of how the books are kept, and nothing else. `rename_account` - too: a name is outside the chart the model keeps. -/ - | record (L : Ledger) (r : Nat) : Step L { L with records := r :: L.records } - -/-- States reachable from empty books. -/ -inductive Reachable : Ledger → Prop - | empty : Reachable Ledger.empty - | step {L L' : Ledger} : Reachable L → Step L L' → Reachable L' - -/-- What holds in every reachable state. -/ -structure Inv (L : Ledger) : Prop where - distinct : Distinct (codes L.chart) - accepted : ∀ e ∈ L.posted, Accepted e.lines - known : ∀ e ∈ L.posted, ∀ l ∈ e.lines, l.account ∈ codes L.chart - draftsOpen : ∀ d ∈ L.drafts, ¬ L.closedOn d.date - -theorem closedOn_closeIn (L : Ledger) (p : Period) (d : Nat) : - ({ L with periods := closeIn p L.periods } : Ledger).closedOn d → - L.closedOn d ∨ (p ∈ L.periods ∧ p.contains d = true) := by - rintro ⟨q, hq, hc, hd⟩ - simp only [closeIn, List.mem_map] at hq - obtain ⟨q', hq', rfl⟩ := hq - by_cases e : q' = p - · subst e; right; simp at hd; exact ⟨hq', by simpa [Period.contains] using hd⟩ - · left; simp [e] at hc hd; exact ⟨q', hq', hc, hd⟩ - -theorem closingFor_lines (L : Ledger) (p : Period) (ts : List (Nat × Int)) (id : Nat) : - ∀ e ∈ closingFor L p ts id, - e.date = p.stop ∧ e.lines = closingEntry (nominalCodes L.chart) ts - (allLines (through p.stop L.posted)) - ∧ closings (nominalCodes L.chart) (allLines (through p.stop L.posted)) ≠ [] := by - intro e he - unfold closingFor at he - split at he - · simp at he - · simp at he; subst he; exact ⟨rfl, rfl, by assumption⟩ - -/-- **Every step preserves the invariant.** -/ -theorem Step.inv {L L' : Ledger} (s : Step L L') (h : Inv L) : Inv L' := by - cases s with - | addAccount a t ha => - exact ⟨⟨ha, h.distinct⟩, h.accepted, - fun e he l hl => List.mem_cons_of_mem _ (h.known e he l hl), h.draftsOpen⟩ - | draft e hc => - refine ⟨h.distinct, h.accepted, h.known, ?_⟩ - intro d hd - cases hd with - | head => exact hc - | tail _ hd => exact h.draftsOpen d hd - | post e hd ha hk hc => - refine ⟨h.distinct, ?_, ?_, ?_⟩ - · intro x hx - simp only [List.mem_append, List.mem_singleton] at hx - rcases hx with hx | rfl - · exact h.accepted x hx - · exact ha - · intro x hx - simp only [List.mem_append, List.mem_singleton] at hx - rcases hx with hx | rfl - · exact h.known x hx - · exact hk - · intro d hd - exact h.draftsOpen d ((List.mem_filter.1 hd).1) - | reverse e he r hr hc => - refine ⟨h.distinct, ?_, ?_, h.draftsOpen⟩ - · intro x hx - simp only [List.mem_append, List.mem_singleton] at hx - rcases hx with hx | rfl - · exact h.accepted x hx - · rw [hr, accepted_reversal_iff]; exact h.accepted e he - · intro x hx - simp only [List.mem_append, List.mem_singleton] at hx - rcases hx with hx | rfl - · exact h.known x hx - · intro l hl - rw [hr] at hl - simp only [reversal, List.mem_map] at hl - obtain ⟨l', hl', rfl⟩ := hl - exact h.known e he l' hl' - | openPeriod p hp => - refine ⟨h.distinct, h.accepted, h.known, ?_⟩ - rintro d hd ⟨q, hq, hc, hdq⟩ - cases hq with - | head => simp [hp] at hc - | tail _ hq => exact h.draftsOpen d hd ⟨q, hq, hc, hdq⟩ - | close p hp hopen hdrafts hother hlater ts hts hsum id => - refine ⟨h.distinct, ?_, ?_, ?_⟩ - · intro x hx - simp only [List.mem_append] at hx - rcases hx with hx | hx - · exact h.accepted x hx - · obtain ⟨-, hl, hne⟩ := closingFor_lines L p ts id x hx - rw [hl]; exact closingEntry_accepted _ _ _ hsum hne - · intro x hx - simp only [List.mem_append] at hx - rcases hx with hx | hx - · exact h.known x hx - · obtain ⟨-, hl, -⟩ := closingFor_lines L p ts id x hx - intro l hl' - rw [hl] at hl' - simp only [closingEntry, List.mem_append] at hl' - rcases hl' with hl' | hl' - · exact nominalCodes_sub _ _ (closings_account _ _ l hl') - · obtain ⟨y, hy, e⟩ := equityLines_account ts l hl' - rw [e]; exact (hts y hy).1 - · intro d hd hcl - rcases closedOn_closeIn L p d.date hcl with hcl | ⟨-, hc⟩ - · exact h.draftsOpen d hd hcl - · simp [hdrafts d hd] at hc - | record r => exact ⟨h.distinct, h.accepted, h.known, h.draftsOpen⟩ - -/-- **The invariant holds in every reachable state.** -/ -theorem Reachable.inv {L : Ledger} (r : Reachable L) : Inv L := by - induction r with - | empty => exact ⟨trivial, by simp [Ledger.empty], by simp [Ledger.empty], by simp [Ledger.empty]⟩ - | step _ s ih => exact s.inv ih - -/-! ## The books always balance -/ - -/-- **The books net to zero**: in every reachable state, the posted lines' -debits equal their credits. -/ -theorem Reachable.books_net_zero {L : Ledger} (r : Reachable L) : net (allLines L.posted) = 0 := - net_allLines _ (fun e he => (r.inv.accepted e he).net_zero) - -/-- The same through any day: the books as of any date balance too. -/ -theorem Reachable.net_zero_through {L : Ledger} (r : Reachable L) (d : Nat) : - net (allLines (through d L.posted)) = 0 := - net_allLines _ (fun e he => (r.inv.accepted e ((List.mem_filter.1 he).1)).net_zero) - -theorem Reachable.covers {L : Ledger} (r : Reachable L) (es : List Entry) - (hs : ∀ e ∈ es, e ∈ L.posted) : ∀ l ∈ allLines es, l.account ∈ codes L.chart := by - intro l hl - obtain ⟨e, he, hl⟩ := mem_allLines.1 hl - exact r.inv.known e (hs e he) l hl - -/-- **The trial balance balances**, in every reachable state. -/ -theorem Reachable.trial_balance {L : Ledger} (r : Reachable L) : - tbDebits (allLines L.posted) (codes L.chart) = tbCredits (allLines L.posted) (codes L.chart) := - trial_balance_balances _ _ r.inv.distinct (r.covers _ (fun _ h => h)) r.books_net_zero - -/-- **The trial balance as of any date balances.** -/ -theorem Reachable.trial_balance_through {L : Ledger} (r : Reachable L) (d : Nat) : - tbDebits (allLines (through d L.posted)) (codes L.chart) - = tbCredits (allLines (through d L.posted)) (codes L.chart) := - trial_balance_balances _ _ r.inv.distinct - (r.covers _ (fun _ h => (List.mem_filter.1 h).1)) (r.net_zero_through d) - -/-- **The accounting equation holds**, in every reachable state: -Assets = Liabilities + Equity + (Revenue - Expenses). -/ -theorem Reachable.accounting_equation {L : Ledger} (r : Reachable L) : - let ls := allLines L.posted - typeTotal .asset ls L.chart - = typeTotal .liability ls L.chart + typeTotal .equity ls L.chart - + (typeTotal .revenue ls L.chart - typeTotal .expense ls L.chart) := - Bookspec.accounting_equation _ _ r.inv.distinct (r.covers _ (fun _ h => h)) r.books_net_zero - -/-- **The balance sheet as of any date balances.** -/ -theorem Reachable.accounting_equation_through {L : Ledger} (r : Reachable L) (d : Nat) : - let ls := allLines (through d L.posted) - typeTotal .asset ls L.chart - = typeTotal .liability ls L.chart + typeTotal .equity ls L.chart - + (typeTotal .revenue ls L.chart - typeTotal .expense ls L.chart) := - Bookspec.accounting_equation _ _ r.inv.distinct - (r.covers _ (fun _ h => (List.mem_filter.1 h).1)) (r.net_zero_through d) - -/-! ## Posted is forever -/ - -/-- **Posting only appends.** No step edits, reorders, or removes a posted -entry. -/ -theorem Step.extends {L L' : Ledger} (s : Step L L') : ∃ t, L'.posted = L.posted ++ t := by - cases s with - | addAccount => exact ⟨[], by simp⟩ - | draft => exact ⟨[], by simp⟩ - | post e => exact ⟨[e], rfl⟩ - | reverse e _ r => exact ⟨[r], rfl⟩ - | openPeriod => exact ⟨[], by simp⟩ - | close p _ _ _ _ _ ts _ _ id => exact ⟨closingFor L p ts id, rfl⟩ - | record => exact ⟨[], by simp⟩ - -/-- Any number of steps. -/ -inductive Steps : Ledger → Ledger → Prop - | refl (L : Ledger) : Steps L L - | tail {L M N : Ledger} : Steps L M → Step M N → Steps L N - -/-- **Posted is forever**: after any sequence of commands, the old log is -a prefix of the new one. Every posted entry keeps its place and its -lines. -/ -theorem Steps.extends {L L' : Ledger} (s : Steps L L') : ∃ t, L'.posted = L.posted ++ t := by - induction s with - | refl => exact ⟨[], by simp⟩ - | tail _ s ih => - obtain ⟨t, ht⟩ := ih - obtain ⟨u, hu⟩ := s.extends - exact ⟨t ++ u, by rw [hu, ht, List.append_assoc]⟩ - -/-! ## Closed periods are frozen -/ - -/-- Entries dated in a period. -/ -def within (p : Period) (es : List Entry) : List Entry := es.filter (fun e => p.contains e.date) - -theorem within_append (p : Period) (xs ys : List Entry) : - within p (xs ++ ys) = within p xs ++ within p ys := List.filter_append .. - -/-- A closed period stays closed. -/ -theorem Step.keeps_closed {L L' : Ledger} (s : Step L L') (q : Period) (hq : q ∈ L.periods) - (hc : q.closed = true) : q ∈ L'.periods := by - cases s with - | openPeriod p _ => exact List.mem_cons_of_mem _ hq - | close p _ hopen => - have : q ≠ p := fun e => by subst e; simp [hopen] at hc - exact List.mem_map.2 ⟨q, hq, by simp [this]⟩ - | _ => exact hq - -/-- **A step never changes what a closed period contains.** -/ -theorem Step.frozen {L L' : Ledger} (s : Step L L') (q : Period) (hq : q ∈ L.periods) - (hc : q.closed = true) : within q L'.posted = within q L.posted := by - have nothing : ∀ e : Entry, ¬ L.closedOn e.date → within q [e] = [] := by - intro e he - simp only [within, List.filter_cons, List.filter_nil] - split - · exact absurd ⟨q, hq, hc, by assumption⟩ he - · rfl - cases s with - | post e _ _ _ hcl => rw [within_append, nothing e hcl, List.append_nil] - | reverse e _ r _ hcl => rw [within_append, nothing r hcl, List.append_nil] - | close p hp hopen hdrafts hother hlater ts hts hsum id => - rw [within_append] - have : within q (closingFor L p ts id) = [] := by - unfold closingFor - split - · rfl - · simp only [within, List.filter_cons, List.filter_nil] - rw [hother q hq hc]; rfl - rw [this, List.append_nil] - | _ => rfl - -/-- **Closed periods are frozen**: once a period is closed, no sequence of -commands adds, removes, or changes an entry dated in it. -/ -theorem Steps.frozen {L L' : Ledger} (s : Steps L L') (q : Period) (hq : q ∈ L.periods) - (hc : q.closed = true) : within q L'.posted = within q L.posted ∧ q ∈ L'.periods := by - induction s with - | refl => exact ⟨rfl, hq⟩ - | tail _ s ih => - obtain ⟨h1, h2⟩ := ih - exact ⟨(s.frozen q h2 hc).trans h1, s.keeps_closed q h2 hc⟩ - -/-! ## Closing -/ - -/-- **After a close, every revenue and expense account is zero through the -period's last day.** (The trial balance still balances: the close is a -step, and `Reachable.trial_balance` covers every step.) -/ -theorem close_zeroes {L : Ledger} (r : Reachable L) (p : Period) - (ts : List (Nat × Int)) (hts : ∀ x ∈ ts, x.1 ∈ codes L.chart ∧ x.1 ∉ nominalCodes L.chart) - (id : Nat) (a : Nat) (ha : a ∈ nominalCodes L.chart) : - movement a (allLines (through p.stop (L.posted ++ closingFor L p ts id))) = 0 := by - have hd := nominalCodes_distinct _ r.inv.distinct - have hta : ∀ x ∈ ts, x.1 ≠ a := fun x hx e => (hts x hx).2 (e ▸ ha) - rw [through, List.filter_append, ← through, allLines_append] - unfold closingFor - split - · rename_i hnil - simp only [List.filter_nil, allLines, List.append_nil] - exact closings_eq_nil _ _ hnil a ha - · simp only [List.filter_cons, Nat.le_refl, decide_true, ite_true, List.filter_nil, allLines, - List.append_nil] - exact closingEntry_zeroes _ ts _ hd a ha hta - -/-- **A close credits equity with exactly the period's net income**: -revenue minus expenses through its last day, split among the targets. -/ -theorem close_credits_net_income (L : Ledger) (p : Period) (ts : List (Nat × Int)) - (hsum : total (ts.map Prod.snd) - = net (closings (nominalCodes L.chart) (allLines (through p.stop L.posted)))) : - let ls := allLines (through p.stop L.posted) - total (ts.map Prod.snd) = typeTotal .revenue ls L.chart - typeTotal .expense ls L.chart := by - intro ls - rw [hsum, net_closings_is_net_income] - -/-! ## Period statements add up -/ - -/-- Entries dated after day `b`, through day `c`. -/ -def between (b c : Nat) (es : List Entry) : List Entry := - es.filter (fun e => decide (b < e.date ∧ e.date ≤ c)) - -theorem movement_split (a b c : Nat) (h : b ≤ c) (es : List Entry) : - movement a (allLines (through c es)) - = movement a (allLines (through b es)) + movement a (allLines (between b c es)) := by - induction es with - | nil => rfl - | cons e es ih => - simp only [through, between] at ih ⊢ - by_cases h1 : e.date ≤ b - · have h2 : e.date ≤ c := by omega - have h3 : ¬ (b < e.date ∧ e.date ≤ c) := by omega - rw [List.filter_cons_of_pos (by simpa using h2), List.filter_cons_of_pos (by simpa using h1), - List.filter_cons_of_neg (by simpa using h3)] - simp only [allLines, movement_append, ih]; omega - · by_cases h2 : e.date ≤ c - · have h3 : b < e.date ∧ e.date ≤ c := by omega - rw [List.filter_cons_of_pos (by simpa using h2), List.filter_cons_of_neg (by simpa using h1), - List.filter_cons_of_pos (by simpa using h3)] - simp only [allLines, movement_append, ih]; omega - · have h3 : ¬ (b < e.date ∧ e.date ≤ c) := by omega - rw [List.filter_cons_of_neg (by simpa using h2), List.filter_cons_of_neg (by simpa using h1), - List.filter_cons_of_neg (by simpa using h3)] - exact ih - -theorem typeTotal_split (t : AccountType) (x y z : List Line) - (h : ∀ a, movement a x = movement a y + movement a z) (c : Chart) : - typeTotal t x c = typeTotal t y c + typeTotal t z c := by - induction c with - | nil => rfl - | cons p c ih => - obtain ⟨a, t'⟩ := p - simp only [typeTotal, h a, ih] - split - · cases t' <;> simp [balance, AccountType.normal, signed] <;> omega - · omega - -/-- **Period statements add up**: every account type's total (so revenue, -expenses, and net income) through day `c` is its total through day `b` -plus its total for the days after `b` through `c`. A year's P&L is the sum -of its months'. -/ -theorem statements_add_up (t : AccountType) (b c : Nat) (h : b ≤ c) (es : List Entry) - (chart : Chart) : - typeTotal t (allLines (through c es)) chart - = typeTotal t (allLines (through b es)) chart - + typeTotal t (allLines (between b c es)) chart := - typeTotal_split t _ _ _ (fun a => movement_split a b c h es) chart - -/-! ## The statement of cash flows foots (rules.md B-14 rule 9) -/ - -/-- Some entries' cash, summed, is the movement of the cash accounts over -their lines, when the cash tag follows the account. -/ -theorem cashTotal_is_movement (F : List Line → Line → Flow) (cash : List Nat) - (hd : Distinct cash) (hF : ∀ e l, F e l = .cash ↔ l.account ∈ cash) : - ∀ xs : List Entry, - cashTotal F (xs.map Entry.lines) = sumOver (fun a => movement a (allLines xs)) cash := by - intro xs - induction xs with - | nil => simpa [cashTotal, allLines] using (sumOver_zero cash).symm - | cons x xs ih => - simp only [List.map_cons, cashTotal, allLines, movement_append] - rw [sumOver_add, ← ih, cashOf, secNet_cash_is_movement (F x.lines) cash hd (hF x.lines)] - -/-- **The statement of cash flows foots**, in every reachable state: over -the posted entries dated after day `b` through day `c` (any selection of -them, `p`: the engine leaves out closing entries), operating + investing + -financing is the movement of the cash accounts over the same entries, for -any tagging whose cash tag follows the account, and whatever a member or -lender handled for each entry (`C`, rule 11) as long as it nets to zero -(`constructive_nets`). Every posted entry is accepted (`Reachable.inv`), -so each nets to zero. -/ -theorem Reachable.cashflow_foots {L : Ledger} (r : Reachable L) (b c : Nat) (p : Entry → Bool) - (F : List Line → Line → Flow) (C : List Line → List Int) (hC : ∀ e, sum3 (C e) = 0) - (cash : List Nat) (hd : Distinct cash) - (hF : ∀ e l, F e l = .cash ↔ l.account ∈ cash) : - let xs := (between b c L.posted).filter p - sectionTotal F C 0 (xs.map Entry.lines) + sectionTotal F C 1 (xs.map Entry.lines) - + sectionTotal F C 2 (xs.map Entry.lines) - = sumOver (fun a => movement a (allLines xs)) cash := by - intro xs - rw [cashflow_sections_sum_to_cash_change, cashTotal_is_movement F cash hd hF] - · intro e he - obtain ⟨x, hx, rfl⟩ := List.mem_map.1 he - have hb := (List.mem_filter.1 hx).1 - exact (r.inv.accepted x (List.mem_filter.1 hb).1).net_zero - · exact fun e _ => hC e - -/-- **The sections are the change in the cash accounts' balances**: with -every entry between `b` and `c` counted, the three sections sum to the -cash accounts' movement through `c` less their movement through `b` -(their ending balance less their beginning balance). -/ -theorem Reachable.cashflow_is_change_in_cash {L : Ledger} (r : Reachable L) (b c : Nat) - (hbc : b ≤ c) (F : List Line → Line → Flow) (C : List Line → List Int) - (hC : ∀ e, sum3 (C e) = 0) (cash : List Nat) (hd : Distinct cash) - (hF : ∀ e l, F e l = .cash ↔ l.account ∈ cash) : - let es := (between b c L.posted).map Entry.lines - sectionTotal F C 0 es + sectionTotal F C 1 es + sectionTotal F C 2 es - = sumOver (fun a => movement a (allLines (through c L.posted))) cash - - sumOver (fun a => movement a (allLines (through b L.posted))) cash := by - intro es - have h := r.cashflow_foots b c (fun _ => true) F C hC cash hd hF - have hall : (between b c L.posted).filter (fun _ => true) = between b c L.posted := - List.filter_eq_self.2 (fun _ _ => rfl) - simp only [hall] at h - rw [h] - have hs : ∀ a, movement a (allLines (through c L.posted)) - = movement a (allLines (through b L.posted)) + movement a (allLines (between b c L.posted)) := - fun a => movement_split a b c hbc L.posted - rw [sumOver_congr _ _ cash (fun a _ => hs a), sumOver_add] - omega - -/-- **A reversal and its original in one period drop out together**: for -an entry and its reversal (lines flipped, tagged as the original's, and -what was handled for it mirrored, `constructive_neg`), the sum of their -flows in each section is zero. -/ -theorem cashflow_reversal_nets (F : List Line → Line → Flow) (C : List Line → List Int) - (e : List Line) (hF : ∀ l ∈ e, F (reversal e) l.reverse = F e l) - (hC : C (reversal e) = (C e).map (fun x => -x)) (k : Nat) : - sectionTotal F C k [e, reversal e] = 0 := by - simp only [sectionTotal, stmt_flows_reversal F C e hF hC, getD_neg] - omega - -/-! ## How the books are kept changes no figure -/ - -/-- **A record changes nothing the books hold**: a settings change, a -convention, a decision, an open item or an approval leaves the chart, every -posted entry, every draft and every period exactly as they were. (The -`record` step touches only `records` by definition: this pins the model. -The engine's side is checked in Rust by `settings_replay_is_pure`. -Settings that change how the same entries are presented, such as the -fiscal year, are outside the model.) -/ -theorem Step.record_changes_no_figure (L : Ledger) (r : Nat) : - let L' : Ledger := { L with records := r :: L.records } - L'.chart = L.chart ∧ L'.posted = L.posted ∧ L'.drafts = L.drafts ∧ L'.periods = L.periods := - ⟨rfl, rfl, rfl, rfl⟩ - -/-- **Any number of records changes no figure**, and they keep the -invariant: a reachable state plus records is reachable, with the same -books. -/ -theorem Reachable.records {L : Ledger} (h : Reachable L) (rs : List Nat) : - Reachable { L with records := rs ++ L.records } := by - induction rs with - | nil => simpa using h - | cons r rs ih => exact Reachable.step ih (Step.record _ r) - -end Bookspec diff --git a/bookspec/lean/Bookspec/Statements.lean b/bookspec/lean/Bookspec/Statements.lean deleted file mode 100644 index 011e4a3..0000000 --- a/bookspec/lean/Bookspec/Statements.lean +++ /dev/null @@ -1,683 +0,0 @@ -import Bookspec.Entry - -/-! -# Balances and statements - -Mirrors `kernel::normal_balance`, `kernel::columns`, -`Ledger::account_balance`, and `reports::trial_balance`; and the statement -of cash flows (`kernel::take`, `kernel::apply_cash`, `kernel::entry_flows`, -`kernel::constructive`, `cashflow::cash_flow`'s footing and reconciliation -checks). --/ - -namespace Bookspec - -/-- A chart of accounts: each account's code and type. -/ -abbrev Chart := List (Nat × AccountType) - -/-- The chart's codes. -/ -def codes (c : Chart) : List Nat := c.map Prod.fst - -@[simp] theorem codes_nil : codes [] = [] := rfl -@[simp] theorem codes_cons (a : Nat) (t : AccountType) (c : Chart) : - codes ((a, t) :: c) = a :: codes c := rfl - -/-- An account's balance on its normal side (`kernel::normal_balance`). -/ -def balance (t : AccountType) (m : Int) : Int := signed t.normal m - -/-- **A normal-side line raises a balance by its amount; an opposite-side -line lowers it.** -/ -theorem balance_after_line (t : AccountType) (m : Int) (s : Side) (a : Int) : - balance t (m + signed s a) = balance t m + (if s = t.normal then a else -a) := by - cases t <;> cases s <;> simp [balance, AccountType.normal, signed] <;> omega - -/-- The total balance of every account of type `t`. -/ -def typeTotal (t : AccountType) (ls : List Line) : Chart → Int - | [] => 0 - | (a, t') :: rest => (if t' = t then balance t' (movement a ls) else 0) + typeTotal t ls rest - -/-- The accounting equation's two sides differ by the net movement of the -chart's accounts. -/ -theorem equation_gap (ls : List Line) (c : Chart) : - typeTotal .asset ls c + typeTotal .expense ls c - - typeTotal .liability ls c - typeTotal .equity ls c - typeTotal .revenue ls c - = sumOver (fun a => movement a ls) (codes c) := by - induction c with - | nil => rfl - | cons x c ih => - obtain ⟨a, t⟩ := x - cases t <;> simp [typeTotal, balance, AccountType.normal, signed] <;> omega - -/-- **The accounting equation.** For lines that net to zero, over a chart -with distinct codes covering every line's account: -Assets = Liabilities + Equity + (Revenue - Expenses). -/ -theorem accounting_equation (ls : List Line) (c : Chart) (hd : Distinct (codes c)) - (hcov : ∀ l ∈ ls, l.account ∈ codes c) (hnet : net ls = 0) : - typeTotal .asset ls c - = typeTotal .liability ls c + typeTotal .equity ls c - + (typeTotal .revenue ls c - typeTotal .expense ls c) := by - have := equation_gap ls c - rw [sum_movements _ hd ls hcov, hnet] at this - omega - -/-! ## Trial balance -/ - -/-- The debit column for a movement (`kernel::columns`). -/ -def colDebit (m : Int) : Int := if 0 < m then m else 0 -/-- The credit column for a movement (`kernel::columns`). -/ -def colCredit (m : Int) : Int := if 0 < m then 0 else -m - -theorem columns_spec (m : Int) : - 0 ≤ colDebit m ∧ 0 ≤ colCredit m ∧ (colDebit m = 0 ∨ colCredit m = 0) - ∧ colDebit m - colCredit m = m := by - unfold colDebit colCredit; split <;> omega - -/-- The trial balance's debit column total. -/ -def tbDebits (ls : List Line) (as : List Nat) : Int := sumOver (fun a => colDebit (movement a ls)) as -/-- The trial balance's credit column total. -/ -def tbCredits (ls : List Line) (as : List Nat) : Int := - sumOver (fun a => colCredit (movement a ls)) as - -theorem tb_gap (ls : List Line) (as : List Nat) : - tbDebits ls as - tbCredits ls as = sumOver (fun a => movement a ls) as := by - induction as with - | nil => rfl - | cons a as ih => - simp only [tbDebits, tbCredits, sumOver_cons] at ih ⊢ - have := (columns_spec (movement a ls)).2.2.2 - omega - -/-- **The trial balance balances** for lines that net to zero. -/ -theorem trial_balance_balances (ls : List Line) (as : List Nat) (hd : Distinct as) - (hcov : ∀ l ∈ ls, l.account ∈ as) (hnet : net ls = 0) : - tbDebits ls as = tbCredits ls as := by - have := tb_gap ls as - rw [sum_movements as hd ls hcov, hnet] at this - omega - -/-- And conversely: a balanced trial balance means the lines net to zero. -The trial balance is a complete check of the books' arithmetic. -/ -theorem trial_balance_iff (ls : List Line) (as : List Nat) (hd : Distinct as) - (hcov : ∀ l ∈ ls, l.account ∈ as) : - tbDebits ls as = tbCredits ls as ↔ net ls = 0 := by - have := tb_gap ls as - rw [sum_movements as hd ls hcov] at this - omega - -/-! ## The statement of cash flows (rules.md B-14) - -Mirrors `kernel::Flow`, `kernel::take`, `kernel::apply_cash` and -`kernel::entry_flows`, and the identities `cashflow::cash_flow` checks at -runtime: the sections sum to the cash accounts' change, a reversal's flows -are its original's negated, and net income reconciles to operating cash. - -Lines are tagged per entry (`F : List Line → Line → Flow`), as the engine -tags them: a revenue or expense line is operating in one entry and -investing in another (rule 4), and a reversal is tagged by its chain's -root. The theorems hold for every such tagging; their hypotheses say what -the engine guarantees (the cash tag follows the account; revenue and -expense lines are operating or investing; a reversal's lines take its -original's tags). A line the engine splits between two sections (the bill -split, rule 12) is two lines here; a split keeps the entry's net. -/ - -/-- Where a line falls on the statement of cash flows (`kernel::Flow`). -/ -inductive Flow where - | cash - | operating - | investing - | financing - deriving DecidableEq, Repr - -/-- The net movement of the lines `f` tags `k`. -/ -def secNet (f : Line → Flow) (k : Flow) : List Line → Int - | [] => 0 - | l :: ls => (if f l = k then l.signed else 0) + secNet f k ls - -@[simp] theorem secNet_nil (f : Line → Flow) (k : Flow) : secNet f k [] = 0 := rfl -@[simp] theorem secNet_cons (f : Line → Flow) (k : Flow) (l : Line) (ls : List Line) : - secNet f k (l :: ls) = (if f l = k then l.signed else 0) + secNet f k ls := rfl - -/-- **Every line is in exactly one place**: an entry's net is its cash plus -its three sections' movements. -/ -theorem net_splits_by_flow (f : Line → Flow) (ls : List Line) : - net ls = secNet f .cash ls + secNet f .operating ls + secNet f .investing ls - + secNet f .financing ls := by - induction ls with - | nil => rfl - | cons l ls ih => - simp only [net_cons, secNet_cons, ih] - cases f l <;> simp <;> omega - -/-- The cash one section takes from what is left (`kernel::take`): only a -section moving against the cash, never more than its own movement. -/ -def take (rem n : Int) : Int := - if 0 < rem ∧ n < 0 then min rem (-n) else if rem < 0 ∧ 0 < n then max rem (-n) else 0 - -/-- `kernel::apply_cash`: the cash applied to each movement in order, and -what is left. -/ -def applyCash : Int → List Int → List Int × Int - | rem, [] => ([], rem) - | rem, n :: ns => (take rem n :: (applyCash (rem - take rem n) ns).1, - (applyCash (rem - take rem n) ns).2) - -/-- A sum of integers. -/ -def isum : List Int → Int - | [] => 0 - | x :: xs => x + isum xs - -@[simp] theorem isum_nil : isum [] = 0 := rfl -@[simp] theorem isum_cons (x : Int) (xs : List Int) : isum (x :: xs) = x + isum xs := rfl - -theorem isum_append (xs ys : List Int) : isum (xs ++ ys) = isum xs + isum ys := by - induction xs with - | nil => simp - | cons x xs ih => simp only [List.cons_append, isum_cons, ih]; omega - -theorem isum_neg (xs : List Int) : isum (xs.map (fun x => -x)) = -isum xs := by - induction xs with - | nil => rfl - | cons x xs ih => simp only [List.map_cons, isum_cons, ih]; omega - -/-- Applying cash invents and loses nothing: the parts and what is left -sum to the cash. -/ -theorem applyCash_conserves (rem : Int) (ns : List Int) : - isum (applyCash rem ns).1 + (applyCash rem ns).2 = rem := by - induction ns generalizing rem with - | nil => simp [applyCash] - | cons n ns ih => - simp only [applyCash, isum_cons] - have := ih (rem - take rem n) - omega - -theorem applyCash_length (rem : Int) (ns : List Int) : - (applyCash rem ns).1.length = ns.length := by - induction ns generalizing rem with - | nil => rfl - | cons n ns ih => simp [applyCash, ih] - -theorem take_zero (n : Int) : take 0 n = 0 := by - simp [take] - -/-- With no cash, nothing is applied and nothing is left. -/ -theorem applyCash_zero (ns : List Int) : (applyCash 0 ns).2 = 0 := by - induction ns with - | nil => rfl - | cons n ns ih => simp [applyCash, take_zero, ih] - -/-- Cash in is used up when the movements against it cover it. -/ -theorem applyCash_exhausts_pos (rem : Int) (ns : List Int) (h0 : 0 ≤ rem) - (h : rem + isum ns ≤ 0) : (applyCash rem ns).2 = 0 := by - induction ns generalizing rem with - | nil => simp [applyCash] at h ⊢; omega - | cons n ns ih => - simp only [applyCash] - simp only [isum_cons] at h - by_cases hr : rem = 0 - · subst hr; simp [take_zero, applyCash_zero] - · by_cases hn : n < 0 - · by_cases hle : -n ≤ rem - · have ht : take rem n = -n := by - simp only [take]; rw [ite_eq_left (by omega)]; omega - rw [ht]; exact ih _ (by omega) (by omega) - · have ht : take rem n = rem := by - simp only [take]; rw [ite_eq_left (by omega)]; omega - rw [ht, Int.sub_self]; exact applyCash_zero ns - · have ht : take rem n = 0 := by - simp only [take]; rw [ite_eq_right (by omega), ite_eq_right (by omega)] - rw [ht, Int.sub_zero]; exact ih _ h0 (by omega) - -/-- Cash out is used up when the movements against it cover it. -/ -theorem applyCash_exhausts_neg (rem : Int) (ns : List Int) (h0 : rem ≤ 0) - (h : 0 ≤ rem + isum ns) : (applyCash rem ns).2 = 0 := by - induction ns generalizing rem with - | nil => simp [applyCash] at h ⊢; omega - | cons n ns ih => - simp only [applyCash] - simp only [isum_cons] at h - by_cases hr : rem = 0 - · subst hr; simp [take_zero, applyCash_zero] - · by_cases hn : 0 < n - · by_cases hle : rem ≤ -n - · have ht : take rem n = -n := by - simp only [take]; rw [ite_eq_right (by omega), ite_eq_left (by omega)]; omega - rw [ht]; exact ih _ (by omega) (by omega) - · have ht : take rem n = rem := by - simp only [take]; rw [ite_eq_right (by omega), ite_eq_left (by omega)]; omega - rw [ht, Int.sub_self]; exact applyCash_zero ns - · have ht : take rem n = 0 := by - simp only [take]; rw [ite_eq_right (by omega), ite_eq_right (by omega)] - rw [ht, Int.sub_zero]; exact ih _ h0 (by omega) - -/-- **Balanced, the cash is used up**: when the cash and the movements sum -to zero, nothing is left over. -/ -theorem applyCash_exhausts (rem : Int) (ns : List Int) (h : rem + isum ns = 0) : - (applyCash rem ns).2 = 0 := by - rcases Int.le_total 0 rem with h0 | h0 - · exact applyCash_exhausts_pos rem ns h0 (by omega) - · exact applyCash_exhausts_neg rem ns h0 (by omega) - -theorem take_neg (r n : Int) : take (-r) (-n) = -take r n := by - simp only [take] - by_cases h1 : 0 < r ∧ n < 0 - · rw [ite_eq_right (by omega), ite_eq_left (by omega), ite_eq_left h1]; omega - · by_cases h2 : r < 0 ∧ 0 < n - · rw [ite_eq_left (by omega), ite_eq_right h1, ite_eq_left h2]; omega - · rw [ite_eq_right (by omega), ite_eq_right (by omega), ite_eq_right h1, ite_eq_right h2]; omega - -/-- Applying minus the cash to minus the movements gives minus the parts: -what lets a reversal mirror its original. -/ -theorem applyCash_neg (rem : Int) (ns : List Int) : - applyCash (-rem) (ns.map (fun x => -x)) - = ((applyCash rem ns).1.map (fun x => -x), -(applyCash rem ns).2) := by - induction ns generalizing rem with - | nil => rfl - | cons n ns ih => - simp only [List.map_cons, applyCash, take_neg] - have : -rem - -take rem n = -(rem - take rem n) := by omega - rw [this, ih] - -/-- An entry's cash: the net of the lines tagged cash. -/ -def cashOf (F : List Line → Line → Flow) (e : List Line) : Int := secNet (F e) .cash e - -/-- An entry's section movements, `[operating, investing, financing]`. -/ -def moves (F : List Line → Line → Flow) (e : List Line) : List Int := - [secNet (F e) .operating e, secNet (F e) .investing e, secNet (F e) .financing e] - -/-- An entry's cash flows per section (`kernel::entry_flows`). -/ -def flows (F : List Line → Line → Flow) (e : List Line) : List Int := - (applyCash (cashOf F e) (moves F e)).1 - -/-- **An entry's cash flows sum to its cash** (`entry_flows_sum`): for a -balanced entry, the sections' cash is exactly the cash that moved. -/ -theorem entry_flows_sum (F : List Line → Line → Flow) (e : List Line) (h : net e = 0) : - isum (flows F e) = cashOf F e := by - have hb := net_splits_by_flow (F e) e - have hx : (applyCash (cashOf F e) (moves F e)).2 = 0 := by - apply applyCash_exhausts - simp only [moves, cashOf, isum_cons, isum_nil] - omega - have := applyCash_conserves (cashOf F e) (moves F e) - simp only [flows] - omega - -theorem flows_length (F : List Line → Line → Flow) (e : List Line) : (flows F e).length = 3 := by - simp [flows, applyCash_length, moves] - -theorem secNet_reversal (f g : Line → Flow) (k : Flow) (ls : List Line) - (h : ∀ l ∈ ls, g l.reverse = f l) : secNet g k (reversal ls) = -secNet f k ls := by - induction ls with - | nil => rfl - | cons l ls ih => - simp only [reversal, List.map_cons] at ih ⊢ - simp only [secNet_cons, h l (by simp), Line.reverse_signed, - ih (fun m hm => h m (by simp [hm]))] - split <;> omega - -/-- **A reversal's flows are its original's, negated** (`flows_reversal`), -when its lines take its original's tags: what lets an entry and its -reversal drop out of the statement together (rules.md B-14 rule 6). -/ -theorem flows_reversal (F : List Line → Line → Flow) (e : List Line) - (hF : ∀ l ∈ e, F (reversal e) l.reverse = F e l) : - flows F (reversal e) = (flows F e).map (fun x => -x) := by - simp only [flows, cashOf, moves] - rw [secNet_reversal (F e) (F (reversal e)) .cash e hF, - secNet_reversal (F e) (F (reversal e)) .operating e hF, - secNet_reversal (F e) (F (reversal e)) .investing e hF, - secNet_reversal (F e) (F (reversal e)) .financing e hF] - have := applyCash_neg (secNet (F e) .cash e) - [secNet (F e) .operating e, secNet (F e) .investing e, secNet (F e) .financing e] - simp only [List.map_cons, List.map_nil] at this - rw [this] - -theorem isum_three (xs : List Int) (h : xs.length = 3) : - isum xs = xs.getD 0 0 + xs.getD 1 0 + xs.getD 2 0 := by - match xs, h with - | [a, b, c], _ => simp; omega - -/-! ### What a member or lender pays or receives for the company (rule 11) - -`kernel::constructive` (D-42): what is left unsettled of an entry's -kinds that can pay or receive for the company (`s`, a financing liability -or a member's capital) settles what is left of the kinds they pay or -receive for (`u`), as if through the cash accounts. The engine sums the -parts by section into an entry's constructive triple; the entry theorems -below take any triple that nets to zero (and, for reversals, mirrors), -which `constructive_nets` and `constructive_neg` give the kernel's. -/ - -/-- The credits among some movements, `Σ max(-m, 0)`: what cash in can -settle. -/ -def creditTotal : List Int → Int - | [] => 0 - | m :: ms => max (-m) 0 + creditTotal ms - -/-- The debits among some movements, `Σ max(m, 0)`: what cash out can -settle. -/ -def debitTotal : List Int → Int - | [] => 0 - | m :: ms => max m 0 + debitTotal ms - -theorem creditTotal_nonneg (ms : List Int) : 0 ≤ creditTotal ms := by - induction ms with - | nil => simp [creditTotal] - | cons m ms ih => simp only [creditTotal]; omega - -theorem debitTotal_nonneg (ms : List Int) : 0 ≤ debitTotal ms := by - induction ms with - | nil => simp [debitTotal] - | cons m ms ih => simp only [debitTotal]; omega - -theorem creditTotal_neg (ms : List Int) : - creditTotal (ms.map (fun x => -x)) = debitTotal ms := by - induction ms with - | nil => rfl - | cons m ms ih => simp only [List.map_cons, creditTotal, debitTotal, ih]; omega - -theorem debitTotal_neg (ms : List Int) : - debitTotal (ms.map (fun x => -x)) = creditTotal ms := by - induction ms with - | nil => rfl - | cons m ms ih => - simp only [List.map_cons, creditTotal, debitTotal, ih] - -/-- Cash in is used up when the credits cover it (whatever the debits). -/ -theorem applyCash_exhausts_in (rem : Int) (ns : List Int) (h0 : 0 ≤ rem) - (h : rem ≤ creditTotal ns) : (applyCash rem ns).2 = 0 := by - induction ns generalizing rem with - | nil => simp [applyCash, creditTotal] at h ⊢; omega - | cons n ns ih => - simp only [applyCash] - simp only [creditTotal] at h - by_cases hr : rem = 0 - · subst hr; simp [take_zero, applyCash_zero] - · by_cases hn : n < 0 - · by_cases hle : -n ≤ rem - · have ht : take rem n = -n := by - simp only [take]; rw [ite_eq_left (by omega)]; omega - rw [ht]; exact ih _ (by omega) (by omega) - · have ht : take rem n = rem := by - simp only [take]; rw [ite_eq_left (by omega)]; omega - rw [ht, Int.sub_self]; exact applyCash_zero ns - · have ht : take rem n = 0 := by - simp only [take]; rw [ite_eq_right (by omega), ite_eq_right (by omega)] - rw [ht, Int.sub_zero]; exact ih _ h0 (by omega) - -/-- Cash out is used up when the debits cover it (whatever the credits). -/ -theorem applyCash_exhausts_out (rem : Int) (ns : List Int) (h0 : rem ≤ 0) - (h : -rem ≤ debitTotal ns) : (applyCash rem ns).2 = 0 := by - induction ns generalizing rem with - | nil => simp [applyCash, debitTotal] at h ⊢; omega - | cons n ns ih => - simp only [applyCash] - simp only [debitTotal] at h - by_cases hr : rem = 0 - · subst hr; simp [take_zero, applyCash_zero] - · by_cases hn : 0 < n - · by_cases hle : rem ≤ -n - · have ht : take rem n = -n := by - simp only [take]; rw [ite_eq_right (by omega), ite_eq_left (by omega)]; omega - rw [ht]; exact ih _ (by omega) (by omega) - · have ht : take rem n = rem := by - simp only [take]; rw [ite_eq_right (by omega), ite_eq_left (by omega)]; omega - rw [ht, Int.sub_self]; exact applyCash_zero ns - · have ht : take rem n = 0 := by - simp only [take]; rw [ite_eq_right (by omega), ite_eq_right (by omega)] - rw [ht, Int.sub_zero]; exact ih _ h0 (by omega) - -/-- Which way the money went (`kernel::OnBehalf`). -/ -inductive OnBehalf where - | paid - | received - deriving DecidableEq, Repr - -/-- The other way: what a reversal does. -/ -def OnBehalf.mirror : OnBehalf → OnBehalf - | .paid => .received - | .received => .paid - -/-- The cash the sources take, + in (`kernel::constructive`): paid, the -smaller of the sources' credits and the uses' debits; received, minus the -smaller of the sources' debits and the uses' credits. -/ -def onBehalfCash : OnBehalf → List Int → List Int → Int - | .paid, s, u => min (creditTotal s) (debitTotal u) - | .received, s, u => -min (debitTotal s) (creditTotal u) - -/-- `kernel::constructive`: the sources' parts and the uses' parts. -/ -def constructive (w : OnBehalf) (s u : List Int) : List Int × List Int := - ((applyCash (onBehalfCash w s u) s).1, (applyCash (-onBehalfCash w s u) u).1) - -/-- **What a member or lender handled for the company nets to zero** -(`constructive_nets`; Kani `constructive_nets_to_zero`): the sources take -exactly what the uses give, so an entry's flows still sum to its cash. -/ -theorem constructive_nets (w : OnBehalf) (s u : List Int) : - isum (constructive w s u).1 + isum (constructive w s u).2 = 0 := by - have hs := applyCash_conserves (onBehalfCash w s u) s - have hu := applyCash_conserves (-onBehalfCash w s u) u - have cs := creditTotal_nonneg s - have ds := debitTotal_nonneg s - have cu := creditTotal_nonneg u - have du := debitTotal_nonneg u - have ⟨xs, xu⟩ : (applyCash (onBehalfCash w s u) s).2 = 0 ∧ - (applyCash (-onBehalfCash w s u) u).2 = 0 := by - cases w with - | paid => - simp only [onBehalfCash] - exact ⟨applyCash_exhausts_in _ _ (by omega) (by omega), - applyCash_exhausts_out _ _ (by omega) (by omega)⟩ - | received => - simp only [onBehalfCash] - exact ⟨applyCash_exhausts_out _ _ (by omega) (by omega), - applyCash_exhausts_in _ _ (by omega) (by omega)⟩ - simp only [constructive] - omega - -theorem onBehalfCash_mirror (w : OnBehalf) (s u : List Int) : - onBehalfCash w.mirror (s.map (fun x => -x)) (u.map (fun x => -x)) - = -onBehalfCash w s u := by - cases w <;> simp only [OnBehalf.mirror, onBehalfCash, creditTotal_neg, debitTotal_neg] <;> omega - -/-- **The other way, minus the sources and uses give minus the parts** -(`constructive_neg`; Kani `constructive_mirrors`): a payment made for the -company and its reversal drop out together. -/ -theorem constructive_neg (w : OnBehalf) (s u : List Int) : - constructive w.mirror (s.map (fun x => -x)) (u.map (fun x => -x)) - = ((constructive w s u).1.map (fun x => -x), (constructive w s u).2.map (fun x => -x)) := by - simp only [constructive, onBehalfCash_mirror] - rw [applyCash_neg (onBehalfCash w s u) s, applyCash_neg (-onBehalfCash w s u) u] - -/-- Three sections' figures, summed. -/ -def sum3 (xs : List Int) : Int := xs.getD 0 0 + xs.getD 1 0 + xs.getD 2 0 - -@[simp] theorem sum3_cons (a b c : Int) : sum3 [a, b, c] = a + b + c := by - simp [sum3] - -/-- **The kernel's parts, by section, net to zero**: split the uses into -operating ones then investing ones (`kernel::constructive` takes them in -that order), and the triple (operating, investing, financing = the -sources) the engine adds to an entry's flows sums to zero. -/ -theorem constructive_sections_net (w : OnBehalf) (s uo ui : List Int) : - sum3 [isum ((constructive w s (uo ++ ui)).2.take uo.length), - isum ((constructive w s (uo ++ ui)).2.drop uo.length), - isum (constructive w s (uo ++ ui)).1] = 0 := by - have h := constructive_nets w s (uo ++ ui) - have hs := isum_append ((constructive w s (uo ++ ui)).2.take uo.length) - ((constructive w s (uo ++ ui)).2.drop uo.length) - rw [List.take_append_drop] at hs - rw [sum3_cons] - omega - -/-- Passes add: two triples that net to zero add to one that does. -/ -theorem sum3_add (xs ys : List Int) (hx : sum3 xs = 0) (hy : sum3 ys = 0) : - sum3 [xs.getD 0 0 + ys.getD 0 0, xs.getD 1 0 + ys.getD 1 0, xs.getD 2 0 + ys.getD 2 0] = 0 := by - rw [sum3_cons] - simp only [sum3] at hx hy - omega - -/-- Two triples, added section by section. -/ -def add3 (xs ys : List Int) : List Int := - [xs.getD 0 0 + ys.getD 0 0, xs.getD 1 0 + ys.getD 1 0, xs.getD 2 0 + ys.getD 2 0] - -/-- An entry's flows on the statement: its cash flows, plus `C e`, what a -member or lender paid or received for it, by section (rule 11). -/ -def stmtFlows (F : List Line → Line → Flow) (C : List Line → List Int) (e : List Line) : - List Int := - add3 (flows F e) (C e) - -/-- **An entry's statement flows sum to its cash** when what was handled -for it nets to zero: rule 11 moves no cash (`stmt_flows_sum`). -/ -theorem stmt_flows_sum (F : List Line → Line → Flow) (C : List Line → List Int) - (e : List Line) (h : net e = 0) (hC : sum3 (C e) = 0) : - sum3 (stmtFlows F C e) = cashOf F e := by - have he := entry_flows_sum F e h - rw [isum_three _ (flows_length F e)] at he - simp only [stmtFlows, add3, sum3_cons] - simp only [sum3] at hC - omega - -theorem getD_neg (xs : List Int) (k : Nat) : - (xs.map (fun x => -x)).getD k 0 = -xs.getD k 0 := by - induction xs generalizing k with - | nil => simp - | cons x xs ih => cases k with - | zero => simp - | succ k => simp only [List.map_cons, List.getD_cons_succ, ih] - -/-- **A reversal's statement flows are its original's, negated**, when its -lines take the original's tags and what was handled for it mirrors -(`constructive_neg`). -/ -theorem stmt_flows_reversal (F : List Line → Line → Flow) (C : List Line → List Int) - (e : List Line) (hF : ∀ l ∈ e, F (reversal e) l.reverse = F e l) - (hC : C (reversal e) = (C e).map (fun x => -x)) : - stmtFlows F C (reversal e) = (stmtFlows F C e).map (fun x => -x) := by - simp only [stmtFlows, add3, flows_reversal F e hF, hC, getD_neg, List.map_cons, - List.map_nil, List.cons.injEq, and_true] - refine ⟨?_, ?_, ?_⟩ <;> omega - -/-- A section's total over some entries: the sum of each entry's cash in -it (`k` = 0 operating, 1 investing, 2 financing), what a member or lender -handled for it included. -/ -def sectionTotal (F : List Line → Line → Flow) (C : List Line → List Int) (k : Nat) : - List (List Line) → Int - | [] => 0 - | e :: es => (stmtFlows F C e).getD k 0 + sectionTotal F C k es - -/-- The entries' cash, summed. -/ -def cashTotal (F : List Line → Line → Flow) : List (List Line) → Int - | [] => 0 - | e :: es => cashOf F e + cashTotal F es - -/-- **The sections sum to the change in cash** (plan P3): over any balanced -entries, operating + investing + financing is the entries' cash, exactly, -whatever a member or lender handled for them as long as it nets to zero -within each entry (rule 11, `constructive_nets`). The statement's footing -(rules.md B-14 rule 9) can't fail on posted entries. -/ -theorem cashflow_sections_sum_to_cash_change (F : List Line → Line → Flow) - (C : List Line → List Int) (es : List (List Line)) (h : ∀ e ∈ es, net e = 0) - (hC : ∀ e ∈ es, sum3 (C e) = 0) : - sectionTotal F C 0 es + sectionTotal F C 1 es + sectionTotal F C 2 es = cashTotal F es := by - induction es with - | nil => rfl - | cons e es ih => - simp only [sectionTotal, cashTotal] - have he := stmt_flows_sum F C e (h e (by simp)) (hC e (by simp)) - simp only [sum3] at he - have := ih (fun x hx => h x (by simp [hx])) (fun x hx => hC x (by simp [hx])) - omega - -/-- When the cash tag follows the account (a line is cash exactly when -its account is a cash account), an entry's cash is the movement of the -cash accounts. -/ -theorem secNet_cash_is_movement (f : Line → Flow) (cash : List Nat) (hd : Distinct cash) - (hf : ∀ l, f l = .cash ↔ l.account ∈ cash) (ls : List Line) : - secNet f .cash ls = sumOver (fun a => movement a ls) cash := by - induction ls with - | nil => simpa using (sumOver_zero cash).symm - | cons l ls ih => - simp only [secNet_cons, movement_cons] - rw [sumOver_add, ← ih] - by_cases hc : l.account ∈ cash - · rw [ite_eq_left ((hf l).2 hc), sumOver_indicator l.account l.signed cash hd hc] - · rw [ite_eq_right (fun e => hc ((hf l).1 e)), sumOver_indicator_absent l.account l.signed cash hc] - -/-! ### Net income reconciled to operating cash (rules.md B-14 rule 8) -/ - -/-- Net movement of the lines on revenue and expense accounts (`pl`) -tagged `k` (`sel = true`), or on balance-sheet accounts (`sel = false`). -/ -def plNet (pl : Nat → Bool) (sel : Bool) (f : Line → Flow) (k : Flow) : List Line → Int - | [] => 0 - | l :: ls => (if pl l.account = sel ∧ f l = k then l.signed else 0) + plNet pl sel f k ls - -/-- Net movement of every revenue and expense line. -/ -def plAll (pl : Nat → Bool) : List Line → Int - | [] => 0 - | l :: ls => (if pl l.account = true then l.signed else 0) + plAll pl ls - -/-- Net income: revenue less expenses, `-(net of the P&L lines)`. -/ -def netIncome (pl : Nat → Bool) (e : List Line) : Int := -plAll pl e - -/-- G: the P&L lines rule 4 puts in investing (gains and losses). -/ -def gainLoss (pl : Nat → Bool) (F : List Line → Line → Flow) (e : List Line) : Int := - plNet pl true (F e) .investing e - -/-- W: minus the movement of the operating balance-sheet lines. -/ -def balanceChange (pl : Nat → Bool) (F : List Line → Line → Flow) (e : List Line) : Int := - -plNet pl false (F e) .operating e - -/-- X: what no cash, real or handled for the company, settled of the -operating movement. -/ -def noncashOp (F : List Line → Line → Flow) (C : List Line → List Int) (e : List Line) : Int := - secNet (F e) .operating e + (stmtFlows F C e).getD 0 0 - -theorem secNet_split (pl : Nat → Bool) (f : Line → Flow) (k : Flow) (ls : List Line) : - secNet f k ls = plNet pl true f k ls + plNet pl false f k ls := by - induction ls with - | nil => rfl - | cons l ls ih => - simp only [secNet_cons, plNet, ih] - cases pl l.account <;> by_cases h : f l = k <;> simp [h] <;> omega - -theorem plAll_split (pl : Nat → Bool) (f : Line → Flow) (ls : List Line) - (h : ∀ l ∈ ls, pl l.account = true → f l = .operating ∨ f l = .investing) : - plAll pl ls = plNet pl true f .operating ls + plNet pl true f .investing ls := by - induction ls with - | nil => rfl - | cons l ls ih => - simp only [plAll, plNet, ih (fun m hm => h m (by simp [hm]))] - cases hp : pl l.account - · simp - · rcases h l (by simp) hp with hk | hk <;> simp [hk] <;> omega - -/-- **Net income reconciles to operating cash**, entry by entry: -operating cash = NI + G + X + W, when revenue and expense lines are only -ever operating or investing (rules.md B-14 rule 8; ASC 230-10-45-28 to -45-30), whatever a member or lender handled for it (rule 11). -/ -theorem operating_reconciliation_ties (pl : Nat → Bool) (F : List Line → Line → Flow) - (C : List Line → List Int) (e : List Line) - (h : ∀ l ∈ e, pl l.account = true → F e l = .operating ∨ F e l = .investing) : - (stmtFlows F C e).getD 0 0 - = netIncome pl e + gainLoss pl F e + noncashOp F C e + balanceChange pl F e := by - have hs := secNet_split pl (F e) .operating e - have hp := plAll_split pl (F e) e h - simp only [netIncome, gainLoss, noncashOp, balanceChange] - omega - -/-- The operating section's total over some entries. -/ -def opTotal (pl : Nat → Bool) (F : List Line → Line → Flow) (C : List Line → List Int) : - List (List Line) → Int - | [] => 0 - | e :: es => netIncome pl e + gainLoss pl F e + noncashOp F C e + balanceChange pl F e - + opTotal pl F C es - -/-- **The reconciliation schedule ties to the operating section** over any -entries: the section's total is NI + G + X + W summed. -/ -theorem operating_reconciliation_ties_all (pl : Nat → Bool) (F : List Line → Line → Flow) - (C : List Line → List Int) (es : List (List Line)) - (h : ∀ e ∈ es, ∀ l ∈ e, pl l.account = true → F e l = .operating ∨ F e l = .investing) : - sectionTotal F C 0 es = opTotal pl F C es := by - induction es with - | nil => rfl - | cons e es ih => - simp only [sectionTotal, opTotal] - rw [operating_reconciliation_ties pl F C e (h e (by simp)), - ih (fun x hx => h x (by simp [hx]))] - -end Bookspec diff --git a/bookspec/lean/lake-manifest.json b/bookspec/lean/lake-manifest.json deleted file mode 100644 index 16bbf3f..0000000 --- a/bookspec/lean/lake-manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{"version": "1.3.0", - "packagesDir": ".lake/packages", - "packages": [], - "name": "bookspec", - "lakeDir": ".lake", - "fixedToolchain": false} diff --git a/bookspec/lean/lakefile.toml b/bookspec/lean/lakefile.toml deleted file mode 100644 index a16dbc4..0000000 --- a/bookspec/lean/lakefile.toml +++ /dev/null @@ -1,5 +0,0 @@ -name = "bookspec" -defaultTargets = ["Bookspec"] - -[[lean_lib]] -name = "Bookspec" diff --git a/bookspec/lean/lean-toolchain b/bookspec/lean/lean-toolchain deleted file mode 100644 index ba8ebf2..0000000 --- a/bookspec/lean/lean-toolchain +++ /dev/null @@ -1 +0,0 @@ -leanprover/lean4:v4.34.1 diff --git a/bookspec/runner/Cargo.toml b/bookspec/runner/Cargo.toml deleted file mode 100644 index 78f3d1d..0000000 --- a/bookspec/runner/Cargo.toml +++ /dev/null @@ -1,14 +0,0 @@ -[package] -name = "bookspec-runner" -version = "0.1.0" -edition.workspace = true -license.workspace = true -repository.workspace = true -description = "Runs bookspec examples against any engine that implements bookspec's Engine trait." - -[dependencies] -bookspec-checker = { path = "../checker" } -serde_json = "1" - -[lints] -workspace = true diff --git a/bookspec/runner/src/lib.rs b/bookspec/runner/src/lib.rs deleted file mode 100644 index 72d9f99..0000000 --- a/bookspec/runner/src/lib.rs +++ /dev/null @@ -1,302 +0,0 @@ -//! bookspec's conformance runner: it drives any [`Engine`] through the -//! examples and reports, per rule, pass, fail or not claimed -//! (`plan/served.md` 8.2 P13 step 2). -//! -//! The runner is the only judge. An engine answers; the runner compares the -//! answer with the example's expected values, and with the refusal the -//! example names when the engine refuses. The engine is never asked -//! whether its own answer is right. - -use std::collections::BTreeMap; - -use bookspec_checker::{Claims, Engine, Example, Expect, Query, Rule, RuleId, Values, rules}; - -/// What the runner says about one rule. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum Outcome { - /// The engine claims the rule and every example that demonstrates it - /// passed. - Pass, - /// The engine claims the rule and an example failed, or a claim has no - /// example behind it. - Fail, - /// The engine does not claim the rule. - NotClaimed, -} - -impl Outcome { - /// The outcome as the words a report prints. - pub fn key(self) -> &'static str { - match self { - Outcome::Pass => "pass", - Outcome::Fail => "fail", - Outcome::NotClaimed => "not claimed", - } - } -} - -/// One example's outcome. -#[derive(Clone, Debug, PartialEq, Eq)] -pub struct ExampleReport { - /// The example's id. - pub id: String, - /// Why it failed, if it did. - pub failure: Option, -} - -/// The runner's report: what it found of each rule, and each example. -#[derive(Clone, Debug, Default, PartialEq, Eq)] -pub struct Report { - /// Every rule of the catalogue the runner was given, and its outcome. - pub rules: BTreeMap, - /// Every example, in the order run. - pub examples: Vec, - /// Failures that belong to no rule: an example no rule lists, a rule - /// claimed but unknown to the catalogue. - pub unclaimed: Vec, -} - -impl Report { - /// True when every claimed rule passed and nothing else failed. - pub fn passed(&self) -> bool { - self.unclaimed.is_empty() - && self.rules.values().all(|o| *o != Outcome::Fail) - && self.examples.iter().all(|e| e.failure.is_none()) - } - - /// Every failure, with its rule's outcome where it has one. - pub fn failures(&self) -> Vec { - let mut out = self.unclaimed.clone(); - for (rule, outcome) in &self.rules { - if *outcome == Outcome::Fail { - let ids: Vec<&str> = rules() - .iter() - .find(|r| &r.id == rule) - .map(|r| r.examples.to_vec()) - .unwrap_or_default(); - out.push(format!("{rule} failed (examples: {})", ids.join(", "))); - } - } - for e in &self.examples { - if let Some(f) = &e.failure { - out.push(f.clone()); - } - } - out - } -} - -/// Run `examples` against engines made by `make`, one fresh engine per -/// example, and report per rule against `catalog` (normally -/// [`bookspec_checker::rules()`]). -pub fn run<'a, E>( - examples: &'a [Example], - catalog: &[Rule], - mut make: impl FnMut(&'a Example) -> E, -) -> Report -where - E: Engine + 'a, -{ - let mut out = Report::default(); - let mut claims = Claims::default(); - for ex in examples { - let mut engine = make(ex); - let declared = engine.claims(); - claims.rules.extend(declared.rules); - for (rule, choice) in declared.choices { - claims.choices.entry(rule).or_insert(choice); - } - let failure = run_example(ex, &mut engine).err(); - out.examples.push(ExampleReport { - id: ex.id.clone(), - failure, - }); - } - for rule in catalog { - let outcome = if !claims.claims(&rule.id) { - Outcome::NotClaimed - } else if !rule.has_examples() { - out.unclaimed - .push(format!("{} is claimed but has no example", rule.id)); - Outcome::Fail - } else { - let failed = rule.examples.iter().any(|id| { - out.examples - .iter() - .find(|e| &e.id == id) - .is_none_or(|e| e.failure.is_some()) - }); - if failed { Outcome::Fail } else { Outcome::Pass } - }; - out.rules.insert(rule.id.clone(), outcome); - } - for rule in &claims.rules { - if !catalog.iter().any(|r| &r.id == rule) { - out.unclaimed - .push(format!("{rule} is claimed but is not in the catalogue")); - } - } - for ex in examples { - if !catalog.iter().any(|r| r.examples.contains(&ex.id.as_str())) { - out.unclaimed - .push(format!("example {} is listed under no rule", ex.id)); - } - } - out -} - -/// Run one example against one engine: load the setup, apply every event -/// (accepted or refused as the example says), check the expectations named -/// after each event, make the changes named after it, then check the rest. -pub fn run_example(ex: &Example, engine: &mut dyn Engine) -> Result<(), String> { - engine - .load(&ex.setup) - .map_err(|r| format!("load refused: {} ({})", r.reason, r.rule))?; - for (i, event) in ex.events.iter().enumerate() { - let n = i + 1; - match (engine.apply(event), &event.refused) { - (Ok(()), None) => {} - (Err(r), Some(want)) if &r.reason == want => {} - (Err(r), Some(want)) => { - return Err(format!( - "event {n}: refused {:?}, expected {want:?} ({})", - r.reason, r.rule - )); - } - (Err(r), None) => { - return Err(format!( - "event {n}: refused {:?}, expected accepted ({})", - r.reason, r.rule - )); - } - (Ok(()), Some(want)) => { - return Err(format!("event {n}: accepted, expected refused: {want}")); - } - } - for x in ex.expect.iter().filter(|x| x.after == Some(n)) { - check(engine, x).map_err(|m| format!("after event {n}: {m}"))?; - } - for c in ex.changes.iter().filter(|c| c.after == n) { - engine - .change(c) - .map_err(|r| format!("change after {n}: {} ({})", r.reason, r.rule))?; - } - } - for x in ex.expect.iter().filter(|x| x.after.is_none()) { - check(engine, x)?; - } - Ok(()) -} - -/// Check one expectation against the engine's answer. -fn check(engine: &dyn Engine, x: &Expect) -> Result<(), String> { - let q = x.query(); - let answer = engine.query(&q); - match (answer, &x.refused, x.accepted) { - (Ok(v), None, Some(true)) => { - if v == serde_json::Value::Bool(true) { - Ok(()) - } else { - Err(format!( - "{}: accepted, but answered {v} (a command shows no values)", - q.name - )) - } - } - (Ok(v), None, None) => compare(x, &v), - (Ok(v), Some(want), _) => Err(format!( - "{}: answered {v}, expected refused: {want}", - q.name - )), - (Err(r), Some(want), _) if &r.reason == want => Ok(()), - (Err(r), Some(want), _) => Err(format!( - "{}: refused {:?}, expected {want:?} ({})", - q.name, r.reason, r.rule - )), - (Err(r), None, _) => Err(format!("{}: refused {:?} ({})", q.name, r.reason, r.rule)), - (Ok(v), None, Some(false)) => Err(format!( - "{}: accepted = false is not the examples' vocabulary; answered {v}", - q.name - )), - } -} - -/// Compare an answer with what the example expects. -fn compare(x: &Expect, got: &serde_json::Value) -> Result<(), String> { - let q: Query = x.query(); - let Some(want) = &x.values else { - return Err(format!("{}: expects values, none given", q.name)); - }; - match want { - Values::Figures(figures) => { - let serde_json::Value::Object(map) = got else { - return Err(format!("{}: answered {got}, expected a table", q.name)); - }; - for (key, want) in figures { - match map.get(key) { - Some(serde_json::Value::String(got)) if got == want => {} - Some(got) => { - return Err(format!("{}: {key}: got {got}, expected {want:?}", q.name)); - } - None => { - return Err(format!("{}: {key} is missing (expected {want:?})", q.name)); - } - } - } - if q.is_complete() { - let extra: Vec<&String> = - map.keys().filter(|k| !figures.contains_key(*k)).collect(); - let missing: Vec<&String> = - figures.keys().filter(|k| !map.contains_key(*k)).collect(); - if !extra.is_empty() || !missing.is_empty() { - return Err(format!( - "{}: lists exactly what it covers; missing {missing:?}, extra {extra:?}", - q.name - )); - } - } - Ok(()) - } - Values::Lines(lines) => { - let serde_json::Value::Array(got) = got else { - return Err(format!("{}: answered {got}, expected a list", q.name)); - }; - let got: Vec<&str> = got - .iter() - .map(|v| v.as_str().unwrap_or("")) - .collect(); - let want: Vec<&str> = lines.iter().map(String::as_str).collect(); - if got == want { - Ok(()) - } else { - Err(format!("{}: got {got:?}, expected {want:?}", q.name)) - } - } - Values::Rows(rows) => { - let serde_json::Value::Array(got) = got else { - return Err(format!("{}: answered {got}, expected rows", q.name)); - }; - let got: Vec> = got - .iter() - .map(|row| { - row.as_array() - .map(|r| { - r.iter() - .map(|v| v.as_str().unwrap_or("")) - .collect() - }) - .unwrap_or_default() - }) - .collect(); - let want: Vec> = rows - .iter() - .map(|r| r.iter().map(String::as_str).collect()) - .collect(); - if got == want { - Ok(()) - } else { - Err(format!("{}: got {got:?}, expected {want:?}", q.name)) - } - } - } -} diff --git a/bookspec/runner/tests/runner.rs b/bookspec/runner/tests/runner.rs deleted file mode 100644 index a6ced22..0000000 --- a/bookspec/runner/tests/runner.rs +++ /dev/null @@ -1,240 +0,0 @@ -//! The runner's own tests, with an engine that only pretends: the runner -//! must judge the answer, never the engine's belief about it. - -use bookspec_checker::{ - Area, ChangeSpec, Claims, Engine, Event, Example, Query, Refusal, Rule, RuleId, Setup, Status, -}; -use bookspec_runner::Outcome; - -const EXAMPLE: &str = r#" -id = "fake.example" -title = "A fake engine answers" -rule = "The chart's names MUST be the account_names the example expects." -status = "AUTHORITY" -sources = ["bookspec/README.md"] -tags = ["runner"] - -events = [] - -[setup] -currency = "USD" -fiscal_year_start = "01-01" -basis = "accrual" -rounding = "half-even" - -[[setup.accounts]] -code = "1000" -name = "Cash" -type = "asset" - -[[setup.accounts]] -code = "3000" -name = "Capital" -type = "equity" - -[[expect]] -query = "account_names" - -[expect.values] -"1000" = "Cash" -"3000" = "Capital" -"#; - -fn example() -> Example { - Example::from_toml(EXAMPLE).expect("the fake example is well formed") -} - -fn catalog() -> Vec { - vec![Rule { - b: 1, - id: RuleId::new(Area::Entry, 1), - area: Area::Entry, - title: "Every entry is a double entry: debits equal credits", - status: Some(Status::Authority), - examples: &["fake.example"], - }] -} - -/// The rules an engine that claims the catalogue's example states. -fn claim() -> Claims { - Claims::new([RuleId::new(Area::Entry, 1)]) -} - -struct Fake { - claims: Claims, - names: serde_json::Value, - accept: bool, - refuse_query: Option, - change_ok: bool, -} - -impl Default for Fake { - fn default() -> Self { - Self { - claims: claim(), - names: serde_json::json!({"1000": "Cash", "3000": "Capital"}), - accept: true, - refuse_query: None, - change_ok: true, - } - } -} - -impl Engine for Fake { - fn load(&mut self, _setup: &Setup) -> Result<(), Refusal> { - Ok(()) - } - - fn apply(&mut self, event: &Event) -> Result<(), Refusal> { - if let Some(reason) = &event.refused { - return Err(Refusal::of_reason(reason)); - } - if self.accept { - Ok(()) - } else { - Err(Refusal::of_reason("entry_irreversible")) - } - } - - fn query(&self, q: &Query) -> Result { - if let Some(r) = &self.refuse_query { - return Err(r.clone()); - } - match q.name.as_str() { - "account_names" => Ok(self.names.clone()), - other => Err(Refusal::new( - RuleId::new(Area::Entry, 1), - format!("the fake engine answers no {other}"), - )), - } - } - - fn claims(&self) -> Claims { - self.claims.clone() - } - - fn change(&mut self, _change: &ChangeSpec) -> Result<(), Refusal> { - if self.change_ok { - Ok(()) - } else { - Err(Refusal::of_reason("unchanged")) - } - } -} - -/// An engine that answers what the example expects passes, and its rule is -/// claimed and passed. -#[test] -fn a_passing_engine_passes() { - let examples = [example()]; - let report = bookspec_runner::run(&examples, &catalog(), |_| Fake::default()); - assert!(report.passed(), "{:?}", report.failures()); - assert_eq!( - report.rules.get(&RuleId::new(Area::Entry, 1)), - Some(&Outcome::Pass) - ); -} - -/// The runner judges the answer: a wrong figure fails the rule, whatever -/// the engine says about itself. -#[test] -fn a_wrong_answer_fails() { - let examples = [example()]; - let report = bookspec_runner::run(&examples, &catalog(), |_| Fake { - names: serde_json::json!({"1000": "Coin", "3000": "Capital"}), - ..Fake::default() - }); - assert!(!report.passed()); - assert_eq!( - report.rules.get(&RuleId::new(Area::Entry, 1)), - Some(&Outcome::Fail) - ); - assert!( - report.failures().iter().any(|f| f.contains("1000")), - "{:?}", - report.failures() - ); -} - -/// An engine that claims nothing is not claimed, not failed; and the -/// example it does not claim is still run (its answer is compared). -#[test] -fn an_engine_that_claims_nothing_is_not_claimed() { - let examples = [example()]; - let report = bookspec_runner::run(&examples, &catalog(), |_| Fake { - claims: Claims::default(), - ..Fake::default() - }); - assert!(report.passed(), "{:?}", report.failures()); - assert_eq!( - report.rules.get(&RuleId::new(Area::Entry, 1)), - Some(&Outcome::NotClaimed) - ); -} - -/// A claim with no example behind it is a failure: a claim needs evidence. -#[test] -fn a_claim_without_an_example_fails() { - let mut catalog = catalog(); - catalog[0].examples = &[]; - let examples = [example()]; - let report = bookspec_runner::run(&examples, &catalog, |_| Fake::default()); - assert!(!report.passed()); - assert_eq!( - report.rules.get(&RuleId::new(Area::Entry, 1)), - Some(&Outcome::Fail) - ); -} - -/// A query the engine refuses fails, and says which rule it refused under. -#[test] -fn a_refused_query_fails() { - let examples = [example()]; - let report = bookspec_runner::run(&examples, &catalog(), |_| Fake { - refuse_query: Some(Refusal::new( - RuleId::new(Area::Ledger, 1), - "not implemented", - )), - ..Fake::default() - }); - assert!(!report.passed()); - let failures = report.failures().join("\n"); - assert!(failures.contains("not implemented"), "{failures}"); - assert!(failures.contains("BS-ledger-1"), "{failures}"); -} - -/// An example no rule lists is a failure: it proves nothing. -#[test] -fn an_example_listed_under_no_rule_fails() { - let examples = [example()]; - let report = bookspec_runner::run(&examples, &[], |_| Fake::default()); - assert!(!report.passed()); - assert!( - report - .failures() - .iter() - .any(|f| f.contains("listed under no rule")), - "{:?}", - report.failures() - ); -} - -/// A change the engine refuses fails the example. -#[test] -fn a_refused_change_fails() { - let text = EXAMPLE.replace("events = []\n", "").replace( - "[[expect]]", - "[[events]]\ndate = \"2026-01-02\"\ndescription = \"A\"\nlines = [\n { account = \"1000\", side = \"debit\", amount = \"1.00\" },\n { account = \"3000\", side = \"credit\", amount = \"1.00\" },\n]\n\n[[changes]]\nafter = 1\nrequire_tax_category = \"refuse\"\n\n[[expect]]", - ); - let examples = [Example::from_toml(&text).unwrap()]; - let report = bookspec_runner::run(&examples, &catalog(), |_| Fake { - change_ok: false, - ..Fake::default() - }); - assert!(!report.passed()); - assert!( - report.failures().iter().any(|f| f.contains("unchanged")), - "{:?}", - report.failures() - ); -} diff --git a/bookspec/spec/00-model.md b/bookspec/spec/00-model.md deleted file mode 100644 index 767d15f..0000000 --- a/bookspec/spec/00-model.md +++ /dev/null @@ -1,101 +0,0 @@ -# bookspec, first edition: definitions and the model - -bookspec is a specification of double-entry bookkeeping that stands apart -from any program. Each rule is stated normatively, sourced to an authority -(or marked as a convention or a choice), written symbolically, and shown -by concrete examples (`bookspec/examples/*.toml`) that any engine can be -run against (`bookspec/runner`). wanderbooks is the first engine checked; -the GnuCash oracle is the second (`bookspec/gnucash`). - -## Reading the rules - -The words MUST, MUST NOT and MAY are RFC 2119's. A rule has - -- an **identifier**, `BS--`, stable and never reused; the table - from the wanderbooks catalogue's `B-1` to `B-45` is `rules-index.md`; -- a **status**: `AUTHORITY` (a source states it), `CONVENTION` (common - practice no authority states; the best reference is given), or `CHOICE` - (a person decided; the specification names the options and an engine - declares which it picks, `Claims::choices`); -- a **statement** (what MUST hold), its **sources** (each pinned exactly: - a vendored source with a verbatim quotation the build checks, or an - external reference to a paragraph, never a paraphrase of memory), its - **symbolic form**, its **examples** (ids of the TOML files) and its - **applicability** (which entities, bases and jurisdictions). - -A rule with no example is stated and not claimable: an engine can only -claim a rule an example demonstrates (`covered_rules`). The first edition -says plainly which rules those are (`rules-index.md`, "—"). - -Sources are US GAAP (the FASB Codification: cited, never copied, and read -only in the Foundation's own view or a publisher that reproduces it) and -US federal tax (the Internal Revenue Code, Treasury Regulations, IRS -publications and form instructions, which are public domain), then older -or general authorities (Pacioli, OpenStax, ISO 4217). The large firms' -guides are never a source. - -## The model - -- **Currency and money.** A ledger has one **base currency**. An - **amount** is an exact decimal, a whole number of the currency's - smallest unit (ISO 4217 minor units). Arithmetic on amounts is exact; - it never uses binary floating point, and it rounds only where a rule - says so, and then by the rounding mode the rule names. -- **Account.** A code, a name and one of five **types**: asset, - liability, equity, revenue, expense. An account may have a parent of - its own type; an account may be a **header** (it groups, and takes no - posting). Assets and expenses have a **normal balance** on the debit - side; liabilities, equity and revenue on the credit side. -- **Line.** `(account, side, amount)`: `side` is debit or credit, - `amount > 0`. Its **signed value** is `+amount` for a debit and - `-amount` for a credit. -- **Entry.** A dated, described list of at least two lines. An entry is - **posted** or not; the ledger holds the posted ones. A **reversal** of - an entry is a new entry with the same accounts and amounts and the - sides flipped, linked to it. -- **Ledger.** The sequence of posted entries, written only forward. The - **balance** of an account through a day is the sum of its lines' signed - values dated on or before the day; shown on its normal side it is that - sum, negated for a credit-normal account. -- **Period and close.** A **period** is a range of days. **Closing** it - posts an entry that brings every revenue and expense account's balance - through the period's last day to zero against equity. -- **Document.** An invoice or a bill: a receivable or payable the ledger - carries as lines tagged with the document; control accounts equal the - documents behind them. - -## The five laws - -Every other rule serves these, and each is a theorem in `bookspec/lean` -(`Bookspec.law_*`), true of every state the ledger can reach by any -sequence of its commands: - -1. **Debits equal credits.** `sum over all posted lines of signed value = 0` - (`law_debits_equal_credits`; BS-entry-1). -2. **The accounting equation.** `A = L + E + (R - X)`, with each term the - sum of the balances of that type's accounts on their normal side - (`law_accounting_equation`; BS-statement-2). -3. **The trial balance balances.** The debit balances sum to the credit - balances (`law_trial_balance`; BS-statement-1). -4. **Net income articulates to equity.** Closing a period credits equity - with exactly `R - X` through its last day - (`law_net_income_articulates`; BS-period-1). -5. **The cash flow articulates to cash.** The three sections of the - statement of cash flows sum to the change in the cash accounts' - balances (`law_cash_flow_is_change_in_cash`; BS-statement-3). - -Money in the proofs is `Int`, a count of the smallest unit; every law is -a law of addition, and any finite set of exact decimals scales to -integers without loss. - -## How an example is checked - -An example is a TOML file: a setup, a sequence of events, and -expectations asked of the books, with every expected figure worked by -hand from the rule and its sources (`bookspec/README.md` has the shape). -Two checks stand behind each: **conformance** (the runner drives an -engine through it and compares the engine's answers with the expected -figures) and **derivation** (the checker computes the expected figures -from the rule's symbolic form, independent of any engine, and fails the -specification's own build when the example's figure disagrees; see -`bookspec/checker/src/derive.rs`). diff --git a/bookspec/spec/1099.md b/bookspec/spec/1099.md deleted file mode 100644 index 7929408..0000000 --- a/bookspec/spec/1099.md +++ /dev/null @@ -1,14 +0,0 @@ -# Area: 1099 - -Information returns for payments to contractors and landlords. - -### BS-1099-1: 1099 reporting thresholds, payment methods, and decisions (B-19) - -- **Status:** AUTHORITY, with the CHOICEs recorded in `docs/spec/rules.md` B-19 -- **Statement:** A payee's payments made in a calendar year in the course of the payor's trade or business MUST be reportable when they reach the threshold (600 dollars through 2025; 2,000 dollars for payments made after December 31, 2025; indexed after 2026). Payments by payment card or third-party network MUST be excluded (they are reported on Form 1099-K by the settlement entity); payments through a financing account whose method is unrecorded MUST be undetermined, not counted; a payee's decision (reportable, not reportable with a reason, undetermined) applies by the payment's date; money between the company's own accounts pays no one. -- **Sources:** - - vendored `sources/irs-1099-thresholds.md`: "6041(a)" - - vendored `sources/irs-1099-payment-method.md`: "1099-K" -- **Symbolic form:** `reportable(p, y) iff sum{pay : payee = p, year = y, method not card/network} >= threshold(y)`; `threshold(y) = 600 if y <= 2025 else 2000`. -- **Examples:** `1099-a-dated-status-applies-by-payment-date`, `1099-a-members-check-through-financing-counts`, `1099-and-party-totals-for-a-members-payment`, `1099-card-payments-are-excluded`, `1099-money-between-own-accounts-pays-no-one`, `1099-money-from-a-payee-is-netted-only-as-a-refund`, `1099-the-threshold-is-the-years`, `1099-worksheet-through-as-of` -- **Applicability:** US payors; royalties (10 dollars) and attorney gross proceeds (600 dollars) are not modelled. diff --git a/bookspec/spec/arap.md b/bookspec/spec/arap.md deleted file mode 100644 index 9cae2ca..0000000 --- a/bookspec/spec/arap.md +++ /dev/null @@ -1,64 +0,0 @@ -# Area: arap - -Receivables and payables: documents, aging, terms, write-offs and -settlements. - -### BS-arap-1: Aging buckets: Current, 1-30, 31-60, 61-90, 90+ days past due (B-18) - -- **Status:** CONVENTION (bucket edges); CHOICE (a document due today is Current; aging as of a day shows only documents issued by then, less payments made by then; a payment never predates its document) -- **Statement:** An aging as of a day MUST place each open document in exactly one of the buckets Current (not yet past due, including due today), 1-30, 31-60, 61-90 and 90+ days past due, and the buckets MUST partition what the documents owe, exactly. A void document is in no bucket; a payment reversed by the day no longer counts. -- **Sources:** - - external: ASC 326-20-55-37 and 326-20-55-38, an illustrative aging of trade receivables -- **Symbolic form:** `d = as_of - due`; bucket `Current` if `d <= 0`, `1-30` if `1 <= d <= 30`, `31-60`, `61-90`, `90+` if `d >= 91`; `sum_b owed(b) = sum of open balances`. -- **Examples:** none yet. The schema has no aging query; the example for it waits on one (a document-level query is owed by this edition's successor). -- **Applicability:** accrual-basis entities with receivables or payables. - -### BS-arap-2: A document's figures are its ledger lines that stand; a control account is its documents (B-38) - -- **Status:** CHOICE (the owner, 2026-10-23); CONVENTION (a control account agrees with its subledger) -- **Statement:** Every ledger line on a receivables or payables control account MUST carry the document it belongs to; a document's total, paid and owed MUST be derived from its tagged lines that stand (a reversed line no longer counts); a control account's balance MUST equal the sum of its documents'. A hand line on a control account MUST be refused. -- **Sources:** - - external: ASC 310-10 (receivables); ASC 405-20-40-1 (a liability is removed only when paid or released); ASC 606-10-45-4 (a receivable is a right to consideration that is unconditional) -- **Symbolic form:** `bal(1200) = sum_{doc} owed(doc)` and `owed(doc) = sum{signed tagged lines that stand}`. -- **Examples:** `arap-hand-control-posting-refused` -- **Applicability:** engines with invoices and bills. - -### BS-arap-3: Terms: when a document is due (B-39) - -- **Status:** reserved -- **Statement:** Reserved: allocated in the wanderbooks plan (8.0.3) for payment terms and due dates, not yet stated in `docs/spec/rules.md`. -- **Sources:** - - none yet -- **Symbolic form:** none yet. -- **Examples:** none yet -- **Applicability:** none yet. - -### BS-arap-4: Numbers and the issued copy (B-40) - -- **Status:** reserved -- **Statement:** Reserved: allocated in the wanderbooks plan (8.0.3) for document numbers and the issued copy, not yet stated in `docs/spec/rules.md`. -- **Sources:** - - none yet -- **Symbolic form:** none yet. -- **Examples:** none yet -- **Applicability:** none yet. - -### BS-arap-5: A debt that will not be collected is written off, and a recovery is income (B-41) - -- **Status:** CHOICE (direct write-off, the owner, 2026-10-23); options: direct write-off, allowance method -- **Statement:** On the direct method, an invoice that will not be collected MUST be written off by debiting bad debt expense and crediting receivables, the receivable line tagged with the invoice; a partial write-off is the amount charged off; a write-off MUST be refused on cash-basis books and for more than is owed; a later recovery is income. -- **Sources:** - - external: 26 U.S.C. section 166(a); Treas. Reg. sections 1.166-1(e), 1.166-2(a), 1.166-3(a); 26 U.S.C. section 111 (recovery); ASC 326-20 (the allowance method, not applied) -- **Symbolic form:** `Dr 6990 amount; Cr 1200 amount` tagged with the invoice; `0 < amount <= owed`. -- **Examples:** `bad-debt-direct-write-off` -- **Applicability:** accrual-basis taxpayers. - -### BS-arap-6: A settlement is exact, one party, and names its lines; the gross discount and the gross fee (B-42) - -- **Status:** CONVENTION; CHOICE (the gross method for an early-payment discount, the owner A-8; the processor's fee posted gross, A-4) -- **Statement:** A settlement MUST apply to documents of one party and one kind, never the same document twice; sum of cash plus sum of discounts MUST equal what it applies to the documents, and the deposit MUST equal cash less fee, exactly; no application may exceed what its document owes on any day from the payment's date on; a discount MUST be at most the terms allow and be dated within the discount period, or carry a reason; a processor's fee posts gross (expense debited, deposit the net, receivables credited the gross). -- **Sources:** - - external: ASC 606-10-32-5 to 32-9 (the strict treatment of an expected discount, simplified here by the gross method) -- **Symbolic form:** `sum cash + sum discount = sum applied`; `deposit = sum cash - fee`; `owed_after >= 0` on every day. -- **Examples:** none yet. The schema has no payment or settlement event (`arap-settlement-exact` and `processor-fee-gross-presentation` are the names the plan allocates; they wait on that event). -- **Applicability:** engines with documents and settlements. diff --git a/bookspec/spec/asset.md b/bookspec/spec/asset.md deleted file mode 100644 index 82b8084..0000000 --- a/bookspec/spec/asset.md +++ /dev/null @@ -1,45 +0,0 @@ -# Area: asset - -Fixed assets, depreciation, the register and capitalization. - -### BS-asset-1: Depreciation: straight line, salvage never depreciated, partial months by days (B-15) - -- **Status:** CONVENTION (the book method); AUTHORITY for tax conventions, which an engine need not apply -- **Statement:** Depreciation MUST spread cost less salvage over the useful life in a systematic and rational manner: straight line, full months equal, the first and last partial months by their days, the last month taking what the others leave so the total is exactly cost less salvage. Salvage MUST NOT be depreciated. A run MUST post what the schedule says is due through the month and the books do not carry (a skipped month caught up); a disposal MUST post any depreciation still due through its day, then the disposal entry with its gain or loss; depreciation past a disposal MUST be refused. -- **Sources:** - - vendored `sources/fasb-asc.md`: "360-10-35-4" - - external: OpenStax, Principles of Accounting, Volume 1, section 11.3, straight-line depreciation -- **Symbolic form:** `dep_m = round((cost - salvage) * days_m / days_total)` for the partial months; `sum_m dep_m = cost - salvage`. -- **Examples:** `assets-acquired-before-in-service`, `assets-a-reversed-run-is-not-posted`, `assets-book-value-is-what-the-books-carry`, `depreciation-catch-up-month`, `depreciation-disposal-after-a-late-change-undone`, `depreciation-disposal-before-a-later-run`, `depreciation-disposal-in-final-month`, `depreciation-disposal-inside-a-posted-month`, `depreciation-disposal-mid-month`, `depreciation-final-month-by-days`, `depreciation-final-month-drift`, `depreciation-reversal-before-its-run-is-refused`, `depreciation-run-reversed-after-its-month` -- **Applicability:** entities with fixed assets; tax conventions (half-year, mid-month) are IRS Pub. 946's and are not applied. - -### BS-asset-2: Expense or capitalize (book policy, mirrors tax) (B-22) - -- **Status:** AUTHORITY -- **Statement:** An item costing no more than the de minimis safe harbor (500 dollars per invoice or item without an applicable financial statement; 2,500 dollars from taxable years beginning in 2016 under Notice 2015-82) MAY be expensed; a larger one SHOULD be capitalized and depreciated, and amounts paid for benefits lasting no more than twelve months MAY be expensed under the 12-month rule. -- **Sources:** - - vendored `sources/irs-capitalization.md`: "1.263(a)-1(f)" - - vendored `sources/irs-capitalization.md`: "1.263(a)-4(f)" -- **Symbolic form:** `cost <= safe_harbor implies expense MAY`; the election is made yearly on the return. -- **Examples:** none yet. This is a policy the bookkeeper applies; no engine enforces it, so no engine-neutral behaviour can be demonstrated. -- **Applicability:** US taxpayers; thresholds as of 2026. - -### BS-asset-3: Asset tags: one label per asset, never reused (B-24) - -- **Status:** CHOICE (the owner, 2026-10-15) -- **Statement:** An asset tag (1 to 32 ASCII letters, digits and `- _ . /`) MUST be unique ignoring case and MUST NOT be reused by another asset, even after a retag or a disposal. The fixed asset register MUST reconcile to the cost and accumulated depreciation accounts, showing unregistered cost, unposted depreciation and unattributed accumulated depreciation. -- **Sources:** - - external: CHOICE only: the owner, 2026-10-15 (docs/spec/rules.md B-24); no authority governs a label -- **Symbolic form:** `tag(a) = tag(b) implies a = b` (case folded), through the asset's whole life. -- **Examples:** none yet. The schema registers assets without tags; the register-row and register-totals queries demonstrate the reconciliation under BS-asset-1. -- **Applicability:** engines with an asset register; an engine declares its pick. - -### BS-asset-4: Tax figures beside book value: the section 723 basis, the depreciable basis, the section 704(c) built-in gain (B-25) - -- **Status:** AUTHORITY; CHOICE (the register carries tax basis; D-41) -- **Statement:** For property a member contributes, the partnership's basis MUST be the member's adjusted basis (carryover, section 723); book value is its fair market value; the difference at contribution is the section 704(c) built-in gain, fixed at contribution; the depreciable basis of property converted from personal use is the lesser of fair market value and adjusted basis. -- **Sources:** - - vendored `sources/irs-partnership-basis.md`: "723" -- **Symbolic form:** `basis_723 = adjusted_basis`; `built_in = fmv - basis_723` at contribution; `depreciable = min(fmv, adjusted_basis)` for personal-use conversion. -- **Examples:** `assets-723-basis-apart-from-depreciable-basis`, `assets-built-in-gain-fixed-at-contribution`, `assets-four-contributed-devices` -- **Applicability:** partnerships receiving contributed property. diff --git a/bookspec/spec/entry.md b/bookspec/spec/entry.md deleted file mode 100644 index 1103aa0..0000000 --- a/bookspec/spec/entry.md +++ /dev/null @@ -1,54 +0,0 @@ -# Area: entry - -The mechanics of one entry: its lines, their sides and amounts. - -### BS-entry-1: Every entry is a double entry: debits equal credits (B-1) - -- **Status:** AUTHORITY -- **Statement:** An entry MUST have at least two lines, and the sum of its debit lines MUST equal the sum of its credit lines exactly, with no tolerance. An entry that does not MUST be refused whole: it posts nothing, and leaves no partial entry behind. -- **Sources:** - - vendored `sources/pacioli-1494.md`: "All entries made in the ledger have to be double entries" - - vendored `sources/irs-records.md`: "the total debits must equal the total credits" -- **Symbolic form:** for an entry with lines `(s_i, a_i)`, `a_i > 0`: `n >= 2` and `sum{a_i : s_i = debit} = sum{a_i : s_i = credit}`. -- **Examples:** `entry-an-unbalanced-entry-is-refused`, `statement-the-books-balance-and-the-equation-holds` -- **Applicability:** every entity, basis and jurisdiction. - -### BS-entry-2: A line's amount is positive; its side gives the direction (B-2) - -- **Status:** CONVENTION -- **Statement:** A line's amount MUST be greater than zero, and its side, never its sign, MUST give the direction money moves. A line with a negative or zero amount MUST be refused. -- **Sources:** - - vendored `sources/pacioli-1494.md`: "All the creditors must appear in the Ledger at the right hand side, and all the debtors at the left." -- **Symbolic form:** `a_i > 0`; `signed(i) = +a_i` if `s_i = debit`, `-a_i` if `s_i = credit`. -- **Examples:** `entry-a-line-amount-is-positive` -- **Applicability:** every engine. No authority forbids a negative amount outright; one way to write each line is chosen so every line reads one way. - -### BS-entry-3: Exact amounts in the books; rounding only on returns (B-21) - -- **Status:** AUTHORITY -- **Statement:** The books MUST keep every amount exactly, and a sum of amounts MUST be their exact sum. Rounding MUST NOT occur inside the books; where a rule needs a rounded figure (an allocation, a return line), it names the rounding mode and rounds only the total. -- **Sources:** - - vendored `sources/irs-partnerships.md`: "include cents when adding the amounts and round off only the total" -- **Symbolic form:** amounts are elements of `Z * u` (`u` the currency's smallest unit); `+` is exact on them. -- **Examples:** `entry-cents-add-exactly` -- **Applicability:** every engine. Binary floating point is not an implementation of this rule. - -### BS-entry-4: What new commands take: four-digit years, amounts a line can carry (B-28) - -- **Status:** CONVENTION -- **Statement:** A command MUST take only dates in 0001-01-01 through 9999-12-31 written `YYYY-MM-DD`, and amounts no larger than one quadrillion with at most ten decimal places; anything else MUST be refused, never wrapped or truncated. Replaying books already written MUST NOT apply these bounds. -- **Sources:** - - external: ISO 8601-1:2019 section 5.2.2.1, calendar dates with four-digit years in the basic profile -- **Symbolic form:** `date in [0001-01-01, 9999-12-31]`; `|amount| <= 10^15`; `scale(amount) <= 10`. -- **Examples:** none yet. The example schema writes dates as `YYYY-MM-DD` already (a malformed date fails the schema), so a refusal example needs a command whose input can be out of range; the first edition has none. -- **Applicability:** commands only; replay of existing books is exempt (posted is forever). - -### BS-entry-5: Drafts and evidence (B-43) - -- **Status:** reserved -- **Statement:** Reserved: allocated in the wanderbooks plan (8.0.3) for the draft states of unit P4, not yet stated in `docs/spec/rules.md`. Its identifier and area are fixed so a later edition fills it in without moving another number. -- **Sources:** - - none yet -- **Symbolic form:** none yet. -- **Examples:** none yet -- **Applicability:** none yet. diff --git a/bookspec/spec/format.md b/bookspec/spec/format.md deleted file mode 100644 index 1dc5680..0000000 --- a/bookspec/spec/format.md +++ /dev/null @@ -1,23 +0,0 @@ -# Area: format - -Interchange formats and the journal's format gate. - -### BS-format-1: The journal format gate: newer is not corrupt (B-27) - -- **Status:** CHOICE (the owner, 2026-10-16; D-39, 2026-10-20) -- **Statement:** A reader MUST distinguish a journal line it does not know because it is newer from one that is corrupt: a line's own hash is checked before it is called newer; a reader stops at the first line it cannot read and says the journal is newer; older books move to a newer format only with the owner's go-ahead. -- **Sources:** - - external: CHOICE only: the owner, 2026-10-16 (docs/spec/rules.md B-27); no authority governs a file format's evolution -- **Symbolic form:** `hash_ok(line) and type(line) unknown implies newer`; `not hash_ok(line) implies corrupt`. -- **Examples:** none yet. The example schema has no journal-file event; the gate is checked by wanderbooks' store tests. -- **Applicability:** engines that persist a journal. - -### BS-format-2: The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out, classes carried (B-34) - -- **Status:** AUTHORITY -- **Statement:** An IIF file MUST be plain ASCII, tab-separated, with each line ending CR LF; lists (accounts, classes) MUST come before the transactions that use them; an account is identified by its name, a subaccount's name its path joined by colons; every entry through the as-of day MUST be in the file and closing entries left out; classes MUST be carried; text the file must write differently from the books (a name past 31 characters, a memo past 4,095) MUST be reported as a change. -- **Sources:** - - vendored `sources/intuit-iif.md`: "Accounts are identified by NAME" -- **Symbolic form:** `file = header lists transactions`, `forall line: ascii(line) and tabs(line)`. -- **Examples:** `iif-quickbooks-journal-file` -- **Applicability:** exports to QuickBooks Desktop. diff --git a/bookspec/spec/ledger.md b/bookspec/spec/ledger.md deleted file mode 100644 index dfee2ae..0000000 --- a/bookspec/spec/ledger.md +++ /dev/null @@ -1,141 +0,0 @@ -# Area: ledger - -The chart, the journal, posted-is-forever, corrections, control roles and -how the books are kept. - -### BS-ledger-1: One money through the ledger; foreign amounts at the transaction-date rate (B-3) - -- **Status:** AUTHORITY (the single money, the rate); CHOICE (booking in the smallest unit, the owner, 2026-10-13) -- **Statement:** A ledger MUST be kept in one base currency. A foreign amount MUST be booked at the rate in effect on the transaction date, rounded half away from zero to the base currency's smallest unit, and the original amount and rate kept. A line finer than the smallest unit MUST be refused, never rounded silently. -- **Sources:** - - vendored `sources/pacioli-1494.md`: "the money that you reckon by at the beginning should be the same all through the Ledger" - - vendored `sources/iso-4217.md`: "Minor units" (the table of currencies by minor-unit digits; two for the US dollar) - - external: ASC 830-20-30-1, initial measurement in the functional currency at the rate in effect at the transaction date -- **Symbolic form:** `base = round_half_away(foreign * rate, u)`; every booked amount `in Z * u`. -- **Examples:** `ledger-a-fraction-of-a-cent-is-refused` -- **Applicability:** every engine. The single-currency refusal is demonstrated; the foreign-currency booking has no example in the first edition (the schema has no foreign line). - -### BS-ledger-2: Five elements, closed list (B-4) - -- **Status:** AUTHORITY (the definitions); CONVENTION (the five-type chart); CHOICE (a child has its parent's type, the owner, 2026-10-13) -- **Statement:** Every account MUST be exactly one of asset, liability, equity, revenue, expense. An account under a parent MUST have its parent's type. -- **Sources:** - - external: FASB Concepts Statement No. 8, Chapter 4, paragraphs E16 (asset), E37 (liability), E61 (equity), E80 (revenue), E81 (expense) -- **Symbolic form:** `type(a) in {asset, liability, equity, revenue, expense}`; `parent(a) = p implies type(a) = type(p)`. -- **Examples:** `ledger-a-child-account-has-its-parents-type` -- **Applicability:** every engine; engines with a finer chart (contra accounts, memo accounts) map onto the five. - -### BS-ledger-3: Normal balances (B-5) - -- **Status:** CONVENTION -- **Statement:** An asset or expense account's balance MUST be shown as debits less credits, a liability, equity or revenue account's as credits less debits. -- **Sources:** - - vendored `sources/pacioli-1494.md`: "This capital must always be placed as creditor" - - external: OpenStax, Principles of Accounting, Volume 1, section 3.1 -- **Symbolic form:** `bal(a) = D(a) - C(a)` if `type(a) in {asset, expense}`, else `C(a) - D(a)`. -- **Examples:** `statement-the-books-balance-and-the-equation-holds` -- **Applicability:** every engine. - -### BS-ledger-4: Posted is forever: the book is written only forward (B-8) - -- **Status:** AUTHORITY -- **Statement:** A posted entry MUST NOT be changed or deleted. The ledger at a later time MUST contain every earlier posted entry unchanged, and a change to a record MUST leave the audit trail intact. -- **Sources:** - - vendored `sources/pacioli-1494.md`: "You should not write backward, but always forward" - - vendored `sources/irs-records.md`: "A taxpayer establishes this reconciliation by demonstrating the relationship" - - external: Handelsgesetzbuch (HGB) section 239(3), official English translation: an entry may not be modified such that its original content can no longer be established -- **Symbolic form:** for ledger states `S_t` and `t <= t'`: `posted(S_t)` is a prefix of `posted(S_t')`. -- **Examples:** `ledger-a-mistake-is-reversed-never-erased` -- **Applicability:** every engine. - -### BS-ledger-5: A mistake is corrected by an opposite entry, never erased (B-9) - -- **Status:** AUTHORITY -- **Statement:** A mistaken entry MUST be corrected by a reversal: a new entry with the same accounts and amounts and the sides flipped, linked to the original. The original MUST remain as it was written, and the pair MUST net to zero on every account. -- **Sources:** - - vendored `sources/pacioli-1494.md`: "entry opposite this one in the credit for the same amount" -- **Symbolic form:** `reverse(E) = {(flip(s_i), a_i)}`; per account `signed(E) + signed(reverse(E)) = 0`. -- **Examples:** `ledger-a-mistake-is-reversed-never-erased` -- **Applicability:** every engine. - -### BS-ledger-6: An entry is reversed at most once; a closing entry never (B-10) - -- **Status:** CHOICE (the owner, 2026-10-13); options: reversible once, reversible repeatedly; a close never reversible -- **Statement:** An entry MUST be reversed at most once; a second reversal of it MUST be refused. A closing entry MUST NOT be reversed, because a closed period stays closed. To restore a reversed entry, post a new entry. -- **Sources:** - - vendored `sources/pacioli-1494.md`: "entry opposite this one in the credit for the same amount" -- **Symbolic form:** `|{R : reversal_of(R) = E}| <= 1`; `closing(E) implies no R with reversal_of(R) = E`. -- **Examples:** `ledger-a-mistake-is-reversed-never-erased` -- **Applicability:** an engine declares its pick (`claims.choices`). - -### BS-ledger-7: Every transaction has supporting documents; records are kept (B-20) - -- **Status:** AUTHORITY -- **Statement:** Every entry MUST have supporting documents, kept for the period of limitations of the return it supports (three years generally; seven for a bad debt). Evidence the books file with an entry MUST stay with it; a reversal needs none of its own. -- **Sources:** - - vendored `sources/irs-records.md`: "In addition, you must keep supporting documents" -- **Symbolic form:** `forall posted E, not reversal(E): |documents(E)| >= 1`. -- **Examples:** none yet. The example schema has no evidence event, and wanderbooks enforces this at its tool layer, not in the ledger core; a first-edition example would assert a policy of the tool surface, which is not engine-neutral. -- **Applicability:** a US taxpayer keeping records for a return. - -### BS-ledger-8: Control roles: one account each, of the right kind, moved only when empty (B-23) - -- **Status:** CONVENTION (a control account agrees with the records it controls); CHOICE (default codes, the owner, 2026-10-13 and 2026-10-15) -- **Statement:** An engine that posts by role (receivables, payables, revenue, and the like) MUST give each role exactly one account, postable and of the role's type, and MUST refuse to move a role to another account while the old one holds a balance. A control account's lines MUST each belong to the document that put them there; a hand line on one MUST be refused. -- **Sources:** - - external: ASC 310-10 (receivables) and ASC 405-20-40-1 (a liability is removed only when paid or released) -- **Symbolic form:** `role` is an injective partial map from roles to accounts; `bal(control(r)) = sum of open documents of r`. -- **Examples:** `arap-hand-control-posting-refused` -- **Applicability:** engines with documents (invoices, bills). - -### BS-ledger-9: The books keep how they are kept (B-29) - -- **Status:** CHOICE (the owner, 2026-10-16) -- **Statement:** The settings, conventions, decisions and open items that govern the books MUST be facts in the books, each with who decided it and when, so that replaying the books gives the same results without any state held elsewhere. -- **Sources:** - - external: IRS Publication 538, Accounting Periods and Methods, "Accounting Method": the method is a matter of the books -- **Symbolic form:** `state = fold(apply, empty, journal)`. -- **Examples:** none yet. The schema's setup is the example's own, not a journal; replay equivalence is checked by the wanderbooks adapter (`Store::verify`) after every example, not stated as an engine-neutral expectation. -- **Applicability:** engines that keep a journal. - -### BS-ledger-10: Renaming an account changes its label, nothing else (B-31) - -- **Status:** CHOICE (the owner, 2026-10-20) -- **Statement:** An account MUST be renamed without changing any entry, line, balance or statement amount; every report, past periods included, prints the new name. A blank name, an unchanged name, a sibling's name (ignoring capitals and surrounding space), a name holding a nine-digit number and a name that is not one line MUST be refused. -- **Sources:** - - vendored `sources/irs-records.md`: "A taxpayer establishes this reconciliation by demonstrating the relationship" - - vendored `sources/intuit-iif.md`: "Accounts are identified by NAME" -- **Symbolic form:** `rename(a, n)` leaves `lines`, `bal` unchanged; `name(a) = n`. -- **Examples:** `rename-changes-no-amount` -- **Applicability:** every engine. - -### BS-ledger-11: Every revenue and expense account maps to a line of the return (B-32) - -- **Status:** AUTHORITY (why it matters); CHOICE (warn versus refuse, the owner, 2026-10-20); options: warn, refuse -- **Statement:** A revenue or expense account (not a header) SHOULD carry a tax category linking it to a line of the return; under the refuse policy an engine MUST refuse to create one without a category or to clear one. A header needs none. -- **Sources:** - - vendored `sources/irs-records.md`: "Your books must show your gross income, as well as your deductions and credits." -- **Symbolic form:** `policy = refuse implies forall a in nominal, not header(a): category(a) defined`. -- **Examples:** `tax-category-required` -- **Applicability:** a US business filing a return. - -### BS-ledger-12: Evidence: extraction, dedup, annotations, settlements (B-44) - -- **Status:** CONVENTION -- **Statement:** Text and facts read off a receipt MUST be derived records the books can re-make, the file's bytes staying the authority; identical bytes MUST be one file wherever they are evidence; an annotation MUST change no amount; a payment's settlements MUST be named, never guessed. -- **Sources:** - - external: Revenue Procedure 98-25, section 5.01, machine-sensible records with an audit trail - - vendored `sources/irs-records.md`: "In addition, you must keep supporting documents" -- **Symbolic form:** `text(file) = f(bytes(file), extractor, version)`; `bytes(a) = bytes(b) implies file(a) = file(b)`. -- **Examples:** none yet. The example schema has no attachment or file event; an evidence example waits on a schema extension (the unit's step 5 names it as owed). -- **Applicability:** engines that hold receipts. - -### BS-ledger-13: Audits and attestations (B-45) - -- **Status:** reserved -- **Statement:** Reserved: allocated in the wanderbooks plan (8.0.3), not yet stated in `docs/spec/rules.md`. -- **Sources:** - - none yet -- **Symbolic form:** none yet. -- **Examples:** none yet -- **Applicability:** none yet. diff --git a/bookspec/spec/partner.md b/bookspec/spec/partner.md deleted file mode 100644 index d73360f..0000000 --- a/bookspec/spec/partner.md +++ /dev/null @@ -1,48 +0,0 @@ -# Area: partner - -Members of a partnership or LLC taxed as one, and the parties the books -deal with. - -### BS-partner-1: Partners share income by the partnership agreement; capital accounts carry it (B-13) - -- **Status:** AUTHORITY (shares, capital accounts); CHOICE (cent rounding, 2026-10-13; counting from the first close, 2026-10-16; the statement splits unclosed earnings the same running way, 2026-10-20) -- **Statement:** A partner's distributive share of income MUST be determined by the partnership agreement. Each member's capital account MUST be one running balance of contributions, allocations and distributions; a close MUST allocate the period's net income among members by their shares, the shares summing to one. An allocation MUST sum to exactly the net, each member's share rounded to the currency's smallest unit by the largest-remainder rule and a running split carrying what earlier closes gave. -- **Sources:** - - vendored `sources/irs-partnerships.md`: "A partner's distributive share of income, gain, loss, deduction, or credit shall, except as otherwise provided in this chapter, be determined by the partnership agreement." - - vendored `sources/irs-capital-accounts.md`: "capital account" -- **Symbolic form:** `sum_k share_k = 1`; `part_k(t) = round_LR(share_k * N(t)) - sum_{s= 0`. -- **Examples:** `schedule-daily-deferred-revenue`, `schedule-daily-small-first-and-last-month`, `schedule-equal-months-start-on-the-first` -- **Applicability:** accrual-basis entities. diff --git a/bookspec/spec/revenue.md b/bookspec/spec/revenue.md deleted file mode 100644 index 3077542..0000000 --- a/bookspec/spec/revenue.md +++ /dev/null @@ -1,13 +0,0 @@ -# Area: revenue - -How revenue is presented. - -### BS-revenue-1: Platform payouts are recorded gross when the business is the principal (B-17) - -- **Status:** AUTHORITY -- **Statement:** When the business controls the good or service before it is transferred to the customer (is the principal), a payout from a platform MUST be recorded as revenue in the gross amount of consideration, the platform's fee as an expense, and the deposit as gross less fee. An agent records only its fee or commission. -- **Sources:** - - external: ASC 606-10-55-37, 55-37A and 55-37B (principal: gross amount of consideration); ASC 606-10-55-38 (agent: the fee or commission); ASC 606-10-55-39 (indicators) -- **Symbolic form:** `revenue = gross`; `expense = fee`; `deposit = gross - fee`. -- **Examples:** none yet. The schema has no payout event; the example for a settlement through a processor (BS-arap-6) shows the same gross presentation for a customer payment. -- **Applicability:** principal versus agent is a judgment under 55-39; the CPA confirms. diff --git a/bookspec/spec/rules-index.md b/bookspec/spec/rules-index.md deleted file mode 100644 index 8d30d5f..0000000 --- a/bookspec/spec/rules-index.md +++ /dev/null @@ -1,60 +0,0 @@ -# bookspec's rule index - -Every rule of `docs/spec/rules.md` (B-1 to B-45), its bookspec -identifier `BS--` (`plan/served.md` P13 step 3), its area, -what its statement rests on and the examples that demonstrate it. - -A rule whose only source is a human choice carries rules.md's **CHOICE**, bookspec's status of the same name: -the specification names the options, an engine declares its pick -(`claims.choices`, `bookspec_checker::Claims`). Rules B-39, B-40, B-43 -and B-45 are allocated in `plan/served.md` 8.0.3 and not yet stated: their -identifier and area are fixed here so a later unit fills them in -without moving anyone else's number. - -| B | bookspec id | area | status | rule | examples | -|---|---|---|---|---|---| -| B-1 | `BS-entry-1` | entry | AUTHORITY | Every entry is a double entry: debits equal credits | `entry-an-unbalanced-entry-is-refused`, `statement-the-books-balance-and-the-equation-holds` | -| B-2 | `BS-entry-2` | entry | CONVENTION | A line's amount is positive; its side gives the direction | `entry-a-line-amount-is-positive` | -| B-3 | `BS-ledger-1` | ledger | AUTHORITY | One money through the ledger; foreign amounts at the transaction-date rate | `ledger-a-fraction-of-a-cent-is-refused` | -| B-4 | `BS-ledger-2` | ledger | AUTHORITY | Five elements, closed list | `ledger-a-child-account-has-its-parents-type` | -| B-5 | `BS-ledger-3` | ledger | CONVENTION | Normal balances | `statement-the-books-balance-and-the-equation-holds` | -| B-6 | `BS-statement-1` | statement | AUTHORITY | The trial balance balances | `statement-the-books-balance-and-the-equation-holds` | -| B-7 | `BS-statement-2` | statement | AUTHORITY | The accounting equation | `statement-the-books-balance-and-the-equation-holds` | -| B-8 | `BS-ledger-4` | ledger | AUTHORITY | Posted is forever: the book is written only forward | `ledger-a-mistake-is-reversed-never-erased` | -| B-9 | `BS-ledger-5` | ledger | AUTHORITY | A mistake is corrected by an opposite entry, never erased | `ledger-a-mistake-is-reversed-never-erased` | -| B-10 | `BS-ledger-6` | ledger | CHOICE | An entry is reversed at most once; a closing entry never | `ledger-a-mistake-is-reversed-never-erased` | -| B-11 | `BS-period-1` | period | AUTHORITY | Revenue and expenses close into profit and loss, and that into capital | `period-a-close-sweeps-the-year-into-capital` | -| B-12 | `BS-period-2` | period | CONVENTION | A closed period takes no new postings; periods close in order | `period-a-close-sweeps-the-year-into-capital` | -| B-13 | `BS-partner-1` | partner | AUTHORITY | Partners share income by the partnership agreement; capital accounts carry it | `members-close.odd-cent-across-closes`, `members-close.statement-before-and-after-a-close`, `members-close.thirds-over-twelve-months`, `members-close.true-up-of-a-per-close-split`, `p3-party-members-equity-and-k1` | -| B-14 | `BS-statement-3` | statement | AUTHORITY | Statements: P&L for a period; cash flows by the direct method | `cash-flow.debt-to-equity`, `cash-flow.government-fees`, `cash-flow.paid-on-the-companys-behalf`, `cash-flow.part-cash-purchase`, `cash-flow.reversal-of-a-reversal`, `cash-flow.reversed-property-contribution`, `cash-flow.two-member-llc-first-year`, `period-a-close-sweeps-the-year-into-capital` | -| B-15 | `BS-asset-1` | asset | CONVENTION | Depreciation: straight line, salvage never depreciated, partial months by days | `assets-acquired-before-in-service`, `assets-a-reversed-run-is-not-posted`, `assets-book-value-is-what-the-books-carry`, `depreciation-catch-up-month`, `depreciation-disposal-after-a-late-change-undone`, `depreciation-disposal-before-a-later-run`, `depreciation-disposal-in-final-month`, `depreciation-disposal-inside-a-posted-month`, `depreciation-disposal-mid-month`, `depreciation-final-month-by-days`, `depreciation-final-month-drift`, `depreciation-reversal-before-its-run-is-refused`, `depreciation-run-reversed-after-its-month` | -| B-16 | `BS-prepaid-1` | prepaid | AUTHORITY | Prepaid expenses and deferred revenue are recognized over the period benefited or earned | `schedule-daily-deferred-revenue`, `schedule-daily-small-first-and-last-month`, `schedule-equal-months-start-on-the-first` | -| B-17 | `BS-revenue-1` | revenue | AUTHORITY | Platform payouts are recorded gross when the business is the principal | — | -| B-18 | `BS-arap-1` | arap | CONVENTION | Aging buckets: Current, 1-30, 31-60, 61-90, 90+ days past due | — | -| B-19 | `BS-1099-1` | 1099 | AUTHORITY | 1099 reporting thresholds, payment methods, and decisions | `1099-a-dated-status-applies-by-payment-date`, `1099-a-members-check-through-financing-counts`, `1099-and-party-totals-for-a-members-payment`, `1099-card-payments-are-excluded`, `1099-money-between-own-accounts-pays-no-one`, `1099-money-from-a-payee-is-netted-only-as-a-refund`, `1099-the-threshold-is-the-years`, `1099-worksheet-through-as-of` | -| B-20 | `BS-ledger-7` | ledger | AUTHORITY | Every transaction has supporting documents; records are kept | — | -| B-21 | `BS-entry-3` | entry | AUTHORITY | Exact amounts in the books; rounding only on returns | `entry-cents-add-exactly` | -| B-22 | `BS-asset-2` | asset | AUTHORITY | Expense or capitalize (book policy, mirrors tax) | — | -| B-23 | `BS-ledger-8` | ledger | CONVENTION | Control roles: one account each, of the right kind, moved only when empty | `arap-hand-control-posting-refused` | -| B-24 | `BS-asset-3` | asset | CHOICE | Asset tags: one label per asset, never reused | — | -| B-25 | `BS-asset-4` | asset | AUTHORITY | Tax figures beside book value: the section 723 basis, the depreciable basis, the section 704(c) built-in gain | `assets-723-basis-apart-from-depreciable-basis`, `assets-built-in-gain-fixed-at-contribution`, `assets-four-contributed-devices` | -| B-26 | `BS-partner-2` | partner | AUTHORITY | Parties: one directory, no full TIN in the books | `p3-party-tin-last4` | -| B-27 | `BS-format-1` | format | CHOICE | The journal format gate: newer is not corrupt | — | -| B-28 | `BS-entry-4` | entry | CONVENTION | What new commands take: four-digit years, amounts a line can carry | — | -| B-29 | `BS-ledger-9` | ledger | CHOICE | The books keep how they are kept | — | -| B-30 | `BS-period-3` | period | AUTHORITY | A report asked for without dates covers this fiscal year so far | `default-period-fiscal-year-to-date` | -| B-31 | `BS-ledger-10` | ledger | CHOICE | Renaming an account changes its label, nothing else | `rename-changes-no-amount` | -| B-32 | `BS-ledger-11` | ledger | AUTHORITY | Every revenue and expense account maps to a line of the return | `tax-category-required` | -| B-33 | `BS-statement-4` | statement | CONVENTION | A package as of a day shows the books through that day | `as-of-package-stops-at-as-of` | -| B-34 | `BS-format-2` | format | AUTHORITY | The QuickBooks Desktop file (IIF): plain ASCII, every entry through as_of, closes left out, classes carried | `iif-quickbooks-journal-file` | -| B-35 | `BS-partner-3` | partner | CONVENTION | A party's part of an entry, and a party's totals | `p3-party-accrual-counts-once`, `p3-party-distribution-still-owed`, `p3-party-gift-card-is-a-promise`, `p3-party-members-equity-and-k1`, `p3-party-own-liability-never-pays`, `p3-party-reversed-card-payment`, `p3-party-signs-and-reversals` | -| B-36 | `BS-partner-4` | partner | AUTHORITY | The K-1 worksheet reports tax-basis capital beside book capital | `partnership-k1-tax-basis-capital`, `partnership-k1-tax-basis-odd-cents` | -| B-37 | `BS-statement-5` | statement | CHOICE | A project is a class with its facts; its figures are its tagged lines | `projects-invoice-and-bill-lines-count-by-project`, `projects-net-is-its-tagged-lines`, `projects-on-spec-costs-are-expenses` | -| B-38 | `BS-arap-2` | arap | CHOICE | A document's figures are its ledger lines that stand; a control account is its documents | `arap-hand-control-posting-refused` | -| B-39 | `BS-arap-3` | arap | — | Terms: when a document is due (allocated, not yet stated) | — | -| B-40 | `BS-arap-4` | arap | — | Numbers and the issued copy (allocated, not yet stated) | — | -| B-41 | `BS-arap-5` | arap | CHOICE | A debt that will not be collected is written off, and a recovery is income | `bad-debt-direct-write-off` | -| B-42 | `BS-arap-6` | arap | CONVENTION | A settlement is exact, one party, and names its lines; the gross discount and the gross fee | — | -| B-43 | `BS-entry-5` | entry | — | Drafts and evidence (allocated, not yet stated) | — | -| B-44 | `BS-ledger-12` | ledger | CONVENTION | Evidence is read from the bytes, never trusted over them; identical files are kept once | — | -| B-45 | `BS-ledger-13` | ledger | — | Audits and attestations (allocated, not yet stated) | — | diff --git a/bookspec/spec/statement.md b/bookspec/spec/statement.md deleted file mode 100644 index 887a559..0000000 --- a/bookspec/spec/statement.md +++ /dev/null @@ -1,56 +0,0 @@ -# Area: statement - -Reports: the trial balance, the accounting equation, the period -statements, the package as of a day, the project. - -### BS-statement-1: The trial balance balances (B-6) - -- **Status:** AUTHORITY -- **Statement:** The trial balance MUST list each account's balance as a debit or a credit, and the sum of the debits MUST equal the sum of the credits, as of every day. -- **Sources:** - - vendored `sources/pacioli-1494.md`: "should be equal to the total of the debits" - - vendored `sources/irs-records.md`: "the total debits must equal the total credits" -- **Symbolic form:** `sum{max(0, D_a - C_a)} = sum{max(0, C_a - D_a)}`, equivalently `sum_a (D_a - C_a) = 0` (`Bookspec.law_trial_balance`). -- **Examples:** `statement-the-books-balance-and-the-equation-holds` -- **Applicability:** every engine. - -### BS-statement-2: The accounting equation (B-7) - -- **Status:** AUTHORITY -- **Statement:** At every date, assets MUST equal liabilities plus equity plus revenue less expenses, each on its normal side. Income not yet closed is part of equity. -- **Sources:** - - external: FASB Concepts Statement No. 8, Chapter 4, paragraph E62: equity is the difference between the entity's assets and its liabilities -- **Symbolic form:** `A = L + E + (R - X)` (`Bookspec.law_accounting_equation`). -- **Examples:** `statement-the-books-balance-and-the-equation-holds` -- **Applicability:** every engine. - -### BS-statement-3: Statements: P&L for a period; cash flows by the direct method (B-14) - -- **Status:** AUTHORITY, except where marked CONVENTION or CHOICE -- **Statement:** The profit and loss for a period MUST report revenue and expense activity within it and MUST exclude closing entries. The statement of cash flows MUST report the cash receipts and payments of the period by operating, investing and financing activity (the direct method); transfers among cash accounts MUST NOT be flows; the three sections MUST sum to the change in the cash accounts' balances; noncash investing and financing MUST be disclosed apart. -- **Sources:** - - external: ASC 230-10-45-1 (cash effects of operating, investing and financing); 230-10-45-4 and 45-5 (cash and cash equivalents; transfers among them are not cash flows); 230-10-45-14 (cash receipts and payments from operating activities); 230-10-45-25 (classes of operating cash payments); 230-10-50-3 (noncash investing and financing) - - vendored `sources/fasb-asc.md`: "230-10-45" -- **Symbolic form:** `operating + investing + financing = cash_end - cash_begin` (`Bookspec.law_cash_flow_is_change_in_cash`); an entry contributes its net movement of the cash accounts only. -- **Examples:** `cash-flow.debt-to-equity`, `cash-flow.government-fees`, `cash-flow.paid-on-the-companys-behalf`, `cash-flow.part-cash-purchase`, `cash-flow.reversal-of-a-reversal`, `cash-flow.reversed-property-contribution`, `cash-flow.two-member-llc-first-year`, `period-a-close-sweeps-the-year-into-capital` -- **Applicability:** accrual and cash basis entities reporting under US GAAP. - -### BS-statement-4: A package as of a day shows the books through that day (B-33) - -- **Status:** CONVENTION; CHOICE for the 1099 worksheet (the owner, 2026-10-20) -- **Statement:** A report or package made as of a day MUST reflect nothing dated after it: no figure, entry, receipt or worksheet row. Balances run through the day; a period's activity from the period's start through it. -- **Sources:** - - vendored `sources/irs-periods-and-methods.md`: "A tax year is an annual accounting period for keeping records and reporting income and expenses" -- **Symbolic form:** `report(as_of) = report(books restricted to date <= as_of)`. -- **Examples:** `as-of-package-stops-at-as-of` -- **Applicability:** every engine that produces a package. - -### BS-statement-5: A project is a class with its facts; its figures are its tagged lines (B-37) - -- **Status:** CHOICE (the owner, 2026-10-21); CONVENTION (job costing); AUTHORITY (costs of work on spec are expensed) -- **Statement:** A project's revenue, expenses and net MUST be exactly the revenue and expense lines tagged with it, closes left out; its to-date figures MUST be every tagged line through the date; an on-spec project's unrecovered cost MUST be its expenses less its revenue to date, never below zero. -- **Sources:** - - external: ASC 340-40-25-5 and 340-40-25-7 (costs to fulfil a contract); ASC 450-30-25-1 (gain contingencies) -- **Symbolic form:** `net(p) = sum{signed credit-normal value of tagged revenue and expense lines of p}`. -- **Examples:** `projects-invoice-and-bill-lines-count-by-project`, `projects-net-is-its-tagged-lines`, `projects-on-spec-costs-are-expenses` -- **Applicability:** engines with classes or projects. diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 4e6e51e..af35684 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -323,7 +323,9 @@ fn run_local(mode: &str, args: Vec) -> i32 { "connect" => ledger_host::commands::connect(&prog, args), "passphrase" => ledger_host::commands::passphrase(&prog, args), #[cfg(unix)] - "update" => ledger_host::update::update(&prog, env!("CARGO_PKG_VERSION"), args), + "update" => { + ledger_host::update::update(&prog, ledger_host::update::program_version(), args) + } #[cfg(unix)] "rollback" => ledger_host::update::rollback(&prog, args), // Updates replace a running program's file in place, which Windows @@ -377,6 +379,8 @@ fn is_bare_launch(words: &[String], stdin_is_terminal: bool) -> bool { } fn main() { + #[cfg(unix)] + ledger_host::update::set_program_version(env!("CARGO_PKG_VERSION")); // A file's path may be any name the system takes: the file commands // get the words as given. Everything else is text. let given: Vec = env::args_os().skip(1).collect(); diff --git a/crates/ledger-cli/tests/agent_evals.rs b/crates/ledger-cli/tests/agent_evals.rs new file mode 100644 index 0000000..67c17d2 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals.rs @@ -0,0 +1,202 @@ +//! Agent evals (plan P5 step 7): scripted transcripts, no model. Each is a +//! JSON file in `agent_evals/`, played against a real books server through +//! the wire as an agent reaches it. The rule they enforce is the point of +//! the result envelope: **an agent that begins with `books.start` can do +//! its work by calling only what the previous result's `next_steps` named.** +//! +//! A transcript is `{ "setup": [..], "settings": "", "steps": [..] }`. +//! `setup` calls are made directly (the chart and the books an agent finds +//! when it arrives; they are not the agent's moves). A step is one of: +//! +//! - `{"call": "books.start", "args": {..}}`: only the first step, and +//! only `books.start`. +//! - `{"follow": "books.x", "with": {..}}`: the previous result's +//! `next_steps` must name tool `books.x`; the call is made with that +//! step's `args` and `with` laid over them (what the agent decides: an +//! amount, a date). +//! - `{"follow_prompt": "onboard"}`: the previous result's `next_steps` +//! must name that prompt; it is fetched with `prompts/get`. +//! - `{"expect_next_steps_contains": "books.x"}` and +//! `{"expect": {"/json/pointer": value}}` on the previous result. +//! +//! In `with` and `args`, `{"$upload": {"name", "mime", "text"}}` is the +//! upload_id of that file, uploaded first as an agent does, and +//! `{"$var": "x"}` is a value a setup call saved with `"save": {"x": +//! "/pointer"}`. +//! +//! Expected behavior comes from `docs/mcp-interface.md` ("The result +//! envelope") and plan 4.6. + +mod common; + +use common::{Books, Wire, answer_of}; +use serde_json::{Map, Value, json}; +use std::collections::HashMap; + +/// A value with `$upload` and `$var` laid in. +fn resolve(w: &mut Wire, vars: &HashMap, v: &Value) -> Value { + match v { + Value::Object(o) if o.len() == 1 && o.contains_key("$upload") => { + let u = &o["$upload"]; + let text = u["text"].as_str().expect("an upload has text"); + json!(w.upload( + text.as_bytes(), + u["name"].as_str().unwrap_or("file.pdf"), + u["mime"].as_str().unwrap_or("application/pdf"), + u["purpose"].as_str().unwrap_or("receipt"), + )) + } + Value::Object(o) if o.len() == 1 && o.contains_key("$var") => vars + .get(o["$var"].as_str().unwrap()) + .unwrap_or_else(|| panic!("no saved value {}", o["$var"])) + .clone(), + Value::Object(o) => Value::Object( + o.iter() + .map(|(k, x)| (k.clone(), resolve(w, vars, x))) + .collect(), + ), + Value::Array(a) => Value::Array(a.iter().map(|x| resolve(w, vars, x)).collect()), + other => other.clone(), + } +} + +/// Play one transcript; returns the tools it called after `books.start`. +fn play(name: &str, script: &Value) -> Vec { + let books = Books::with(&format!("eval-{name}"), "USD", script["settings"].as_str()); + let mut w = books.session("agent-evals"); + let mut vars: HashMap = HashMap::new(); + for s in script["setup"].as_array().into_iter().flatten() { + let args = resolve(&mut w, &vars, &s["args"]); + let tool = s["call"].as_str().unwrap(); + let out = + answer_of(&w.call(tool, args)).unwrap_or_else(|e| panic!("{name}: setup {tool}: {e}")); + for (var, pointer) in s["save"].as_object().into_iter().flatten() { + vars.insert( + var.clone(), + out.pointer(pointer.as_str().unwrap()) + .unwrap_or_else(|| panic!("{name}: setup {tool} has no {pointer}: {out}")) + .clone(), + ); + } + } + let mut last: Option = None; + let mut called = Vec::new(); + for (i, step) in script["steps"] + .as_array() + .expect("steps") + .iter() + .enumerate() + { + let here = format!("{name} step {i}"); + if let Some(tool) = step["call"].as_str() { + assert_eq!( + i, 0, + "{here}: only the first step may call without following" + ); + assert_eq!(tool, "books.start", "{here}: the first call is books.start"); + let args = resolve(&mut w, &vars, &step["args"]); + last = + Some(answer_of(&w.call_raw(tool, args)).unwrap_or_else(|e| panic!("{here}: {e}"))); + } else if let Some(want) = step["follow"].as_str() { + let prev = last + .as_ref() + .unwrap_or_else(|| panic!("{here}: follow before start")); + let offered = prev["next_steps"].as_array().cloned().unwrap_or_default(); + let named = offered + .iter() + .find(|s| s["tool"] == json!(want)) + .unwrap_or_else(|| { + panic!( + "{here}: {want} is not in the previous result's next_steps: {:?}", + offered + .iter() + .map(|s| s["tool"].clone()) + .collect::>() + ) + }); + let mut args: Map = + named["args"].as_object().cloned().unwrap_or_default(); + for (k, v) in step["with"].as_object().into_iter().flatten() { + args.insert(k.clone(), resolve(&mut w, &vars, v)); + } + let out = answer_of(&w.call_raw(want, Value::Object(args))) + .unwrap_or_else(|e| panic!("{here}: {want} was refused: {e}")); + called.push(want.to_string()); + last = Some(out); + } else if let Some(want) = step["follow_prompt"].as_str() { + let prev = last + .as_ref() + .unwrap_or_else(|| panic!("{here}: follow before start")); + assert!( + prev["next_steps"] + .as_array() + .into_iter() + .flatten() + .any(|s| s["prompt"] == json!(want)), + "{here}: prompt {want} is not in next_steps" + ); + let got = w.rpc("prompts/get", json!({"name": want, "arguments": {}})); + let text = got["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap_or_else(|| panic!("{here}: the prompt did not render: {got}")); + assert!(text.contains("books."), "{here}: {text}"); + } else if let Some(tool) = step["expect_next_steps_contains"].as_str() { + let prev = last.as_ref().unwrap(); + assert!( + prev["next_steps"] + .as_array() + .into_iter() + .flatten() + .any(|s| s["tool"] == json!(tool)), + "{here}: next_steps lack {tool}: {}", + prev["next_steps"] + ); + } else if let Some(expect) = step["expect"].as_object() { + let prev = last.as_ref().unwrap(); + for (pointer, want) in expect { + assert_eq!( + prev.pointer(pointer), + Some(want), + "{here}: {pointer} in {prev}" + ); + } + } else { + panic!("{here}: not a step: {step}"); + } + } + called +} + +/// Every transcript follows only what it was told, and ends where it says. +#[test] +fn agent_evals_follow_only_next_steps() { + let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/agent_evals"); + let mut files: Vec<_> = std::fs::read_dir(&dir) + .unwrap() + .map(|e| e.unwrap().path()) + .filter(|p| p.extension().is_some_and(|e| e == "json")) + .collect(); + files.sort(); + let names: Vec = files + .iter() + .map(|p| p.file_stem().unwrap().to_string_lossy().into_owned()) + .collect(); + assert_eq!( + names, + [ + "draft_awaiting_a_receipt", + "month_end_close", + "onboarding_to_first_post", + "receipt_backed_post", + "reconciliation", + "reversal" + ], + "the six evals the plan names" + ); + for (name, path) in names.iter().zip(&files) { + let script: Value = serde_json::from_str(&std::fs::read_to_string(path).unwrap()) + .unwrap_or_else(|e| panic!("{name}: {e}")); + let called = play(name, &script); + assert!(!called.is_empty(), "{name} calls nothing"); + } +} diff --git a/crates/ledger-cli/tests/agent_evals/draft_awaiting_a_receipt.json b/crates/ledger-cli/tests/agent_evals/draft_awaiting_a_receipt.json new file mode 100644 index 0000000..d8b5f92 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/draft_awaiting_a_receipt.json @@ -0,0 +1,102 @@ +{ + "description": "A draft that waits for its receipt, and posts when it arrives.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": {} + }, + { + "follow": "books.draft_entry", + "with": { + "date": "2026-01-10", + "description": "Landlord, January", + "no_party_reason": "test" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "6000", + "side": "debit", + "amount": "40.00" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "1000", + "side": "credit", + "amount": "40.00" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "follow": "books.set_draft_state", + "with": { + "state": "awaiting_evidence", + "note": "the landlord's receipt" + } + }, + { + "expect": { + "/state": "awaiting_evidence" + } + }, + { + "follow": "books.attach_receipt", + "with": { + "upload_id": { + "$upload": { + "name": "receipt.pdf", + "mime": "application/pdf", + "text": "%PDF-1.4 a receipt for rent", + "purpose": "receipt" + } + } + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/month_end_close.json b/crates/ledger-cli/tests/agent_evals/month_end_close.json new file mode 100644 index 0000000..d096cab --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/month_end_close.json @@ -0,0 +1,83 @@ +{ + "description": "A period that ended is closed: the open period is found, previewed, closed, and the books verified.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + }, + { + "call": "books.create_period", + "args": { + "name": "2025-12", + "start": "2025-12-01", + "end": "2025-12-31" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": { + "focus": "close" + } + }, + { + "expect_next_steps_contains": "books.list_periods" + }, + { + "follow": "books.list_periods", + "with": {} + }, + { + "follow": "books.close_period", + "with": {} + }, + { + "expect": { + "/status": "preview" + } + }, + { + "follow": "books.close_period", + "with": {} + }, + { + "expect": { + "/status": "closed" + } + }, + { + "expect_next_steps_contains": "books.verify" + }, + { + "follow": "books.verify", + "with": {} + }, + { + "expect": { + "/status": "ok" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/onboarding_to_first_post.json b/crates/ledger-cli/tests/agent_evals/onboarding_to_first_post.json new file mode 100644 index 0000000..e823f26 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/onboarding_to_first_post.json @@ -0,0 +1,102 @@ +{ + "description": "A new book: onboarding to the first posted entry, following only next_steps.", + "steps": [ + { + "call": "books.start", + "args": { + "agent": { + "model": "scripted", + "reasoning_effort": "none", + "harness": "agent_evals" + } + } + }, + { + "expect": { + "/state": "needs_onboarding" + } + }, + { + "expect_next_steps_contains": "books.set_setting" + }, + { + "follow_prompt": "onboard" + }, + { + "follow": "books.set_setting", + "with": { + "changes": [ + { + "key": "company.name", + "value": "Eval Co" + } + ], + "decided_by": "Avery Quill" + } + }, + { + "expect_next_steps_contains": "books.add_account" + }, + { + "follow": "books.add_account", + "with": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "follow": "books.add_account", + "with": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "follow": "books.draft_entry", + "with": { + "date": "2026-01-05", + "description": "Opening capital", + "no_party_reason": "opening balance" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "1000", + "side": "debit", + "amount": "10.00" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "3000", + "side": "credit", + "amount": "10.00" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "preview" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + }, + { + "expect_next_steps_contains": "books.show" + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/receipt_backed_post.json b/crates/ledger-cli/tests/agent_evals/receipt_backed_post.json new file mode 100644 index 0000000..f783ef4 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/receipt_backed_post.json @@ -0,0 +1,96 @@ +{ + "description": "A post the receipt policy covers: the preview asks for the receipt, the receipt is attached, the entry posts.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": {} + }, + { + "expect_next_steps_contains": "books.draft_entry" + }, + { + "follow": "books.draft_entry", + "with": { + "date": "2026-01-10", + "description": "January rent", + "no_party_reason": "test" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "6000", + "side": "debit", + "amount": "40.00" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "1000", + "side": "credit", + "amount": "40.00" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect_next_steps_contains": "books.attach_receipt" + }, + { + "follow": "books.attach_receipt", + "with": { + "upload_id": { + "$upload": { + "name": "receipt.pdf", + "mime": "application/pdf", + "text": "%PDF-1.4 a receipt for rent", + "purpose": "receipt" + } + } + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/reconciliation.json b/crates/ledger-cli/tests/agent_evals/reconciliation.json new file mode 100644 index 0000000..96acb5e --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/reconciliation.json @@ -0,0 +1,148 @@ +{ + "description": "A cash account reconciled to its statement: record it, mark the lines, reconcile.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + }, + { + "call": "books.draft_entry", + "args": { + "date": "2026-01-05", + "description": "Opening capital", + "no_party_reason": "opening balance" + }, + "save": { + "entry": "/entry_id" + } + }, + { + "call": "books.add_line", + "args": { + "entry_id": { + "$var": "entry" + }, + "account": "1000", + "side": "debit", + "amount": "10.00" + } + }, + { + "call": "books.add_line", + "args": { + "entry_id": { + "$var": "entry" + }, + "account": "3000", + "side": "credit", + "amount": "10.00" + } + }, + { + "call": "books.post_entry", + "args": { + "entry_id": { + "$var": "entry" + }, + "confirmed": true + } + }, + { + "call": "books.file_document", + "args": { + "category": "statement", + "file_name": "jan.pdf", + "mime_type": "application/pdf", + "upload_id": { + "$upload": { + "name": "jan.pdf", + "mime": "application/pdf", + "text": "%PDF-1.4 January statement", + "purpose": "document" + } + } + }, + "save": { + "statement": "/attachment_id" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": {} + }, + { + "expect_next_steps_contains": "books.record_statement" + }, + { + "follow": "books.record_statement", + "with": { + "attachment_id": { + "$var": "statement" + }, + "account": "1000", + "period_start": "2026-01-01", + "period_end": "2026-01-31", + "opening_balance": "0.00", + "closing_balance": "10.00" + } + }, + { + "follow": "books.mark_cleared", + "with": { + "entry_id": { + "$var": "entry" + }, + "line_index": 0, + "cleared": true + } + }, + { + "follow": "books.reconcile", + "with": { + "account": "1000", + "statement_date": "2026-01-31", + "statement_balance": "10.00", + "statement_attachment": { + "$var": "statement" + } + } + }, + { + "expect": { + "/status": "preview" + } + }, + { + "follow": "books.reconcile", + "with": {} + }, + { + "expect": { + "/status": "reconciled" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_evals/reversal.json b/crates/ledger-cli/tests/agent_evals/reversal.json new file mode 100644 index 0000000..b98fb63 --- /dev/null +++ b/crates/ledger-cli/tests/agent_evals/reversal.json @@ -0,0 +1,94 @@ +{ + "description": "A posted entry reversed: previewed, confirmed, never edited.", + "settings": "[company]\nname = \"Eval Co\"\nfiscal_year_start = \"01-01\"\n[books]\ncash_accounts = [\"1000\"]\n", + "setup": [ + { + "call": "books.add_account", + "args": { + "code": "1000", + "name": "Cash", + "account_type": "asset" + } + }, + { + "call": "books.add_account", + "args": { + "code": "3000", + "name": "Capital", + "account_type": "equity" + } + }, + { + "call": "books.add_account", + "args": { + "code": "6000", + "name": "Rent", + "account_type": "expense" + } + } + ], + "steps": [ + { + "call": "books.start", + "args": {} + }, + { + "follow": "books.draft_entry", + "with": { + "date": "2026-01-10", + "description": "Coffee beans", + "no_party_reason": "test" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "6000", + "side": "debit", + "amount": "10.00" + } + }, + { + "follow": "books.add_line", + "with": { + "account": "1000", + "side": "credit", + "amount": "10.00" + } + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "follow": "books.post_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + }, + { + "follow": "books.reverse_entry", + "with": { + "date": "2026-01-11", + "description": "Reverse: entered in error" + } + }, + { + "expect": { + "/status": "preview" + } + }, + { + "follow": "books.reverse_entry", + "with": {} + }, + { + "expect": { + "/status": "posted" + } + } + ] +} diff --git a/crates/ledger-cli/tests/agent_surface.rs b/crates/ledger-cli/tests/agent_surface.rs new file mode 100644 index 0000000..04c97fe --- /dev/null +++ b/crates/ledger-cli/tests/agent_surface.rs @@ -0,0 +1,545 @@ +//! The agent surface over the wire (plan P5): `books.start` v2, the +//! journal in words, source corrections, the prompts and the resource +//! templates. Every call goes through a real books server as an agent +//! reaches it (`common.rs`). +//! +//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — the +//! journal in words (P5)", "The result envelope", "Prompts", "Resources") +//! and plan 4.2, 4.4, 4.5. + +mod common; + +use common::{Books, answer_of}; +use serde_json::{Value, json}; +use std::path::Path; + +const SETTINGS: &str = "[company]\nname = \"Surface Co\"\nfiscal_year_start = \"01-01\"\n\ +[books]\ncash_accounts = [\"1000\"]\n"; + +fn chart(w: &mut common::Wire) { + for (code, name, ty) in [ + ("1000", "Cash", "asset"), + ("3000", "Capital", "equity"), + ("6000", "Rent", "expense"), + ] { + answer_of(&w.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + )) + .unwrap(); + } +} + +/// A posted entry of `amount` rent paid from cash (under the receipt +/// threshold); its id. +fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 { + let id = answer_of(&w.call( + "books.draft_entry", + json!({"date": date, "description": what, "no_party_reason": "test"}), + )) + .unwrap()["entry_id"] + .as_u64() + .unwrap(); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + answer_of(&w.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + )) + .unwrap(); + } + answer_of(&w.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + )) + .unwrap(); + id +} + +/// `books.journal_history` reads the journal in order, in words; filters +/// by subject and type; pages with `more`; refuses a bad subject; and a +/// refusal changes nothing. +#[test] +fn journal_history_traces_what_the_books_did() { + let books = Books::with("p5-history", "USD", Some(SETTINGS)); + let mut w = books.session("p5"); + chart(&mut w); + let first = post(&mut w, "2026-01-05", "January rent", "10.00"); + let second = post(&mut w, "2026-01-06", "Printer paper", "11.00"); + + let all = answer_of(&w.call("books.journal_history", json!({"limit": 200}))).unwrap(); + let lines = all["lines"].as_array().unwrap(); + let seqs: Vec = lines.iter().map(|l| l["seq"].as_u64().unwrap()).collect(); + assert!( + seqs.windows(2).all(|p| p[0] < p[1]), + "journal order: {seqs:?}" + ); + assert_eq!(all["head_seq"], *seqs.last().unwrap()); + assert_eq!(all["more"], false); + let posted = lines + .iter() + .find(|l| l["type"] == "entry.posted" && l["entry_id"] == json!(first)) + .unwrap_or_else(|| panic!("no entry.posted for {first}: {all}")); + assert!( + posted["summary"].as_str().unwrap().contains("January rent"), + "{posted}" + ); + + // A subject keeps the lines about it, and only those. + let about = answer_of(&w.call( + "books.journal_history", + json!({"subject": format!("entry:{second}")}), + )) + .unwrap(); + let about = about["lines"].as_array().unwrap(); + assert!(about.len() >= 3, "created, lines, posted: {about:?}"); + assert!( + about.iter().all(|l| l["entry_id"] == json!(second)), + "{about:?}" + ); + + // A type group, and an exact type. + let group = answer_of(&w.call("books.journal_history", json!({"types": ["entry."]}))).unwrap(); + assert!( + group["lines"] + .as_array() + .unwrap() + .iter() + .all(|l| l["type"].as_str().unwrap().starts_with("entry.")) + ); + let exact = + answer_of(&w.call("books.journal_history", json!({"types": ["entry.posted"]}))).unwrap(); + assert_eq!(exact["lines"].as_array().unwrap().len(), 2); + + // Paging: two at a time, from where the last page ended. + let page = answer_of(&w.call("books.journal_history", json!({"limit": 2}))).unwrap(); + assert_eq!(page["lines"].as_array().unwrap().len(), 2); + assert_eq!(page["more"], true); + let after = page["lines"][1]["seq"].as_u64().unwrap(); + let next = answer_of(&w.call( + "books.journal_history", + json!({"after_seq": after, "limit": 2}), + )) + .unwrap(); + assert_eq!(next["lines"][0]["seq"].as_u64().unwrap(), after + 1); + + // A bad subject is refused with the shapes; the journal is unchanged. + let before = books.journal(); + let err = + answer_of(&w.call("books.journal_history", json!({"subject": "nonsense"}))).unwrap_err(); + assert!(err.contains("entry:12"), "{err}"); + assert_eq!(books.journal(), before); + // It is a read: no head in the envelope. + assert!(all.get("head").is_none(), "{all}"); +} + +/// A source-only correction over the wire: the three tools take `reason` +/// (or `revises` with it), the words stay, the journal says so, and a +/// change of the words is not a correction. +#[test] +fn a_source_only_revision_over_the_wire() { + let books = Books::with("p5-source", "USD", Some(SETTINGS)); + let mut w = books.session("p5"); + let convention = |by: &str, body: &str| { + json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist", + "body_md": body, "decided_by": by, "decided_on": "2026-10-20"}) + }; + answer_of(&w.call( + "books.set_convention", + convention("Owner", "Reconcile, then close."), + )) + .unwrap(); + let same = answer_of(&w.call( + "books.set_convention", + convention("Pat Smith", "Reconcile, then close."), + )) + .unwrap_err(); + assert!( + same.contains("already says exactly that") && same.contains("reason"), + "{same}" + ); + let mut fixed = convention("Pat Smith", "Reconcile, then close."); + fixed["reason"] = json!("the CPA decided it"); + fixed["citation"] = json!("IRS Pub. 538"); + let out = answer_of(&w.call("books.set_convention", fixed.clone())).unwrap(); + assert_eq!( + out["convention"]["source"]["decided_by"], "Pat Smith", + "{out}" + ); + assert_eq!(out["convention"]["revision"], 1, "the words did not change"); + let mut other = convention("Pat Smith", "Close first."); + other["reason"] = json!("x"); + let err = answer_of(&w.call("books.set_convention", other)).unwrap_err(); + assert!(err.contains("more than its source"), "{err}"); + + // A decision and an open item. + let decision = answer_of(&w.call( + "books.record_decision", + json!({"subject": "Materiality", "decision_md": "Five hundred dollars.", + "decided_by": ["Owner"], "decided_on": "2026-10-20"}), + )) + .unwrap()["id"] + .clone(); + let fixed_decision = json!({"revises": decision, "reason": "both decided it", + "decided_by": ["Owner", "Pat Smith"], "decided_on": "2026-10-21"}); + answer_of(&w.call("books.record_decision", fixed_decision.clone())).unwrap(); + let d = answer_of(&w.call("books.decisions", json!({}))).unwrap(); + assert_eq!( + d["decisions"][0]["decided_by"], + json!(["Owner", "Pat Smith"]), + "{d}" + ); + assert_eq!(d["decisions"][0]["decision_md"], "Five hundred dollars."); + let no_reason = answer_of(&w.call( + "books.record_decision", + json!({"revises": decision, "decided_by": ["Pat Smith"], "decided_on": "2026-10-22"}), + )) + .unwrap_err(); + assert!(no_reason.contains("reason"), "{no_reason}"); + let item = answer_of(&w.call( + "books.raise_item", + json!({"title": "Ask the CPA", "decided_by": "Owner", "decided_on": "2026-10-20"}), + )) + .unwrap()["id"] + .clone(); + answer_of(&w.call( + "books.raise_item", + json!({"revises": item, "reason": "raised by the CPA", "decided_by": "Pat Smith", + "decided_on": "2026-10-21"}), + )) + .unwrap(); + let items = answer_of(&w.call("books.open_items", json!({}))).unwrap(); + assert_eq!(items["items"][0]["title"], "Ask the CPA"); + assert_eq!( + items["items"][0]["raised"]["decided_by"], "Pat Smith", + "{items}" + ); + + // The journal says what happened, in order. + let h = answer_of(&w.call( + "books.journal_history", + json!({"types": ["convention.", "decision.", "open_item."]}), + )) + .unwrap(); + let types: Vec<&str> = h["lines"] + .as_array() + .unwrap() + .iter() + .map(|l| l["type"].as_str().unwrap()) + .collect(); + assert_eq!( + types, + [ + "convention.set", + "convention.source_revised", + "decision.recorded", + "decision.source_revised", + "open_item.raised", + "open_item.source_revised" + ] + ); + // A restart reads the same books. + books.restart(); + let mut w = books.session("p5"); + let c = answer_of(&w.call("books.conventions", json!({}))).unwrap(); + assert_eq!( + c["conventions"][0]["source"]["decided_by"], "Pat Smith", + "{c}" + ); +} + +/// Prompts: six, listed with their arguments, rendered with the company's +/// name and the arguments; an argument is one token of the right shape. +#[test] +fn the_prompts_list_and_render() { + let books = Books::with("p5-prompts", "USD", Some(SETTINGS)); + let mut w = books.wire(); + let init = w.initialize("p5"); + assert!( + init["result"]["capabilities"]["prompts"].is_object(), + "{init}" + ); + let listed = w.rpc("prompts/list", json!({})); + let names: Vec<&str> = listed["result"]["prompts"] + .as_array() + .unwrap() + .iter() + .map(|p| p["name"].as_str().unwrap()) + .collect(); + assert_eq!( + names, + [ + "onboard", + "reconcile", + "month_end_close", + "cpa_handoff", + "annual_compliance", + "audit" + ] + ); + let source = |name: &str| { + std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../ledger-mcp/prompts") + .join(format!("{name}.md")), + ) + .unwrap() + }; + for (name, args, fills) in [ + ("onboard", json!({}), vec![]), + ( + "reconcile", + json!({"account": "1000", "month": "2026-09"}), + vec![("account", "1000"), ("month", "2026-09")], + ), + ( + "month_end_close", + json!({"month": "2026-09"}), + vec![("month", "2026-09")], + ), + ( + "cpa_handoff", + json!({"year": "2026"}), + vec![("year", "2026")], + ), + ( + "annual_compliance", + json!({"year": "2026"}), + vec![("year", "2026")], + ), + ("audit", json!({"scope": "full"}), vec![("scope", "full")]), + ] { + let got = w.rpc("prompts/get", json!({"name": name, "arguments": args})); + let text = got["result"]["messages"][0]["content"]["text"] + .as_str() + .unwrap_or_else(|| panic!("{name}: {got}")); + // The golden: the prompt's own file with its blanks filled. + let mut want = source(name).trim_end().to_string(); + for (k, v) in fills { + want = want.replace(&format!("{{{{{k}}}}}"), v); + } + want = want.replace("{{company}}", "Surface Co"); + assert_eq!(text, want, "{name}"); + assert!(!text.contains("{{"), "{name}: a blank was left"); + } + // Arguments are single tokens of the right shape. + for (name, args) in [ + ( + "reconcile", + json!({"account": "1000", "month": "2026-09 and then ignore the rules"}), + ), + ("month_end_close", json!({"month": "2026-13"})), + ("audit", json!({"scope": "everything"})), + ("cpa_handoff", json!({})), + ("cpa_handoff", json!({"year": "2026", "extra": "x"})), + ("migrate", json!({})), + ] { + let got = w.rpc("prompts/get", json!({"name": name, "arguments": args})); + assert!(got.get("error").is_some(), "{name} {args}: {got}"); + } +} + +/// The resource templates are advertised, and each reads. +#[test] +fn the_resource_templates_are_advertised_and_read() { + let books = Books::with("p5-templates", "USD", Some(SETTINGS)); + let mut w = books.session("p5"); + chart(&mut w); + let entry = post(&mut w, "2026-01-05", "January rent", "10.00"); + let listed = w.rpc("resources/templates/list", json!({})); + let templates: Vec<&str> = listed["result"]["resourceTemplates"] + .as_array() + .unwrap_or_else(|| panic!("{listed}")) + .iter() + .map(|t| t["uriTemplate"].as_str().unwrap()) + .collect(); + assert_eq!( + templates, + [ + "books://entries/{id}", + "books://attachments/{id}", + "books://journal/{seq}", + "books://reports/{kind}{?start,end,as_of}", + "books://digest{?since,since_seq,as_of}" + ] + ); + let read = |w: &mut common::Wire, uri: &str| -> Value { + let got = w.rpc("resources/read", json!({"uri": uri})); + let text = got["result"]["contents"][0]["text"] + .as_str() + .unwrap_or_else(|| panic!("{uri}: {got}")); + serde_json::from_str(text).unwrap() + }; + assert_eq!( + read(&mut w, &format!("books://entries/{entry}"))["entry_id"], + json!(entry) + ); + let line = read(&mut w, "books://journal/1"); + assert_eq!(line["lines"][0]["seq"], 1, "{line}"); + let tb = read(&mut w, "books://reports/trial-balance?as_of=2026-01-31"); + assert_eq!(tb["as_of"], "2026-01-31", "{tb}"); + let pl = read( + &mut w, + "books://reports/profit-and-loss?start=2026-01-01&end=2026-01-31", + ); + assert_eq!(pl["start"], "2026-01-01", "{pl}"); + let digest = read(&mut w, "books://digest?since_seq=1"); + assert!( + digest.get("entries").is_some() || digest.get("posted").is_some() || digest.is_object() + ); + let gone = w.rpc("resources/read", json!({"uri": "books://journal/99999"})); + assert!(gone.get("error").is_some(), "{gone}"); +} + +/// `books.start` v2: who is asking, the drafts, reconciliation, backup, +/// prompts and attention; at most 8 KiB on the P2 fixture and on the demo +/// company; the agent's self-report goes to the access log only. +#[test] +fn books_start_v2_is_whole_and_fits() { + let fixture = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/p2-served"); + let books = Books::placed("p5-start-v2", &fixture); + let mut w = books.session("claude-code"); + let raw = w.call( + "books.start", + json!({"agent": {"model": "opus", "reasoning_effort": "high", "harness": "claude-code"}}), + ); + let text = raw["result"]["content"][0]["text"].as_str().unwrap(); + assert!( + text.len() <= 8 * 1024, + "books.start is {} bytes", + text.len() + ); + let start = answer_of(&raw).unwrap(); + assert!(start["book"]["id"].is_string()); + assert!(start["book"]["url"].as_str().unwrap().starts_with("/b/")); + assert!(start["book"]["url"].as_str().unwrap().ends_with("/mcp")); + books.home.assert_hidden(text); + assert_eq!(start["you"]["kind"], "agent", "{start}"); + assert_eq!(start["you"]["client_id"], "claude-code"); + assert!(!start["you"]["scopes"].as_array().unwrap().is_empty()); + assert!(start["drafts"]["building"].is_u64()); + assert!(start["drafts"]["awaiting_evidence"].is_array()); + for k in ["tracked", "never_reconciled", "behind"] { + assert!(start["reconciliation"][k].is_array(), "{k}"); + } + assert!(start["backup"]["stale"].is_boolean()); + assert_eq!(start["prompts"].as_array().unwrap().len(), 6); + assert!(start["needs_attention"]["items"].as_array().unwrap().len() <= 12); + assert!(start["needs_attention"]["counts"].is_object()); + for s in start["next_steps"].as_array().unwrap() { + assert!(s["why"].is_string() && s["required"].is_boolean(), "{s}"); + assert!(s["tool"].is_string() || s["prompt"].is_string(), "{s}"); + } + // The envelope: a read has no head of the envelope's (its own stays). + assert!(start["head"]["seq"].is_u64()); + + // The self-report is in the access log, beside the call. + let log = answer_of(&w.call("books.access_log", json!({"limit": 50}))).unwrap(); + let agent = log["records"] + .as_array() + .unwrap() + .iter() + .find(|r| r["subject"] == "books.start" && r.get("agent").is_some()) + .unwrap_or_else(|| panic!("no agent in the log: {log}")); + assert_eq!(agent["agent"]["model"], "opus"); + assert_eq!(agent["agent"]["harness"], "claude-code"); + // And nowhere else: not in the answer. + assert!(!text.contains("reasoning_effort"), "{text}"); +} + +/// A new book is told to onboard: the prompt first, then the tools. +#[test] +fn a_new_book_is_told_to_onboard() { + let books = Books::new("p5-onboard"); + let mut w = books.session("p5"); + let start = answer_of(&w.call("books.start", json!({}))).unwrap(); + assert_eq!(start["state"], "needs_onboarding"); + let steps = start["next_steps"].as_array().unwrap(); + assert_eq!(steps[0]["prompt"], "onboard", "{steps:?}"); + assert!(steps.iter().any(|s| s["tool"] == "books.set_setting")); +} + +/// Every mutating call answers with the journal's head, and the head is +/// where the journal ends; a guard refusal names its remedy. +#[test] +fn a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy() { + let books = Books::with("p5-head", "USD", Some(SETTINGS)); + let mut w = books.session("p5"); + let added = answer_of(&w.call( + "books.add_account", + json!({"code": "7000", "name": "Misc", "account_type": "expense"}), + )) + .unwrap(); + let head = added["head"]["seq"].as_u64().unwrap(); + let start = answer_of(&w.call("books.start", json!({}))).unwrap(); + assert_eq!( + start["head"]["seq"], + json!(head), + "the head the call answered is the journal's" + ); + assert!(!added["next_steps"].as_array().unwrap().is_empty()); + // A confirm with no preview is refused as stale_preview, naming the + // tool that previews. + chart(&mut w); + let draft = answer_of(&w.call( + "books.draft_entry", + json!({"date": "2026-01-05", "description": "Rent", "no_party_reason": "test"}), + )) + .unwrap()["entry_id"] + .clone(); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + answer_of(&w.call( + "books.add_line", + json!({"entry_id": draft, "account": account, "side": side, "amount": "5.00"}), + )) + .unwrap(); + } + let refused = w.call_raw( + "books.post_entry", + json!({"entry_id": draft, "confirmed": true}), + ); + assert_eq!(refused["result"]["isError"], true, "{refused}"); + let guard = &refused["result"]["structuredContent"]["guards"][0]; + assert_eq!(guard["code"], "stale_preview", "{refused}"); + assert_eq!(guard["remedy_tool"], "books.post_entry"); +} + +/// Done-when (3): `books.start` is at most 8 KiB on the demo company too +/// (the books the viewer ships with, a loose end of every kind), and says +/// what it cut. +#[test] +fn books_start_fits_8_kib_on_the_demo_books() { + let today = ledger_core::settings::today_in(None); + let place = common::Place::new("p5-start-demo"); + let source = tempfile::Builder::new() + .prefix("wb-p5-demo-") + .tempdir() + .unwrap(); + ledger_gui::demo::build(source.path(), today).unwrap(); + std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap(); + std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap(); + let mut wire = place.start(None); + wire.initialize("p5"); + let raw = wire.call("books.start", json!({})); + let text = raw["result"]["content"][0]["text"].as_str().unwrap(); + assert!( + text.len() <= 8 * 1024, + "books.start is {} bytes", + text.len() + ); + let start = answer_of(&raw).unwrap(); + assert_eq!(start["state"], "active"); + assert!( + !start["needs_attention"]["items"] + .as_array() + .unwrap() + .is_empty(), + "the demo has loose ends: {start}" + ); + assert!( + start["needs_attention"]["counts"] + .as_object() + .unwrap() + .len() + >= 2 + ); +} diff --git a/crates/ledger-cli/tests/attention_parity.rs b/crates/ledger-cli/tests/attention_parity.rs index bb9c4aa..533fccf 100644 --- a/crates/ledger-cli/tests/attention_parity.rs +++ b/crates/ledger-cli/tests/attention_parity.rs @@ -9,7 +9,7 @@ use std::collections::HashSet; use std::time::Instant; -use chrono::NaiveDate; +use chrono::{Datelike, NaiveDate}; use ledger_core::{AccountType, Currency, Money, Side}; use ledger_gui::{Source, data}; use rust_decimal::Decimal; @@ -350,3 +350,79 @@ fn a_waiting_draft_and_a_building_draft_are_two_items_on_both_lists() { "{titles:?}" ); } + +/// Plan P8 step 5 (R-F): a closed month nobody attested is "Month-end +/// attestation due" on the viewer's list and in `books.start`'s next steps, +/// from the one engine; the fiscal year that ended closed with no audit +/// for the handoff is "Full audit due before the CPA handoff"; an +/// attestation clears the month. Books that never declared `audits` get +/// none of them (they cannot act on them). +#[test] +fn audit_items_are_on_both_lists_once_audits_are_declared() { + let today = ledger_core::settings::today_in(None); + let place = common::Place::new("attention-audit-parity"); + let dir = place.books_data(); + let _ = std::fs::remove_dir_all(&dir); + let year = today.year() - 1; + let day = |m: u32, d: u32| NaiveDate::from_ymd_opt(year, m, d).unwrap(); + let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap(); + let cash = st + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let capital = st + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let e = st.create_entry(day(1, 5), "Capital").unwrap(); + st.add_line(e, cash, Side::Debit, usd(100_000)).unwrap(); + st.add_line(e, capital, Side::Credit, usd(100_000)).unwrap(); + st.post_entry(e).unwrap(); + let jan = st.create_period("Jan", day(1, 1), day(1, 31)).unwrap(); + st.close_period(jan).unwrap(); + let dec = st.create_period("Dec", day(12, 1), day(12, 31)).unwrap(); + st.close_period(dec).unwrap(); + drop(st); + let mut wire = place.start(Some(SETTINGS)); + wire.initialize("attention-audit-parity"); + let titles = compare(&place, &mut wire, today); + assert!( + titles + .iter() + .any(|t| t.starts_with("Month-end attestation due")), + "{titles:?}" + ); + assert!( + titles + .iter() + .any(|t| t.starts_with("Full audit due before the CPA handoff")), + "{titles:?}" + ); + // Audit and attest January: that month's item goes, the year's stays. + let run = common::answer_of(&wire.call( + "books.audit", + json!({"scope": "month", "through": day(1, 31).to_string(), "confirmed": true}), + )) + .unwrap(); + assert_eq!(run["status"], "audited", "{run}"); + common::answer_of(&wire.call( + "books.attest_period", + json!({"period": "Jan", "method": {"reasoning_effort": "high"}}), + )) + .unwrap(); + let after = compare(&place, &mut wire, today); + let due: Vec<&String> = after + .iter() + .filter(|t| t.starts_with("Month-end attestation due")) + .collect(); + assert_eq!(due.len(), 1, "{after:?}"); + assert!(due[0].contains("Dec"), "{after:?}"); + // The step offers the audit. + let answer = start(&mut wire); + let step = answer["next_steps"] + .as_array() + .unwrap() + .iter() + .find(|s| s["why"] == json!(due[0].as_str())) + .unwrap_or_else(|| panic!("no step: {answer}")); + assert_eq!(step["tool"], json!("books.audit"), "{answer}"); + assert_eq!(step["args"]["scope"], json!("month"), "{answer}"); +} diff --git a/crates/ledger-cli/tests/audit.rs b/crates/ledger-cli/tests/audit.rs new file mode 100644 index 0000000..b8cd31f --- /dev/null +++ b/crates/ledger-cli/tests/audit.rs @@ -0,0 +1,419 @@ +//! Audits and attestations over the wire (plan P8; rules.md B-45, D-9): +//! a deterministic run and workpaper, claims weighed against the bar when +//! asked, the CPA package that waits for a counted audit, and books that +//! have not declared `audits`. +//! +//! Expected behavior comes from `docs/mcp-interface.md` ("Tools — audit +//! and attestations") and rules.md B-45. Every call goes through a real +//! books server as an agent reaches it (`common.rs`). + +mod common; + +use common::{Books, answer_of}; +use serde_json::{Value, json}; + +/// Cash at 1000, the default 25.00 receipt threshold. +const SETTINGS: &str = "[company]\nname = \"Audit Co\"\nfiscal_year_start = \"01-01\"\n\ +[books]\ncash_accounts = [\"1000\"]\n"; + +fn chart(w: &mut common::Wire) { + for (code, name, ty) in [ + ("1000", "Cash", "asset"), + ("3000", "Capital", "equity"), + ("4000", "Sales", "revenue"), + ("6000", "Supplies", "expense"), + ] { + answer_of(&w.call( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + )) + .unwrap(); + } +} + +/// A posted entry of `amount` supplies paid from cash on `date`. +fn post(w: &mut common::Wire, date: &str, what: &str, amount: &str) -> u64 { + let r = answer_of(&w.call( + "books.draft_entry", + json!({"date": date, "description": what, "no_party_reason": "test"}), + )) + .unwrap(); + let id = r["entry_id"].as_u64().unwrap(); + for (account, side) in [("6000", "debit"), ("1000", "credit")] { + answer_of(&w.call( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + )) + .unwrap(); + } + id +} + +fn receipt(w: &mut common::Wire, entry: u64, text: &str) -> u64 { + let upload = w.upload(text.as_bytes(), "receipt.txt", "text/plain", "receipt"); + answer_of(&w.call( + "books.attach_receipt", + json!({"entry_id": entry, "upload_id": upload}), + )) + .unwrap()["attachment_id"] + .as_u64() + .unwrap() +} + +fn confirm_post(w: &mut common::Wire, id: u64) { + let r = answer_of(&w.call( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + )) + .unwrap(); + assert_eq!(r["status"], "posted", "{r}"); +} + +/// Capital in, a 40.00 purchase with its receipt, and a 60.00 one with +/// none. Returns (receipted entry, its receipt, the bare entry). +fn setup(tag: &str) -> (Books, common::Wire, u64, u64, u64) { + let books = Books::with(tag, "USD", Some(SETTINGS)); + let mut w = books.session("p8"); + chart(&mut w); + let cap = answer_of(&w.call( + "books.draft_entry", + json!({"date": "2026-03-01", "description": "Capital", "no_party_reason": "test"}), + )) + .unwrap()["entry_id"] + .as_u64() + .unwrap(); + for (account, side) in [("1000", "debit"), ("3000", "credit")] { + answer_of(&w.call( + "books.add_line", + json!({"entry_id": cap, "account": account, "side": side, "amount": "1000.00"}), + )) + .unwrap(); + } + receipt(&mut w, cap, "Opening deposit 1000.00 2026-03-01"); + confirm_post(&mut w, cap); + let paper = post(&mut w, "2026-03-05", "Printer paper", "40.00"); + let rid = receipt(&mut w, paper, "Printer paper 40.00 2026-03-05"); + confirm_post(&mut w, paper); + // The 60.00 purchase is posted while the books' receipt threshold was + // higher, as it could have been before the rule was tightened. + set_threshold(&mut w, "1000.00"); + let bare = post(&mut w, "2026-03-07", "Toner", "60.00"); + confirm_post(&mut w, bare); + set_threshold(&mut w, "25.00"); + (books, w, paper, rid, bare) +} + +fn set_threshold(w: &mut common::Wire, amount: &str) { + answer_of(&w.call( + "books.set_setting", + json!({"decided_by": "Avery Quill", + "changes": [{"key": "policy.evidence.threshold", "value": amount}]}), + )) + .unwrap(); +} + +fn audit(w: &mut common::Wire, scope: &str, through: &str) -> Value { + answer_of(&w.call( + "books.audit", + json!({"scope": scope, "through": through, "confirmed": true}), + )) + .unwrap_or_else(|e| panic!("books.audit: {e}")) +} + +fn codes(out: &Value) -> Vec { + out["findings"] + .as_array() + .unwrap() + .iter() + .map(|f| f["code"].as_str().unwrap().to_string()) + .collect() +} + +/// The same books, day and scope give the same run, findings and +/// workpaper; asking again files and journals nothing; an entry makes it a +/// new audit. +#[test] +fn audit_is_deterministic() { + let (books, mut w, _, _, bare) = setup("p8-deterministic"); + let preview = answer_of(&w.call( + "books.audit", + json!({"scope": "full", "through": "2026-03-31"}), + )) + .unwrap(); + assert_eq!(preview["status"], "preview"); + assert!(preview["run"].is_null()); + assert!( + preview["checks"] + .as_array() + .unwrap() + .iter() + .any(|c| c["code"] == "evidence_missing") + ); + let first = audit(&mut w, "full", "2026-03-31"); + assert_eq!(first["status"], "audited", "{first}"); + assert!( + codes(&first).contains(&"evidence_missing".to_string()), + "the bare 60.00 entry has no receipt: {first}" + ); + let run = first["run"].clone(); + assert_eq!(run["at_current_head"], true); + let journal = books.journal(); + let again = audit(&mut w, "full", "2026-03-31"); + assert_eq!(again["status"], "already_run"); + assert_eq!(again["run"]["id"], run["id"]); + assert_eq!(again["run"]["findings_sha256"], run["findings_sha256"]); + assert_eq!(again["findings"], first["findings"]); + assert_eq!(books.journal(), journal, "asking again journals nothing"); + // The workpaper is the same twice, JSON and PDF. + let id = run["id"].clone(); + let one = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap(); + let two = answer_of(&w.call("books.workpaper", json!({"id": id}))).unwrap(); + assert_eq!(one["workpaper"], two["workpaper"]); + assert_eq!(one["pdf"]["sha256"], two["pdf"]["sha256"]); + assert_eq!(one["workpaper"]["findings_sha256"], run["findings_sha256"]); + let pdf = w.fetch(&one["pdf"]); + assert!(pdf.starts_with(b"%PDF")); + // The audit fixes nothing and moves no figure; an entry makes it stale. + let tb = answer_of(&w.call("books.trial_balance", json!({"as_of": "2026-03-31"}))).unwrap(); + assert_eq!(tb["balanced"], true); + let extra = post(&mut w, "2026-03-09", "Stamps", "10.00"); + confirm_post(&mut w, extra); + let third = audit(&mut w, "full", "2026-03-31"); + assert_eq!(third["status"], "audited"); + assert_ne!(third["run"]["id"], run["id"]); + let _ = bare; +} + +/// A reading that differs from the entry never counts; one that agrees +/// counts only at the bar, and the bar is read when asked. +#[test] +fn attestations_count_against_the_bar_when_asked() { + let (_books, mut w, paper, rid, _) = setup("p8-bar"); + let good = answer_of(&w.call( + "books.attest_receipt", + json!({"attachment_id": rid, "entry_id": paper, "amount": "40.00", "date": "2026-03-05", + "method": {"model": "opus", "reasoning_effort": "high"}}), + )) + .unwrap(); + assert_eq!(good["attestation"]["counted"], true, "{good}"); + assert_eq!(good["attestation"]["by"], "test"); + let wrong = answer_of(&w.call( + "books.attest_receipt", + json!({"attachment_id": rid, "entry_id": paper, "amount": "41.00", "date": "2026-03-05", + "method": {"reasoning_effort": "max", "adversarial": true, "verifier_agents": 5}}), + )) + .unwrap(); + assert_eq!(wrong["attestation"]["disagrees"], true); + assert_eq!(wrong["attestation"]["counted"], false); + assert!( + wrong["why_not_counted"] + .as_str() + .unwrap() + .contains("differs") + ); + let low = answer_of(&w.call( + "books.review_entry", + json!({"entry_id": paper, "method": {"reasoning_effort": "medium"}}), + )) + .unwrap(); + assert_eq!(low["attestation"]["counted"], false); + assert!(low["why_not_counted"].as_str().unwrap().contains("high")); + let none = answer_of(&w.call("books.review_entry", json!({"entry_id": paper}))).unwrap(); + assert_eq!(none["attestation"]["counted"], false, "no claim, no count"); + let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap(); + assert_eq!(listed["attestations"].as_array().unwrap().len(), 4); + assert_eq!(listed["counted"], 1); + assert_eq!(listed["bar"], "effort=high verifiers=2 adversarial=true"); + let by_kind = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap(); + assert_eq!(by_kind["attestations"].as_array().unwrap().len(), 2); + // The bar is read when asked: lowered, the medium review counts. + answer_of(&w.call( + "books.set_setting", + json!({"decided_by": "Avery Quill", + "changes": [{"key": "policy.attestation_bar", "value": "effort=medium"}]}), + )) + .unwrap(); + let after = answer_of(&w.call("books.attestations", json!({"kind": "entry"}))).unwrap(); + assert_eq!(after["bar"], "effort=medium verifiers=2 adversarial=true"); + assert_eq!(after["attestations"][0]["counted"], true); + assert_eq!(after["attestations"][1]["counted"], false); + // Refused: bad method words, an unposted entry. + let bad = w.call( + "books.review_entry", + json!({"entry_id": paper, "method": {"reasoning_effort": "wild"}}), + ); + assert!(answer_of(&bad).unwrap_err().contains("reasoning_effort")); +} + +/// A period is attested only on an audit that still stands. +#[test] +fn a_period_is_attested_on_a_standing_audit() { + let (_books, mut w, paper, _, _) = setup("p8-period"); + answer_of(&w.call( + "books.create_period", + json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}), + )) + .unwrap(); + let method = json!({"reasoning_effort": "high"}); + let none = w.call( + "books.attest_period", + json!({"period": "2026-03", "method": method}), + ); + assert!(answer_of(&none).unwrap_err().contains("books.audit")); + let run = audit(&mut w, "month", "2026-03-31"); + let ok = answer_of(&w.call( + "books.attest_period", + json!({"period": "2026-03", "method": method}), + )) + .unwrap(); + assert_eq!(ok["attestation"]["audit_run_id"], run["run"]["id"]); + assert_eq!( + ok["attestation"]["counted"], true, + "a month needs the effort only" + ); + // A change since the audit: refused until it is run again. + let extra = post(&mut w, "2026-03-20", "Stamps", "5.00"); + confirm_post(&mut w, extra); + let stale = w.call( + "books.attest_period", + json!({"period": "2026-03", "method": method}), + ); + assert!(answer_of(&stale).unwrap_err().contains("books.audit")); + let listing = answer_of(&w.call("books.workpaper", json!({"id": run["run"]["id"]}))).unwrap(); + assert_eq!(listing["run"]["at_current_head"], false); + let _ = paper; +} + +/// D-9: the CPA package waits for a counted audit for the handoff; a +/// reason overrides it and is journaled as a decision; the bar is the +/// whole bar for a year. +#[test] +fn cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given() { + let (_books, mut w, paper, rid, bare) = setup("p8-cpa"); + // Unset, the package warns until the books hold an audit run; the + // owner who wants it refused from the start says so. + answer_of(&w.call( + "books.set_setting", + json!({"decided_by": "Avery Quill", + "changes": [{"key": "policy.cpa_package_requires_audit", "value": "refuse"}]}), + )) + .unwrap(); + let export = |w: &mut common::Wire, extra: Value| { + let mut args = json!({"as_of": "2026-03-31", "cash_accounts": ["1000"], + "include_receipts": false, "include_tins": false}); + for (k, v) in extra.as_object().unwrap() { + args[k] = v.clone(); + } + w.call("books.export_cpa_package", args) + }; + let refused = export(&mut w, json!({})); + let err: Value = serde_json::from_str(&answer_of(&refused).unwrap_err()).unwrap(); + assert_eq!(err["code"], "audit_required", "{err}"); + assert_eq!(err["remedy_tool"], "books.audit"); + // A reason lets it go, and is a decision in the journal. + let before = answer_of(&w.call("books.decisions", json!({}))).unwrap(); + answer_of(&export( + &mut w, + json!({"without_audit_reason": " the CPA asked for it early "}), + )) + .unwrap(); + let after = answer_of(&w.call("books.decisions", json!({}))).unwrap(); + assert_eq!( + after["decisions"].as_array().unwrap().len(), + before["decisions"].as_array().unwrap().len() + 1 + ); + assert!(after.to_string().contains("without a counted audit")); + // An audit alone does not open it; nor one attested below the bar. + answer_of(&w.call( + "books.create_period", + json!({"name": "2026-03", "start": "2026-03-01", "end": "2026-03-31"}), + )) + .unwrap(); + audit(&mut w, "cpa_handoff", "2026-03-31"); + assert!(answer_of(&export(&mut w, json!({}))).is_err()); + answer_of(&w.call( + "books.attest_period", + json!({"period": "2026-03", "method": {"reasoning_effort": "high"}}), + )) + .unwrap(); + assert!( + answer_of(&export(&mut w, json!({}))).is_err(), + "a year-level audit needs the adversarial check and the verifiers" + ); + answer_of(&w.call( + "books.attest_period", + json!({"period": "2026-03", "method": {"reasoning_effort": "high", + "adversarial": true, "verifier_agents": 2}}), + )) + .unwrap(); + let made = answer_of(&export(&mut w, json!({}))).unwrap(); + assert!(made["file"]["sha256"].is_string(), "{made}"); + // A change since: the audit no longer stands, and the package waits again. + let extra = post(&mut w, "2026-03-20", "Stamps", "5.00"); + confirm_post(&mut w, extra); + assert!(answer_of(&export(&mut w, json!({}))).is_err()); + // Under warn the package is made and says so. + answer_of(&w.call( + "books.set_setting", + json!({"decided_by": "Avery Quill", + "changes": [{"key": "policy.cpa_package_requires_audit", "value": "warn"}]}), + )) + .unwrap(); + let warned = answer_of(&export(&mut w, json!({}))).unwrap(); + assert!( + warned["warnings"] + .to_string() + .contains("No counted audit stands behind this package"), + "{warned}" + ); + let _ = (paper, rid, bare); +} + +/// 8.0.6: books the version before P8 wrote (the fixture `p8_before`) +/// open with no upgrade; the audit's preview works, its run and every +/// attestation are refused with format_upgrade_pending, nothing is +/// journaled, and the CPA package is not held up by an audit it cannot ask +/// for. +#[test] +fn audits_wait_for_the_upgrade() { + let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../ledger-store/tests/fixtures/old-books/p8_before"); + let only = tempfile::tempdir().unwrap(); + std::fs::copy(src.join("journal.jsonl"), only.path().join("journal.jsonl")).unwrap(); + common::copy_private(&src.join("blobs"), &only.path().join("blobs")); + let books = Books::placed("p8-waits", only.path()); + let mut w = books.session("p8"); + let preview = answer_of(&w.call( + "books.audit", + json!({"scope": "full", "through": "2026-02-28"}), + )) + .unwrap(); + assert_eq!(preview["status"], "preview"); + let journal = books.journal(); + for (tool, args) in [ + ( + "books.audit", + json!({"scope": "full", "through": "2026-02-28", "confirmed": true}), + ), + ( + "books.review_entry", + json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}), + ), + ( + "books.attest_receipt", + json!({"attachment_id": 1, "entry_id": 1, "amount": "1000.00", + "date": "2026-01-01"}), + ), + ] { + let e = answer_of(&w.call(tool, args)).unwrap_err(); + assert!(e.starts_with("format_upgrade_pending"), "{tool}: {e}"); + } + assert_eq!(books.journal(), journal, "a refusal journals nothing"); + // The package is held up only by what it always was: an open draft. + let args = json!({"as_of": "2026-01-31", "cash_accounts": ["1000"], + "include_receipts": false, "include_tins": false}); + let made = answer_of(&w.call("books.export_cpa_package", args)).unwrap(); + assert!(made["file"]["sha256"].is_string(), "{made}"); + let listed = answer_of(&w.call("books.attestations", json!({}))).unwrap(); + assert!(listed["attestations"].as_array().unwrap().is_empty()); +} diff --git a/crates/ledger-cli/tests/common/mod.rs b/crates/ledger-cli/tests/common/mod.rs index 981f4d2..a98a68c 100644 --- a/crates/ledger-cli/tests/common/mod.rs +++ b/crates/ledger-cli/tests/common/mod.rs @@ -205,7 +205,13 @@ impl Server { BufReader::new(child.stdout.take().unwrap()) .read_line(&mut line) .unwrap(); - assert_eq!(line, "ready\n", "the server didn't say ready"); + if line != "ready\n" { + // Say why (the portable jobs run this on macOS and Windows, + // where the only evidence is what the server wrote). + let said = + std::fs::read_to_string(home.dir.path().join("server.log")).unwrap_or_default(); + panic!("the server didn't say ready (got {line:?}); its log:\n{said}"); + } Self { child } } diff --git a/crates/ledger-cli/tests/defects.rs b/crates/ledger-cli/tests/defects.rs new file mode 100644 index 0000000..cc3fe43 --- /dev/null +++ b/crates/ledger-cli/tests/defects.rs @@ -0,0 +1,265 @@ +//! The re-audit's engineering mirror (plan P10; `docs/runbooks/re-audit.md`): +//! every defect the 2026-10-16 audit found (1 to 20), P0 (21, 22) and P3 +//! (23, 24) has a named regression test somewhere in the workspace, and the +//! runbook names only tools and commands that exist. +//! +//! The list below is the single place that says which test stands guard for +//! which defect. A test renamed or deleted fails here, so a defect's +//! reproduction cannot be lost quietly. This proves the tests exist, are +//! real `#[test]`s and are not ignored; that they pass is `cargo test`'s +//! job (the runbook's step 4 runs them by name). +//! +//! SOURCE: plan/served.md 8.0 ("defect n") and P10 step 2. + +use std::path::{Path, PathBuf}; + +fn root() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../..") + .canonicalize() + .unwrap() +} + +fn read(rel: &str) -> String { + std::fs::read_to_string(root().join(rel)).unwrap_or_else(|e| panic!("{rel}: {e}")) +} + +/// `(defect, test name, file)`. Defect 2 is closed by D-12 and D-47 (the +/// rule it now shows is that a reversal needs no receipt of its own); +/// defect 20 (the migration scripts) was retired at the cutover and is +/// checked separately below. +const DEFECTS: [(u32, &str, &str); 23] = [ + ( + 1, + "defect1_two_member_llc_first_year_regression", + "crates/ledger-core/tests/spec_cash_flow.rs", + ), + ( + 2, + "a_reversal_needs_no_receipt_of_its_own", + "crates/ledger-cli/tests/drafts.rs", + ), + ( + 3, + "get_receipt_default_has_text_and_no_bytes", + "crates/ledger-cli/tests/evidence.rs", + ), + ( + 4, + "same_bytes_one_record_many_links", + "crates/ledger-store/tests/attachments.rs", + ), + ( + 5, + "a_draft_waits_for_its_receipt_and_posts", + "crates/ledger-cli/tests/drafts.rs", + ), + ( + 6, + "digest_since_seq_is_posting_time", + "crates/ledger-cli/tests/drafts.rs", + ), + ( + 7, + "the_readme_lists_what_the_iif_writes_differently", + "crates/ledger-package/tests/package.rs", + ), + ( + 8, + "add_account_warns_without_tax_category_defect_8", + "crates/ledger-cli/tests/spec_tax_policy.rs", + ), + ( + 9, + "defect9_a_new_schedule_spreads_by_the_day", + "crates/ledger-core/tests/schedules.rs", + ), + ( + 10, + "annotations_replay_and_project", + "crates/ledger-store/tests/attachments.rs", + ), + ( + 11, + "defect11_the_directory_shows_a_members_tin_last4", + "crates/ledger-cli/tests/spec_vault.rs", + ), + ( + 12, + "payment_methods_round_trip_and_classify_payments", + "crates/ledger-cli/tests/spec_1099.rs", + ), + ( + 13, + "four_contributed_devices_each_take_the_lower_of_value_and_basis", + "crates/ledger-core/tests/spec_asset_basis.rs", + ), + ( + 14, + "defect14_party_totals_take_a_basis", + "crates/ledger-cli/tests/spec_party_totals.rs", + ), + ( + 15, + "rename_account_end_to_end_defect_15", + "crates/ledger-cli/tests/mcp_journeys.rs", + ), + ( + 16, + "one_default_period_everywhere_defect_16", + "crates/ledger-cli/tests/mcp_journeys.rs", + ), + ( + 17, + "the_audit_odd_cent_never_piles_up_across_closes", + "crates/ledger-core/tests/odd_cent.rs", + ), + ( + 18, + "undecided_is_not_not_reportable", + "crates/ledger-core/tests/form1099.rs", + ), + ( + 19, + "no_tool_takes_a_path", + "crates/ledger-mcp/src/server_tests.rs", + ), + ( + 21, + "as_of_package_iif_stops_at_as_of", + "crates/ledger-package/tests/package.rs", + ), + ( + 22, + "defect22_depreciation_through_mid_final_month_is_prorated_not_plugged", + "crates/ledger-core/tests/spec_assets_payroll.rs", + ), + ( + 23, + "a_vendor_marked_no_1099_is_not_undecided", + "crates/ledger-cli/tests/spec_1099.rs", + ), + ( + 24, + "defect24_an_accrual_through_another_liability_counts_once", + "crates/ledger-core/tests/spec_party_totals.rs", + ), +]; + +/// Defects 1 to 24 each have a named test that exists, is a test, and is +/// not ignored (defect 20 is the retired scripts, below). +#[test] +fn every_defect_reproduction_is_a_named_test() { + let mut covered: Vec = DEFECTS.iter().map(|d| d.0).collect(); + covered.push(20); + covered.sort_unstable(); + assert_eq!( + covered, + (1..=24).collect::>(), + "a defect is missing" + ); + + let mut missing = Vec::new(); + for (defect, name, file) in DEFECTS { + let text = read(file); + let lines: Vec<&str> = text.lines().collect(); + let needle = format!("fn {name}("); + let Some(at) = lines.iter().position(|l| { + l.trim_start().starts_with(&needle) + || l.trim_start().starts_with(&format!("pub {needle}")) + }) else { + missing.push(format!("defect {defect}: no `fn {name}` in {file}")); + continue; + }; + // The attributes and doc lines above the function, up to the + // previous blank line or item. + let above: String = lines[at.saturating_sub(8)..at].join("\n"); + let in_proptest = text.contains("proptest!"); + if !above.contains("#[test]") && !in_proptest { + missing.push(format!( + "defect {defect}: `{name}` in {file} is not a #[test]" + )); + } + if above.contains("#[ignore") { + missing.push(format!("defect {defect}: `{name}` in {file} is ignored")); + } + } + assert!(missing.is_empty(), "{}", missing.join("\n")); +} + +/// Defect 20, the migration scripts, was retired at the cutover: there is +/// no tool for them, and none may come back as a way past the journal. +/// The defect 19 mirror (`import_local_file` and every path parameter) is +/// `no_tool_takes_a_path` above. +#[test] +fn defect_20_no_migration_tool_exists() { + let scopes = read("crates/ledger-mcp/src/scopes.rs"); + for line in scopes.lines().filter(|l| l.contains("(\"books.")) { + for banned in [ + "migrate", + "import_local_file", + "import_sheet", + "verify_sheet", + ] { + assert!(!line.contains(banned), "a retired tool is back: {line}"); + } + } +} + +/// The runbook names only tools and commands that exist: each +/// `books.` is in the scopes table (the 8.0.3 list of tools), each +/// `wanderbooks ` is a command `main.rs` dispatches, and each +/// test file it names is a file. +#[test] +fn the_re_audit_runbook_names_only_what_exists() { + let runbook = read("docs/runbooks/re-audit.md"); + let scopes = read("crates/ledger-mcp/src/scopes.rs"); + let main = read("crates/ledger-cli/src/main.rs"); + + let mut tools = Vec::new(); + let mut rest = runbook.as_str(); + while let Some(i) = rest.find("books.") { + let tail = &rest[i..]; + let end = tail + .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.')) + .unwrap_or(tail.len()); + let name = tail[..end].trim_end_matches('.'); + // Prose such as "books.start's" ends at the apostrophe; a bare + // "books." (the company's books followed by a full stop) is not a name. + if name.len() > "books.".len() && !tools.contains(&name.to_string()) { + tools.push(name.to_string()); + } + rest = &tail[end.max(1)..]; + } + assert!( + tools.len() > 20, + "the runbook should name its tools: {tools:?}" + ); + let unknown: Vec<&String> = tools + .iter() + .filter(|t| !scopes.contains(&format!("(\"{t}\""))) + .collect(); + assert!( + unknown.is_empty(), + "tools the runbook names that do not exist: {unknown:?}" + ); + + for cmd in ["gui", "connect"] { + if runbook.contains(&format!("wanderbooks {cmd}")) { + assert!(main.contains(&format!("\"{cmd}\"")), "no command {cmd}"); + } + } + assert!( + runbook.contains("wanderbooks gui"), + "step 6 names the viewer" + ); + + for step in 1..=8 { + assert!( + runbook.contains(&format!("## Step {step}.")), + "the runbook lost step {step}" + ); + } + // The runbook must not point at the data root or tell anyone to read it. + assert!(!runbook.contains("wanderbooks.v"), "names a database file"); +} diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs index 520eabf..4c43781 100644 --- a/crates/ledger-cli/tests/documents.rs +++ b/crates/ledger-cli/tests/documents.rs @@ -496,18 +496,18 @@ fn terms_fall_back_in_order() { assert_eq!(bill["terms"], "Net 10", "{bill}"); } -/// `party_id` is the old spelling of `customer`/`vendor` and still names -/// the same record (until P5); a number that is one record's id and +/// A customer given by id and by name is the same record (the old +/// `party_id` spelling is gone, P5); a number that is one record's id and /// another's name is refused, never guessed. #[test] -fn party_id_alias_still_works() { +fn a_customer_by_id_and_by_name_is_one_record() { let books = Books::new("documents-alias"); let mut m = Mcp::spawn(&books); let harbor = m.customer("Harbor Coffee"); let line = json!([{"description": "Work", "amount": "10.00"}]); - let by_alias = m.call( + let by_id = m.call( "books.create_invoice", - json!({"party_id": harbor, "date": "2026-02-01", "terms": "Net 30", + json!({"customer": (harbor).to_string(), "date": "2026-02-01", "terms": "Net 30", "lines": line, "confirmed": true}), ); let by_name = m.call( @@ -520,8 +520,8 @@ fn party_id_alias_still_works() { .iter() .map(|e| e["payload"]["customer_id"].as_u64().unwrap()) .collect(); - assert_eq!(parties, [harbor, harbor], "one customer, two spellings"); - assert_eq!(by_alias["total"], by_name["total"]); + assert_eq!(parties, [harbor, harbor], "one customer, an id and a name"); + assert_eq!(by_id["total"], by_name["total"]); // A customer named "2" that is not customer 2: the number is // ambiguous, and is refused rather than guessed. @@ -730,7 +730,7 @@ fn mark_sent_records_who_and_how() { .unwrap(); let invoice = common::answer_of(&wire.call( "books.create_invoice", - json!({"party_id": customer, "date": "2026-03-01", "due_date": "2026-03-31", + json!({"customer": (customer).to_string(), "date": "2026-03-01", "due_date": "2026-03-31", "lines": [{"description": "Design", "amount": "1200.00"}], "confirmed": true}), )) .unwrap()["id"] @@ -876,7 +876,7 @@ mod paying_on_terms { ok( &mut self.wire, "books.create_invoice", - json!({"party_id": self.customer, "date": date, "due_date": due, + json!({"customer": (self.customer).to_string(), "date": date, "due_date": due, "lines": [{"description": "Consulting", "amount": amount}], "confirmed": true}), )["id"] @@ -897,7 +897,7 @@ mod paying_on_terms { ok( &mut self.wire, "books.create_bill", - json!({"party_id": self.vendor, "date": date, "due_date": due, + json!({"vendor": (self.vendor).to_string(), "date": date, "due_date": due, "vendor_ref": format!("TH-{}", self.bills), "lines": [{"description": "Paper", "amount": amount, "account": "6999"}], "upload_id": file, "confirmed": true}), @@ -1258,7 +1258,7 @@ mod bad_debts { wire, "books.create_invoice", json!({ - "party_id": customer, + "customer": (customer).to_string(), "date": "2026-01-05", "due_date": "2026-02-04", "lines": [{ "description": "Design", "amount": "100.00" }], @@ -1286,10 +1286,10 @@ mod bad_debts { &mut wire, "books.receive_payment", json!({"confirmed": true, - "document_id": inv, + "invoice": inv, "date": "2026-01-20", "amount": "40.00", - "cash_account": "1000", + "deposit_to": "1000", }), ); // The write-off's evidence, uploaded as an agent uploads it (D-46). @@ -1361,10 +1361,10 @@ mod bad_debts { &mut wire, "books.receive_payment", json!({"confirmed": true, - "document_id": inv, + "invoice": inv, "date": "2026-03-02", "amount": "60.00", - "cash_account": "1000", + "deposit_to": "1000", }), ); let doc = ok( diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index fe36e06..f7f2b6a 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -767,7 +767,7 @@ fn books_p2_served_cut_over_whole() { let invoice = a .call( "books.create_invoice", - json!({"party_id": 1, "date": "2026-01-05", "due_date": "2026-02-04", + json!({"customer": (1).to_string(), "date": "2026-01-05", "due_date": "2026-02-04", "lines": [{"description": "Design", "amount": "400.00"}], "confirmed": true}), ) .unwrap()["id"] @@ -789,7 +789,7 @@ fn books_p2_served_cut_over_whole() { let unpaid = a .call( "books.create_invoice", - json!({"party_id": customer, "date": "2026-01-10", "due_date": "2026-02-09", + json!({"customer": (customer).to_string(), "date": "2026-01-10", "due_date": "2026-02-09", "lines": [{"description": "Survey", "amount": "300.00"}], "confirmed": true}), ) .unwrap()["id"] @@ -812,8 +812,24 @@ fn books_p2_served_cut_over_whole() { ) .unwrap(); } + // An open item P2 reads: the correction of its source is + // source_revisions' (P5), and waits. + let item = a + .call( + "books.raise_item", + json!({"title": "Ask the CPA about the vehicle", "decided_by": "Avery Quill"}), + ) + .unwrap()["id"] + .clone(); let waiting = journal(&dir); let refused: Vec<(&str, Value)> = vec![ + // A source correction (source_revisions, P5): an older version + // refuses the type. + ( + "books.raise_item", + json!({"revises": item, "reason": "it was the CPA who raised it", + "decided_by": "Pat Smith"}), + ), // A draft's state and its discard (drafts_v2, P4): an older // version refuses the types. ( @@ -893,6 +909,16 @@ fn books_p2_served_cut_over_whole() { json!({"attachment_id": 1, "fields": {"payee": "Harbor Design LLC", "amount": "12.34", "date": "2026-02-01"}}), ), + // An audit run and an attestation (audits, P8): an older version + // doesn't know the events. + ( + "books.audit", + json!({"scope": "full", "through": "2026-02-28", "confirmed": true}), + ), + ( + "books.review_entry", + json!({"entry_id": 1, "method": {"reasoning_effort": "high"}}), + ), ]; for (tool, args) in &refused { let e = a.call(tool, args.clone()).unwrap_err(); diff --git a/crates/ledger-cli/tests/hostile_args.rs b/crates/ledger-cli/tests/hostile_args.rs index 1dcfc86..6cc3ca9 100644 --- a/crates/ledger-cli/tests/hostile_args.rs +++ b/crates/ledger-cli/tests/hostile_args.rs @@ -1017,13 +1017,13 @@ fn inputs_that_once_panicked_are_refused_or_answered() { ), ( "books.create_invoice", - json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01", + json!({"customer": (client).to_string(), "date": "2026-05-01", "due_date": "2026-06-01", "lines": [{"description": "a", "amount": MAX}, {"description": "b", "amount": "1"}], "confirmed": true}), ), ( "books.create_bill", - json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01", + json!({"vendor": (acme).to_string(), "date": "2026-05-01", "due_date": "2026-06-01", "vendor_ref": "H-1", "lines": [{"description": "a", "amount": MAX, "account": "6000"}, {"description": "b", "amount": "1", "account": "6000"}], @@ -1031,7 +1031,7 @@ fn inputs_that_once_panicked_are_refused_or_answered() { ), ( "books.create_invoice", - json!({"party_id": client, "date": "2026-05-01", "due_date": "+262142-12-31", + json!({"customer": (client).to_string(), "date": "2026-05-01", "due_date": "+262142-12-31", "lines": [{"description": "a", "amount": "1"}], "confirmed": true}), ), ( diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index 8158e25..e431e1f 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -125,7 +125,7 @@ fn mcp_end_to_end() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 133, "expected 133 tools, got {}", tools.len()); + assert_eq!(tools.len(), 140, "expected 140 tools, got {}", tools.len()); // Files come in as tool input: books.begin_upload, never a local path. assert!(tools.iter().any(|t| t["name"] == "books.begin_upload")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); diff --git a/crates/ledger-cli/tests/projects_parity.rs b/crates/ledger-cli/tests/projects_parity.rs index 4328f7a..1ffd3f6 100644 --- a/crates/ledger-cli/tests/projects_parity.rs +++ b/crates/ledger-cli/tests/projects_parity.rs @@ -230,8 +230,8 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s server.tool("books.add_project", json!({"name": "Heron"})); let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone(); let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone(); - let doc = |party: &Value, date: &str, lines: Value| { - json!({"party_id": party, "date": date, "due_date": date, "lines": lines, + let doc = |key: &str, party: &Value, date: &str, lines: Value| { + json!({key: party.to_string(), "date": date, "due_date": date, "lines": lines, "confirmed": true}) }; server.tool( @@ -241,6 +241,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s let inv = server.tool( "books.create_invoice", doc( + "customer", &cedar, "2026-02-02", json!([ @@ -253,6 +254,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s let inv2 = server.tool( "books.create_invoice", doc( + "customer", &cedar, "2026-05-04", json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]), @@ -262,6 +264,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s .wire .upload(b"Dana's invoice", "dana.pdf", "application/pdf", "receipt"); let mut bill_args = doc( + "vendor", &dana, "2026-03-09", json!([ @@ -274,7 +277,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s let bill = server.tool("books.create_bill", bill_args); server.tool( "books.receive_payment", - json!({"confirmed": true, "document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}), + json!({"confirmed": true, "invoice": inv["id"], "date": "2026-02-20", "amount": "1830.00", "deposit_to": "1000"}), ); let b = data::load_once(&Source { data_dir: data.clone(), diff --git a/crates/ledger-cli/tests/spec_assets.rs b/crates/ledger-cli/tests/spec_assets.rs index ff0eb28..1355b22 100644 --- a/crates/ledger-cli/tests/spec_assets.rs +++ b/crates/ledger-cli/tests/spec_assets.rs @@ -606,10 +606,12 @@ fn dispose_asset_takes_the_tag_and_the_tag_stays_reserved() { let p1 = m.ok("books.dispose_asset", by_id.clone()); let p2 = m.ok("books.dispose_asset", by_tag.clone()); // (The digest names the arguments as sent, so it differs by id and - // by tag; everything it shows is the same.) + // by tag, and so does the confirm step the envelope offers, which + // repeats them; everything the preview shows is the same.) let (mut p1, mut p2) = (p1, p2); for p in [&mut p1, &mut p2] { p.as_object_mut().unwrap().remove("preview"); + p.as_object_mut().unwrap().remove("next_steps"); } assert_eq!(p1, p2, "same preview by id and by tag"); let mut both = by_id.clone(); diff --git a/crates/ledger-cli/tests/spec_attachments.rs b/crates/ledger-cli/tests/spec_attachments.rs index afa85c7..d826849 100644 --- a/crates/ledger-cli/tests/spec_attachments.rs +++ b/crates/ledger-cli/tests/spec_attachments.rs @@ -424,7 +424,7 @@ fn a_file_links_to_every_kind_of_subject() { let rent_invoice = m.upload(b"rent invoice", "rent.pdf", "application/pdf", "receipt"); let bill = m.ok( "books.create_bill", - json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28", + json!({"vendor": (vendor).to_string(), "date": "2026-02-01", "due_date": "2026-02-28", "vendor_ref": "RENT-2", "lines": [{"description": "Rent", "amount": "300.00", "account": "6000"}], "upload_id": rent_invoice, "confirmed": true}), @@ -432,7 +432,7 @@ fn a_file_links_to_every_kind_of_subject() { .clone(); let invoice = m.ok( "books.create_invoice", - json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01", + json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01", "lines": [{"description": "Work", "amount": "500.00"}], "confirmed": true}), )["id"] diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index 89d31e3..d88ef2f 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -911,7 +911,7 @@ fn happy_path(name: &str) -> Mcp { // A/R and A/P. let inv = m.ok( "books.create_invoice", - json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01", + json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01", "lines": [{"description": "Consulting", "amount": "500.00"}], "confirmed": true}), )["id"] @@ -919,7 +919,7 @@ fn happy_path(name: &str) -> Mcp { .unwrap(); m.ok( "books.receive_payment", - json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), + json!({"confirmed": true, "invoice": inv, "date": "2026-02-10", "amount": "200.00", "deposit_to": "1000"}), ); let vendor_invoice = m.upload( b"vendor invoice", @@ -929,7 +929,7 @@ fn happy_path(name: &str) -> Mcp { ); let bill = m.ok( "books.create_bill", - json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28", + json!({"vendor": (vendor).to_string(), "date": "2026-02-01", "due_date": "2026-02-28", "vendor_ref": "V-1001", "lines": [{"description": "Office rent", "amount": "300.00", "account": "6000"}], "upload_id": vendor_invoice, "confirmed": true}), @@ -938,7 +938,7 @@ fn happy_path(name: &str) -> Mcp { .unwrap(); m.ok( "books.pay_bill", - json!({"confirmed": true, "document_id": bill, "date": "2026-02-05", "amount": "300.00", "cash_account": "1000"}), + json!({"confirmed": true, "bill": bill, "date": "2026-02-05", "amount": "300.00", "paid_from": "1000"}), ); m.ok("books.ar_aging", json!({"as_of": "2026-06-30"})); m.ok("books.ap_aging", json!({"as_of": "2026-06-30"})); @@ -946,7 +946,7 @@ fn happy_path(name: &str) -> Mcp { // P4b S1: a document voided, and the control accounts' tie-out. let dup = m.ok( "books.create_invoice", - json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02", + json!({"customer": (customer).to_string(), "date": "2026-02-02", "due_date": "2026-03-02", "lines": [{"description": "Consulting, entered twice", "amount": "500.00"}], "confirmed": true}), )["id"] @@ -972,7 +972,7 @@ fn happy_path(name: &str) -> Mcp { // P4b S7: a bad debt, its evidence uploaded and filed with it. let bad = m.ok( "books.create_invoice", - json!({"party_id": customer, "date": "2026-02-03", "due_date": "2026-03-03", + json!({"customer": (customer).to_string(), "date": "2026-02-03", "due_date": "2026-03-03", "lines": [{"description": "Consulting, never paid", "amount": "400.00"}], "confirmed": true}), )["id"] @@ -1146,6 +1146,28 @@ fn happy_path(name: &str) -> Mcp { json!({"period_id": p, "confirmed": true}), ); + // P8: an audit of the month, its workpaper, and the claims that stand + // behind it. + let audit_run = m.ok( + "books.audit", + json!({"scope": "month", "through": "2026-01-31", "confirmed": true}), + )["run"]["id"] + .as_u64() + .unwrap(); + m.ok("books.workpaper", json!({"id": audit_run})); + let audited = m.attach(Some(e1), b"audit receipt"); + m.ok( + "books.attest_receipt", + json!({"attachment_id": audited, "entry_id": e1, "amount": "10000.00", + "date": "2026-01-02", "method": {"reasoning_effort": "high"}}), + ); + m.ok( + "books.review_entry", + json!({"entry_id": e1, "method": {"reasoning_effort": "high"}}), + ); + m.ok("books.attestations", json!({})); + m.ok("books.attest_period", json!({"period": "2026-01"})); + // How the books are kept: start, conventions, decisions, open items, // a template and an approval, and the settings file moving in. m.ok("books.start", json!({})); @@ -1294,6 +1316,8 @@ fn happy_path(name: &str) -> Mcp { // The access log, the backups' status, and a backup now (none is set // up: it says so). m.ok("books.access_log", json!({})); + // P5: the journal in words. + m.ok("books.journal_history", json!({"limit": 5})); m.ok("books.backup", json!({})); m.ok("books.back_up_now", json!({})); @@ -1685,15 +1709,15 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) { vendor_ref, } => ( "books.create_bill", - json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01", + json!({"vendor": (party.resolve(&k.vendors)).to_string(), "date": "2026-03-01", "due_date": "2026-03-31", "vendor_ref": vendor_ref, "lines": [{"description": "thing", "amount": amount, "account": "6000"}], "existing_attachment_id": k.vendor_invoice, "confirmed": true}), ), Op::PayBill { doc, amount } => ( "books.pay_bill", - json!({"confirmed": true, "document_id": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount, - "cash_account": "1000"}), + json!({"confirmed": true, "bill": doc.resolve(&k.bills), "date": "2026-03-15", "amount": amount, + "paid_from": "1000"}), ), Op::Payout { deposit } => ( "books.record_payout", @@ -1834,7 +1858,7 @@ fn inv3_other_tools_refuse_ungrammatical_amounts() { ); m.refuse( "books.create_bill", - json!({"party_id": vendor, "date": "2026-03-01", "due_date": "2026-03-31", + json!({"vendor": (vendor).to_string(), "date": "2026-03-01", "due_date": "2026-03-31", "lines": [{"description": "x", "amount": bad}]}), ); m.refuse( @@ -2591,7 +2615,7 @@ fn assigned_roles_steer_invoices() { .unwrap(); m.ok( "books.create_invoice", - json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01", + json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01", "lines": [{"description": "Consulting", "amount": "500.00"}], "confirmed": true}), ); @@ -2654,7 +2678,7 @@ fn documents_void_and_tie_out_over_the_wire() { ); let inv = m.ok( "books.create_invoice", - json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01", + json!({"customer": (customer).to_string(), "date": "2026-02-01", "due_date": "2026-03-01", "lines": [{"description": "Consulting", "amount": "500.00"}], "confirmed": true}), )["id"] @@ -2662,8 +2686,8 @@ fn documents_void_and_tie_out_over_the_wire() { .unwrap(); let paid = m.ok( "books.receive_payment", - json!({"confirmed": true, "document_id": inv, "date": "2026-02-10", "amount": "200.00", - "cash_account": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}), + json!({"confirmed": true, "invoice": inv, "date": "2026-02-10", "amount": "200.00", + "deposit_to": "1000", "instrument": "Harbor checking", "reference": "tr_8812"}), ); assert_eq!(paid["remaining"], "300.00", "{paid}"); assert_eq!(paid["status"], "partial", "{paid}"); @@ -2685,7 +2709,7 @@ fn documents_void_and_tie_out_over_the_wire() { let dup = m.ok( "books.create_invoice", - json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02", + json!({"customer": (customer).to_string(), "date": "2026-02-02", "due_date": "2026-03-02", "lines": [{"description": "Consulting, entered twice", "amount": "500.00"}], "confirmed": true}), )["id"] @@ -2753,7 +2777,7 @@ fn documents_list_and_timeline_over_the_wire() { let invoice = |m: &mut Mcp, date: &str, due: &str, amount: &str| { m.ok( "books.create_invoice", - json!({"party_id": customer, "date": date, "due_date": due, + json!({"customer": (customer).to_string(), "date": date, "due_date": due, "lines": [{"description": "Consulting", "amount": amount}], "confirmed": true}), )["id"] @@ -2764,7 +2788,7 @@ fn documents_list_and_timeline_over_the_wire() { let later = invoice(&mut m, "2026-02-05", "2026-04-30", "80.00"); let pay = m.ok( "books.receive_payment", - json!({"confirmed": true, "document_id": late, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}), + json!({"confirmed": true, "invoice": late, "date": "2026-02-10", "amount": "200.00", "deposit_to": "1000"}), )["entry_id"] .as_u64() .unwrap(); diff --git a/crates/ledger-cli/tests/spec_parties.rs b/crates/ledger-cli/tests/spec_parties.rs index 00e6661..bea7d00 100644 --- a/crates/ledger-cli/tests/spec_parties.rs +++ b/crates/ledger-cli/tests/spec_parties.rs @@ -835,15 +835,15 @@ fn party_totals_match_a_hand_computed_year() { ); let bill = m.ok( "books.create_bill", - json!({"party_id": v, "date": "2026-07-01", "due_date": "2026-07-31", + json!({"vendor": (v).to_string(), "date": "2026-07-01", "due_date": "2026-07-31", "vendor_ref": "D-1041", "lines": [{"description": "Logo", "amount": "800.00", "account": "6000"}], "upload_id": logo_invoice, "confirmed": true}), ); let pay = m.ok( "books.pay_bill", - json!({"confirmed": true, "document_id": bill["id"], "date": "2026-07-15", "amount": "300.00", - "cash_account": "1000"}), + json!({"confirmed": true, "bill": bill["id"], "date": "2026-07-15", "amount": "300.00", + "paid_from": "1000"}), )["entry_id"] .as_u64() .unwrap(); @@ -917,14 +917,14 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() { .unwrap(); let inv = m.ok( "books.create_invoice", - json!({"party_id": c, "date": "2026-03-01", "due_date": "2026-03-31", + json!({"customer": (c).to_string(), "date": "2026-03-01", "due_date": "2026-03-31", "lines": [{"description": "Work", "amount": "1000.00"}], "confirmed": true}), ); let pay = m.ok( "books.receive_payment", - json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "600.00", - "cash_account": "1000"}), + json!({"confirmed": true, "invoice": inv["id"], "date": "2026-03-20", "amount": "600.00", + "deposit_to": "1000"}), )["entry_id"] .as_u64() .unwrap(); diff --git a/crates/ledger-cli/tests/spec_projects.rs b/crates/ledger-cli/tests/spec_projects.rs index 04522bb..e861fc6 100644 --- a/crates/ledger-cli/tests/spec_projects.rs +++ b/crates/ledger-cli/tests/spec_projects.rs @@ -1231,7 +1231,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() { ); let inv = m.ok( "books.create_invoice", - json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ + json!({"customer": (cedar).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ {"description": "Design", "amount": "3000.00", "class": "Zeta site"}, {"description": "Hosting", "amount": "200.00", "class": harbor.to_string()}, {"description": "Rush fee", "amount": "100.00"}, @@ -1264,7 +1264,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() { ); let bill = m.ok( "books.create_bill", - json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08", + json!({"vendor": (dana_vendor).to_string(), "date": "2026-03-09", "due_date": "2026-04-08", "vendor_ref": "DANA-77", "lines": [ {"description": "Illustration", "amount": "1200.00", "account": "6000", "class": "Zeta site"}, @@ -1284,11 +1284,11 @@ fn invoice_and_bill_lines_count_toward_their_projects() { // Payments are balance-sheet only: no project. let got = m.ok( "books.receive_payment", - json!({"confirmed": true, "document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}), + json!({"confirmed": true, "invoice": inv["id"], "date": "2026-03-20", "amount": "3800.00", "deposit_to": "1000"}), ); let paid = m.ok( "books.pay_bill", - json!({"confirmed": true, "document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}), + json!({"confirmed": true, "bill": bill["id"], "date": "2026-03-30", "amount": "1250.00", "paid_from": "1000"}), ); for e in [&got["entry_id"], &paid["entry_id"]] { let e = e.as_u64().unwrap(); @@ -1353,10 +1353,13 @@ fn a_document_naming_an_unknown_project_is_refused_whole() { m.ok("books.add_project", json!({"name": "Zeta site"})); let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone(); let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone(); - for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] { + for (tool, key, party) in [ + ("books.create_invoice", "customer", &cedar), + ("books.create_bill", "vendor", &v), + ] { let why = m.refuse( tool, - json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ + json!({key: (party).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ {"description": "a", "amount": "10.00", "class": "Zeta site"}, {"description": "b", "amount": "10.00", "class": "Nowhere"}, ]}), @@ -1379,7 +1382,7 @@ fn a_document_for_a_finished_project_says_so() { let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone(); let out = m.ok( "books.create_invoice", - json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [ + json!({"customer": (cedar).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "lines": [ {"description": "Final", "amount": "10.00", "class": "Harbor"}, {"description": "Again", "amount": "10.00", "class": "harbor"}, {"description": "Old", "amount": "10.00", "class": "Dock"}, @@ -1424,7 +1427,7 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() { let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone(); let out = m.ok( "books.create_invoice", - json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [ + json!({"customer": (cedar).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "lines": [ {"description": "Build", "amount": "10.00", "class": "Kestrel"}, {"description": "More", "amount": "5.00", "class": "kestrel"}, {"description": "Site", "amount": "7.00", "class": "Zeta"}, @@ -1450,7 +1453,7 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() { ); let bill = m.ok( "books.create_bill", - json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04", + json!({"vendor": (v).to_string(), "date": "2026-10-05", "due_date": "2026-11-04", "vendor_ref": "V-512", "lines": [ {"description": "Icons", "amount": "10.00", "account": "6000", "class": "Kestrel"}, @@ -1472,10 +1475,13 @@ fn books_without_the_feature_refuse_a_tagged_document() { let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone(); let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone(); let head = m.journal(); - for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] { + for (tool, key, party) in [ + ("books.create_invoice", "customer", &cedar), + ("books.create_bill", "vendor", &v), + ] { let why = m.refuse( tool, - json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ + json!({key: (party).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ {"description": "a", "amount": "10.00", "class": "Zeta site"}, ]}), ); @@ -1487,7 +1493,7 @@ fn books_without_the_feature_refuse_a_tagged_document() { assert_eq!(m.journal(), head, "nothing journaled"); m.ok( "books.create_invoice", - json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ + json!({"customer": (cedar).to_string(), "date": "2026-03-02", "due_date": "2026-04-01", "lines": [ {"description": "a", "amount": "10.00"}, ], "confirmed": true}), ); diff --git a/crates/ledger-cli/tests/spec_tax_policy.rs b/crates/ledger-cli/tests/spec_tax_policy.rs index c4e7433..cc9339e 100644 --- a/crates/ledger-cli/tests/spec_tax_policy.rs +++ b/crates/ledger-cli/tests/spec_tax_policy.rs @@ -278,7 +278,7 @@ fn require_tax_category_refuses_defect_8() { ); let bill = m.call( "books.create_bill", - json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01", + json!({"vendor": (vendor["id"]).to_string(), "date": "2026-02-01", "due_date": "2026-03-01", "vendor_ref": "CH-1", "lines": [{"description": "hosting", "amount": "42.00", "account": "6150"}], "upload_id": first_invoice, "confirmed": true}), @@ -303,7 +303,7 @@ fn require_tax_category_refuses_defect_8() { ); let again = m.call( "books.create_bill", - json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02", + json!({"vendor": (vendor["id"]).to_string(), "date": "2026-02-02", "due_date": "2026-03-02", "vendor_ref": "CH-2", "lines": [{"description": "hosting", "amount": "1.00", "account": "6150"}], "upload_id": second_invoice, "confirmed": true}), diff --git a/crates/ledger-cli/tests/spec_vault.rs b/crates/ledger-cli/tests/spec_vault.rs index b4c75d3..ed7c527 100644 --- a/crates/ledger-cli/tests/spec_vault.rs +++ b/crates/ledger-cli/tests/spec_vault.rs @@ -521,6 +521,9 @@ fn files_under(dir: &Path) -> Vec { let p = e.path(); if p.is_dir() { out.extend(files_under(&p)); + } else if p.extension().is_some_and(|x| x == "lock") { + // A lock file holds no data, and on Windows a held lock is + // mandatory: reading it is refused while the server runs. } else { out.push(p); } diff --git a/crates/ledger-cli/tests/update.rs b/crates/ledger-cli/tests/update.rs index 4f121b2..e6a2500 100644 --- a/crates/ledger-cli/tests/update.rs +++ b/crates/ledger-cli/tests/update.rs @@ -4,7 +4,8 @@ //! and host.rs; the P11a tests update_is_atomic, failed_update_rolls_back, //! unsigned_update_refused, bridge_survives_update, //! update_waits_for_a_running_call, package_managed_install_refuses_update, -//! and the rollback laws (kept version, the format gate). +//! and the rollback laws (kept version, the format gate); and step 4's +//! daily check, staged update and apply-at-next-start. #![cfg(target_os = "linux")] mod common; @@ -16,8 +17,7 @@ use std::process::{Command, Stdio}; use std::sync::mpsc; use std::time::{Duration, Instant}; -const KNOBS: &str = - "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300"; +const KNOBS: &str = "close_after=2500,backoff=50,exe_poll=100,init_wait=5000,queue_wait=30000,backup_quiet=300,update_check=400"; /// The platform key a manifest names, as the program computes it. fn platform() -> String { @@ -76,6 +76,10 @@ fn base_program() -> &'static Path { struct Home { dir: tempfile::TempDir, exe: PathBuf, + /// A pretend AppImage (P11a step 4): the programs run from `exe`, as + /// from an image's mount, and `APPIMAGE` names this other file, the + /// one an update replaces. + image: Option, } impl Home { @@ -95,7 +99,22 @@ impl Home { std::fs::create_dir(&bin).unwrap(); let exe = bin.join("wanderbooks"); std::fs::copy(base_program(), &exe).unwrap(); - let home = Self { dir, exe }; + let home = Self { + dir, + exe, + image: None, + }; + home.runnable(); + home + } + + /// A home whose program is an AppImage: the same program twice, one + /// copy to run (the mount's) and one the image file `APPIMAGE` names. + fn new_image(tag: &str) -> Self { + let mut home = Self::new(tag); + let image = home.dir.path().join("Wanderbooks.AppImage"); + std::fs::copy(base_program(), &image).unwrap(); + home.image = Some(image); home.runnable(); home } @@ -103,25 +122,35 @@ impl Home { /// Wait until the copy can be run (a child another test thread forked /// while it was written may hold it open for a moment). fn runnable(&self) { - let deadline = Instant::now() + Duration::from_secs(10); - loop { - match Command::new(&self.exe) - .arg("--help") - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .stderr(Stdio::null()) - .status() - { - Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { - assert!(Instant::now() < deadline, "the copy stayed busy"); - std::thread::sleep(Duration::from_millis(20)); + for exe in std::iter::once(&self.exe).chain(self.image.as_ref()) { + let deadline = Instant::now() + Duration::from_secs(10); + loop { + match Command::new(exe) + .arg("--help") + .env("APPIMAGE_EXTRACT_AND_RUN", "1") + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Err(e) if e.kind() == std::io::ErrorKind::ExecutableFileBusy => { + assert!(Instant::now() < deadline, "the copy stayed busy"); + std::thread::sleep(Duration::from_millis(20)); + } + Err(e) => panic!("the copy can't be run: {e}"), + Ok(_) => break, } - Err(e) => panic!("the copy can't be run: {e}"), - Ok(_) => return, } } } + /// The environment of the pretend AppImage, if this is one. + fn image_env(&self, c: &mut Command) { + if let Some(image) = &self.image { + c.env("APPIMAGE", image); + } + } + fn root(&self) -> PathBuf { self.dir.path().join(".local/share/wanderbooks") } @@ -139,11 +168,13 @@ impl Home { .env_clear() .env("PATH", "/usr/bin:/bin") .env("HOME", self.dir.path()); + self.image_env(&mut c); c } + /// The program that updates replace: the image, if there is one. fn program(&self) -> Vec { - std::fs::read(&self.exe).unwrap() + std::fs::read(self.image.as_ref().unwrap_or(&self.exe)).unwrap() } fn create(&self, id: &str) { @@ -180,6 +211,7 @@ impl Home { .env("PATH", "/usr/bin:/bin") .env("HOME", self.dir.path()) .stdin(Stdio::null()); + self.image_env(&mut c); c.output().unwrap() } @@ -655,7 +687,11 @@ impl Drop for Agent { /// host and server run the new file afterwards, and the head is the one /// the writes made. fn update_under_writes(tag: &str) -> (Home, Agent, Vec) { - let home = Home::new(tag); + update_under_writes_in(Home::new(tag)) +} + +fn update_under_writes_in(home: Home) -> (Home, Agent, Vec) { + let tag = "under-writes"; home.create("acme"); let release = Release::new(); let next = newer(&home, tag); @@ -673,18 +709,21 @@ fn update_under_writes(tag: &str) -> (Home, Agent, Vec) { release.dir.path().join("manifest.json").display() ); let key = release.dir.path().join("release.pub"); + let image = home.image.clone(); std::thread::spawn(move || { let _keep = &release; - Command::new(exe) - .args(["update", "--root"]) + let mut c = Command::new(exe); + c.args(["update", "--root"]) .arg(root) .args(["--feed", &feed, "--trusted-key"]) .arg(key) .env_clear() .env("PATH", "/usr/bin:/bin") - .env("HOME", dir) - .output() - .unwrap() + .env("HOME", dir); + if let Some(image) = image { + c.env("APPIMAGE", image); + } + c.output().unwrap() }) }; let mut written = vec!["4000".to_string()]; @@ -742,6 +781,39 @@ fn bridge_survives_update() { assert!(in_books >= written.len(), "{in_books} < {}", written.len()); } +#[test] +fn a_replaced_appimage_restarts_the_books() { + // The programs run from one file (an image's mount) while `APPIMAGE` + // names another (the image). Replacing the image alone must restart + // the host onto it, with the session and every write intact. + let home = Home::new_image("appimage"); + let image = home.image.clone().unwrap(); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "appimage"); + release.publish("9.0.0", &next, |_| {}); + let mut agent = Agent::start(&home, "acme"); + agent.add_account("4000").unwrap(); + let host_before = wait_host(&home); + let out = home.run("update", Some(&release)); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(std::fs::read(&image).unwrap(), next); + // The mount's copy is untouched; only the image changed. + assert_ne!(std::fs::read(&home.exe).unwrap(), next); + assert!( + wait_for(60, || { + home.pids("host").len() == 1 && home.pids("host")[0] != host_before + }), + "the host never restarted onto the replaced image" + ); + let host = wait_host(&home); + assert_eq!( + std::fs::read_link(format!("/proc/{host}/exe")).unwrap(), + image + ); + assert!(agent.add_account("4001").is_ok(), "the session carries on"); +} + #[test] fn update_waits_for_a_running_call() { // No call is cut off or made twice: each account written across the @@ -763,3 +835,161 @@ fn update_waits_for_a_running_call() { "a write lost, or made twice, across the update" ); } + +/// Name `release` as this home's local feed, as the owner would +/// (`/updates/feed`: the address, then the key file). +fn name_feed(home: &Home, release: &Release) { + std::fs::create_dir_all(home.updates()).unwrap(); + std::fs::write( + home.updates().join("feed"), + format!( + "file://{}\n{}\n", + release.dir.path().join("manifest.json").display(), + release.dir.path().join("release.pub").display() + ), + ) + .unwrap(); +} + +fn staged_version(home: &Home) -> Option { + let meta = std::fs::read(home.updates().join("staged/meta.json")).ok()?; + let v: Value = serde_json::from_slice(&meta).ok()?; + v["version"].as_str().map(str::to_string) +} + +#[test] +fn the_host_stages_a_daily_update_and_the_next_start_applies_it() { + let home = Home::new("daily"); + home.create("acme"); + let old = home.program(); + let release = Release::new(); + let next = newer(&home, "daily"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let mut agent = Agent::start(&home, "acme"); + agent.add_account("4000").unwrap(); + let host = wait_host(&home); + + // The host looks, downloads, verifies and stages; nothing running + // changes: not the file, not the host. + assert!( + wait_for(30, || staged_version(&home).as_deref() == Some("9.0.0")), + "nothing was staged" + ); + assert_eq!(home.program(), old, "the program was swapped while open"); + assert!(!home.updates().join("previous").exists()); + std::thread::sleep(Duration::from_millis(1200)); + assert_eq!(home.pids("host"), vec![host], "the host restarted"); + assert!(agent.add_account("4001").is_ok()); + + // The books close; the next opener applies it before the host starts. + drop(agent); + let out = home.cmd("close").output().unwrap(); + assert!(out.status.success(), "{}", text(&out)); + assert!(wait_for(20, || home.pids("host").is_empty())); + let mut agent = Agent::start(&home, "acme"); + assert_eq!(home.program(), next, "the staged update wasn't applied"); + assert!(!home.updates().join("staged").exists()); + assert!( + home.updates() + .join("previous") + .join("wanderbooks") + .is_file() + ); + assert_eq!( + std::fs::read(home.updates().join("previous/wanderbooks")).unwrap(), + old + ); + let host = wait_host(&home); + assert!( + !std::fs::read_link(format!("/proc/{host}/exe")) + .unwrap() + .to_string_lossy() + .contains("(deleted)") + ); + let chart = agent.call("books.chart", json!({})).unwrap(); + let codes: Vec<&str> = chart["accounts"] + .as_array() + .unwrap() + .iter() + .filter_map(|a| a["code"].as_str()) + .collect(); + assert!(codes.contains(&"4000") && codes.contains(&"4001")); +} + +#[test] +fn a_daily_update_that_is_not_ours_is_not_staged() { + let home = Home::new("daily-bad"); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "daily-bad"); + let other = minisign::KeyPair::generate_unencrypted_keypair().unwrap(); + // Signed by another key; and, separately, a wrong checksum. + let sig = Release::b64(&Release::sign_with(&other, &next)); + release.publish("9.0.0", &next, |e| e["signature"] = json!(sig)); + name_feed(&home, &release); + let _agent = Agent::start(&home, "acme"); + assert!( + wait_for(30, || home.updates().join("checked").exists()), + "no look" + ); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(staged_version(&home), None); + assert!(!home.updates().join("staged").exists()); + release.publish("9.0.1", &next, |e| e["sha256"] = json!("00".repeat(32))); + std::fs::remove_file(home.updates().join("checked")).unwrap(); + assert!( + wait_for(30, || home.updates().join("checked").exists()), + "no second look" + ); + std::thread::sleep(Duration::from_millis(500)); + assert_eq!(staged_version(&home), None); +} + +#[test] +fn update_installs_the_copy_the_host_staged_without_downloading_again() { + let home = Home::new("daily-use"); + home.create("acme"); + let release = Release::new(); + let next = newer(&home, "daily-use"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let _agent = Agent::start(&home, "acme"); + assert!(wait_for(30, || staged_version(&home).is_some())); + // The program file is gone from the feed: only the staged copy is left. + std::fs::remove_file(release.dir.path().join("wanderbooks-9.0.0")).unwrap(); + let out = home.run("update", None); + assert!(out.status.success(), "{}", text(&out)); + assert_eq!(home.program(), next); + assert!(!home.updates().join("staged").exists()); +} + +#[test] +fn a_staged_update_that_was_tampered_with_is_dropped_not_applied() { + let home = Home::new("daily-tamper"); + home.create("acme"); + let old = home.program(); + let release = Release::new(); + let next = newer(&home, "daily-tamper"); + release.publish("9.0.0", &next, |_| {}); + name_feed(&home, &release); + let agent = Agent::start(&home, "acme"); + assert!(wait_for(30, || staged_version(&home).is_some())); + drop(agent); + let out = home.cmd("close").output().unwrap(); + assert!(out.status.success(), "{}", text(&out)); + assert!(wait_for(20, || home.pids("host").is_empty())); + // The staged file changes, its recorded checksum with it: the + // signature no longer holds. + let staged = home.updates().join("staged"); + let mut bytes = std::fs::read(staged.join("wanderbooks")).unwrap(); + bytes.extend_from_slice(b"tamper"); + std::fs::write(staged.join("wanderbooks"), &bytes).unwrap(); + let mut meta: Value = + serde_json::from_slice(&std::fs::read(staged.join("meta.json")).unwrap()).unwrap(); + meta["sha256"] = json!(sha256(&bytes)); + std::fs::write(staged.join("meta.json"), meta.to_string()).unwrap(); + let _agent = Agent::start(&home, "acme"); + assert_eq!(home.program(), old, "a tampered update was applied"); + assert!(!staged.exists(), "and kept"); +} diff --git a/crates/ledger-core/Cargo.toml b/crates/ledger-core/Cargo.toml index acefd11..30bdcbb 100644 --- a/crates/ledger-core/Cargo.toml +++ b/crates/ledger-core/Cargo.toml @@ -26,8 +26,10 @@ proptest = "1" # store (a dev-dependency only: the store depends on this crate), each in # a directory of its own. toml = "1" -bookspec-checker = { path = "../../bookspec/checker" } -bookspec-runner = { path = "../../bookspec/runner" } +# The specification lives in its own repository (P13 step 6, D-34, D-65), +# pinned to a commit. +bookspec-checker = { git = "https://git.swaits.com/swaits/bookspec", rev = "74f2853d5f0db0066147c17fed8033cfe3d83a24" } +bookspec-runner = { git = "https://git.swaits.com/swaits/bookspec", rev = "74f2853d5f0db0066147c17fed8033cfe3d83a24" } ledger-package = { path = "../ledger-package" } ledger-import = { path = "../ledger-import" } ledger-store = { path = "../ledger-store" } diff --git a/crates/ledger-core/src/audit.rs b/crates/ledger-core/src/audit.rs new file mode 100644 index 0000000..460c80d --- /dev/null +++ b/crates/ledger-core/src/audit.rs @@ -0,0 +1,871 @@ +//! The audit's statements checks (plan P8; rules.md B-45): the books, +//! read as an auditor would, through a date, as a list of findings. +//! +//! ```text +//! GOAL: one deterministic list of what is wrong or unfinished in the +//! books through a date, from the ledger alone, so an audit run is +//! reproducible and its workpaper hashes the same twice. +//! SOURCE: IRS Pub. 583 (records that support entries); Rev. Proc. 98-25 +//! §5.01 (the relationship between the records and the books must +//! be demonstrable); docs/spec/rules.md B-6 to B-14 (the +//! statements), B-38 (control accounts), B-43 (evidence); +//! DECISION D-9 (the audit and its bar), D-53 (a run, not a job). +//! INVARIANTS: +//! 1. Pure: reads the ledger and the facts [`AuditInput`] hands in, +//! changes nothing, reads no clock: the same books and input give +//! the same findings in the same order. +//! 2. One function per check, one finding code per check ([`CODES`]); +//! a check that finds nothing says nothing. +//! 3. Only entries dated on or before `through` count, drafts and +//! posted alike. +//! 4. A check that cannot be made is itself a finding, never a silent +//! pass (`cash_flow_unfooted` for a statement the engine refuses). +//! SCENARIOS: +//! 1. IF the books are whole: SHOULD give no Failure or Warning. +//! 2. IF a draft is dated on or before `through`: SHOULD give exactly +//! `draft_open`. +//! ``` + +use std::collections::BTreeSet; + +use chrono::NaiveDate; +use rust_decimal::Decimal; +use serde::Serialize; + +use crate::evidence::{Policy, Requirement}; +use crate::settings::Settings; +use crate::{AccountType, DocStatus, EntryId, EntryStatus, Ledger, PeriodStatus, Side}; + +/// How much a finding matters. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Severity { + /// The books are wrong or cannot be shown right: an audit with one is + /// not clean. + Failure, + /// Unfinished work or a policy not met. + Warning, + /// Listed so the workpaper is whole; not a defect. + Info, +} + +impl Severity { + /// Its wire name. + pub fn key(self) -> &'static str { + match self { + Severity::Failure => "failure", + Severity::Warning => "warning", + Severity::Info => "info", + } + } +} + +/// What a finding is about. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(tag = "kind", content = "id", rename_all = "snake_case")] +pub enum Subject { + /// The books as a whole. + Books, + /// An account. + Account(u64), + /// An entry. + Entry(u64), + /// An invoice. + Invoice(u64), + /// A bill. + Bill(u64), + /// An accounting period. + Period(u64), + /// An amortization schedule. + Schedule(u64), + /// A filed attachment. + Attachment(u64), + /// A vendor, by name. + Vendor(String), + /// A stored file or the access log, by name. + Named(String), +} + +impl Subject { + /// In words: `entry 7`, `books`. + pub fn describe(&self) -> String { + match self { + Subject::Books => "books".into(), + Subject::Account(i) => format!("account {i}"), + Subject::Entry(i) => format!("entry {i}"), + Subject::Invoice(i) => format!("invoice {i}"), + Subject::Bill(i) => format!("bill {i}"), + Subject::Period(i) => format!("period {i}"), + Subject::Schedule(i) => format!("schedule {i}"), + Subject::Attachment(i) => format!("attachment {i}"), + Subject::Vendor(n) => format!("vendor {n}"), + Subject::Named(n) => n.clone(), + } + } +} + +/// One thing an audit found. +#[derive(Clone, Debug, PartialEq, Eq, Serialize)] +pub struct Finding { + /// Its stable code ([`CODES`]). + pub code: &'static str, + /// How much it matters. + pub severity: Severity, + /// What it is about. + pub subject: Subject, + /// In words, with the exact figures. + pub message: String, +} + +impl Finding { + /// A finding of the severity its code's row says. + pub fn new(code: &'static str, subject: Subject, message: impl Into) -> Finding { + let severity = CODES + .iter() + .find(|r| r.code == code) + .map_or(Severity::Failure, |r| r.severity); + Finding { + code, + severity, + subject, + message: message.into(), + } + } +} + +/// One row of the code table: a check's finding code, how much it matters +/// and the predicate it states. +#[derive(Clone, Copy, Debug)] +pub struct CodeRow { + /// The finding code. + pub code: &'static str, + /// Its severity. + pub severity: Severity, + /// The predicate: what is true when the code is reported. + pub predicate: &'static str, +} + +/// Every statements check, by code (the integrity checks of the store have +/// their own table, `ledger_store::audit::CODES`). `audit_detects_each_ +/// seeded_defect` seeds each row. +pub const CODES: &[CodeRow] = &[ + CodeRow { + code: "trial_balance_unbalanced", + severity: Severity::Failure, + predicate: "total debits differ from total credits in the trial balance through the date", + }, + CodeRow { + code: "equation_broken", + severity: Severity::Failure, + predicate: "assets differ from liabilities plus equity (current earnings included)", + }, + CodeRow { + code: "pnl_not_articulated", + severity: Severity::Failure, + predicate: "net income since the first entry differs from current earnings plus what closes moved to equity", + }, + CodeRow { + code: "cash_flow_unfooted", + severity: Severity::Failure, + predicate: "the statement of cash flows cannot be made, or its sections do not sum to the cash accounts' change", + }, + CodeRow { + code: "control_difference", + severity: Severity::Failure, + predicate: "a control account's balance differs from its documents plus its unattached lines", + }, + CodeRow { + code: "unattached_control_line", + severity: Severity::Warning, + predicate: "a line on a control account carries no document", + }, + CodeRow { + code: "document_overpaid", + severity: Severity::Warning, + predicate: "a document has more paid against it than it is for", + }, + CodeRow { + code: "close_split_drifts", + severity: Severity::Failure, + predicate: "a member's running share of the closes is more than a minor unit from its exact share", + }, + CodeRow { + code: "schedule_behind", + severity: Severity::Warning, + predicate: "an amortization schedule has a month due through the date that is not posted", + }, + CodeRow { + code: "depreciation_behind", + severity: Severity::Warning, + predicate: "the asset schedules say more depreciation is due than the books carry", + }, + CodeRow { + code: "register_differs", + severity: Severity::Failure, + predicate: "the fixed asset register's cost differs from the cost accounts' balances", + }, + CodeRow { + code: "evidence_missing", + severity: Severity::Failure, + predicate: "a posted entry the receipt policy covers has no receipt", + }, + CodeRow { + code: "legacy_reversal_exempt", + severity: Severity::Info, + predicate: "a reversal the policy would cover has no receipt of its own; its original's evidence stands for it (D-47)", + }, + CodeRow { + code: "draft_open", + severity: Severity::Warning, + predicate: "a draft is dated on or before the date", + }, + CodeRow { + code: "tax_category_missing", + severity: Severity::Warning, + predicate: "a revenue or expense account has no tax category", + }, + CodeRow { + code: "vendor_1099_undecided", + severity: Severity::Warning, + predicate: "a vendor paid in a way that could need a 1099 has no decision", + }, + CodeRow { + code: "settings_problem", + severity: Severity::Warning, + predicate: "the settings name an account or role the chart cannot honor", + }, +]; + +/// What the ledger cannot know by itself and the caller reads from the +/// books around it. +#[derive(Clone, Copy, Debug)] +pub struct AuditInput<'a> { + /// The books' settings (the receipt policy). + pub settings: &'a Settings, + /// The entries that have at least one receipt filed against them, not + /// withdrawn. + pub receipts: &'a BTreeSet, + /// The statement of cash flows tried from the first entry through the + /// date with the books' role settings: its footing, or why it could + /// not be made; `None` when the settings name no cash accounts or + /// there is nothing to state. + pub cash_flow: Option<&'a Result>, + /// What the settings check already says is wrong, in words. + pub settings_problems: &'a [String], + /// Vendors paid in a way that could need a 1099 with no decision, and + /// what they were paid. + pub undecided_1099: &'a [(String, Decimal)], +} + +/// All the statements checks through `through`, in a fixed order: +/// failures first, then by code and subject. +pub fn statements(ledger: &Ledger, input: &AuditInput<'_>, through: NaiveDate) -> Vec { + let mut out = Vec::new(); + out.extend(trial_balance(ledger, through)); + out.extend(equation(ledger, through)); + out.extend(articulation(ledger, through)); + out.extend(check_cash_flow(input.cash_flow)); + out.extend(control(ledger, through)); + out.extend(closes(ledger, through)); + out.extend(schedules(ledger, through)); + out.extend(assets(ledger, through)); + out.extend(evidence(ledger, input, through)); + out.extend(drafts(ledger, through)); + out.extend(tax_categories(ledger)); + out.extend(vendors_1099(input.undecided_1099)); + out.extend(settings_problems(input.settings_problems)); + sort(&mut out); + out +} + +/// Findings in their reporting order. +pub fn sort(findings: &mut [Finding]) { + findings.sort_by(|a, b| { + (a.severity, a.code, &a.subject, &a.message) + .cmp(&(b.severity, b.code, &b.subject, &b.message)) + }); +} + +fn trial_balance(ledger: &Ledger, through: NaiveDate) -> Option { + let tb = crate::reports::trial_balance_as_of(ledger, through); + check_trial_balance(&tb) +} + +/// The trial balance check on a built trial balance. +pub fn check_trial_balance(tb: &crate::reports::TrialBalance) -> Option { + (!tb.balanced()).then(|| { + Finding::new( + "trial_balance_unbalanced", + Subject::Books, + format!( + "debits {} differ from credits {}", + tb.total_debits, tb.total_credits + ), + ) + }) +} + +fn equation(ledger: &Ledger, through: NaiveDate) -> Option { + match crate::reports::balance_sheet_as_of(ledger, through) { + Ok(bs) => check_equation(&bs), + Err(e) => Some(Finding::new( + "equation_broken", + Subject::Books, + format!("the balance sheet cannot be made: {e}"), + )), + } +} + +/// The accounting equation on a built balance sheet. +pub fn check_equation(bs: &crate::reports::BalanceSheetStatement) -> Option { + (!bs.balanced()).then(|| { + Finding::new( + "equation_broken", + Subject::Books, + format!( + "assets {} differ from liabilities {} plus equity {}", + bs.total_assets, bs.total_liabilities, bs.total_equity + ), + ) + }) +} + +/// Net income moved to equity by the closes dated through `through`: +/// revenue closed less expenses closed, read off the closing entries' +/// lines. +fn closed_net(ledger: &Ledger, through: NaiveDate) -> Decimal { + let mut net = Decimal::ZERO; + for id in ledger.closing_entry_ids() { + let Some(e) = ledger.get_entry(id) else { + continue; + }; + if e.status() != EntryStatus::Posted || e.date() > through { + continue; + } + for l in e.lines() { + let Some(a) = ledger.chart().get(l.account) else { + continue; + }; + let signed = match l.side { + Side::Debit => l.amount.amount, + Side::Credit => -l.amount.amount, + }; + if matches!(a.account_type, AccountType::Revenue | AccountType::Expense) { + net += signed; + } + } + } + net +} + +fn articulation(ledger: &Ledger, through: NaiveDate) -> Option { + let first = ledger + .posted_entries() + .filter(|e| e.date() <= through) + .map(|e| e.date()) + .min()?; + let pnl = crate::reports::profit_and_loss_for_period(ledger, first, through, None).ok()?; + let bs = crate::reports::balance_sheet_as_of(ledger, through).ok()?; + check_articulation( + pnl.net_income(), + bs.current_earnings, + closed_net(ledger, through), + ) +} + +/// The P&L ties to equity: net income since the first entry is current +/// earnings plus what the closes moved into equity. +pub fn check_articulation( + net_income: Decimal, + current_earnings: Decimal, + closed: Decimal, +) -> Option { + (net_income != current_earnings + closed).then(|| { + Finding::new( + "pnl_not_articulated", + Subject::Books, + format!( + "net income {net_income} differs from current earnings {current_earnings} plus closed {closed}" + ), + ) + }) +} + +/// The statement of cash flows, tried by the caller from the books' role +/// settings: its footing, or why it could not be made. +pub fn check_cash_flow( + made: Option<&Result>, +) -> Option { + match made? { + Ok(f) if f.difference.is_zero() => None, + Ok(f) => Some(Finding::new( + "cash_flow_unfooted", + Subject::Books, + format!( + "the sections sum to {} but the cash accounts changed by {}", + f.sections_sum, f.net_change + ), + )), + Err(e) => Some(Finding::new( + "cash_flow_unfooted", + Subject::Books, + format!("the statement of cash flows cannot be made: {e}"), + )), + } +} + +fn control(ledger: &Ledger, through: NaiveDate) -> Vec { + match ledger.control_check(through) { + Ok(checks) => check_control(&checks), + Err(e) => vec![Finding::new( + "control_difference", + Subject::Books, + format!("the control check cannot be made: {e}"), + )], + } +} + +/// The control accounts' tie-out, as the engine read it. +pub fn check_control(checks: &[crate::arap::ControlCheck]) -> Vec { + let mut out = Vec::new(); + for c in checks { + if !c.difference.is_zero() { + out.push(Finding::new( + "control_difference", + Subject::Account(c.account), + format!( + "the {} account's balance {} differs from its documents {} by {}", + c.role, c.balance, c.documents_total, c.difference + ), + )); + } + for u in &c.unattached { + out.push(Finding::new( + "unattached_control_line", + Subject::Entry(u.entry), + format!( + "line {} of entry {} ({}), {} on the {} account, carries no document", + u.line, u.entry, u.date, u.amount, c.role + ), + )); + } + for a in c + .attention + .iter() + .filter(|a| a.status == DocStatus::Overpaid) + { + let subject = match a.doc { + crate::DocRef::Invoice(i) => Subject::Invoice(i), + crate::DocRef::Bill(b) => Subject::Bill(b), + }; + out.push(Finding::new( + "document_overpaid", + subject, + format!("{} is overpaid: its total is {}", a.doc, a.total), + )); + } + } + out +} + +fn closes(ledger: &Ledger, through: NaiveDate) -> Vec { + let unit = Decimal::new(1, ledger.base_currency().minor_units().unwrap_or(2)); + let mut out = Vec::new(); + for p in ledger.list_periods() { + if p.status != PeriodStatus::Closed || p.end > through { + continue; + } + if let Some((_, split)) = ledger.member_close_split(p.id) { + out.extend(check_split(p.id, &p.name, &split, unit)); + } + } + out +} + +/// One close's members: each one's running total of the closes must be +/// within a minor unit of its exact share (the cumulative rule, +/// `kernel::apportion_cumulative`). +pub fn check_split( + period: u64, + name: &str, + split: &[crate::periods::MemberSplit], + unit: Decimal, +) -> Vec { + split + .iter() + .filter(|m| (m.running - m.exact).abs() > unit) + .map(|m| { + Finding::new( + "close_split_drifts", + Subject::Period(period), + format!( + "after the close of {name}, account {} holds {} of the closes where its exact share is {}", + m.capital, m.running, m.exact + ), + ) + }) + .collect() +} + +fn schedules(ledger: &Ledger, through: NaiveDate) -> Vec { + let mut out = Vec::new(); + for s in ledger.schedules() { + let months = match s.due(through) { + Ok(m) => m, + Err(e) => { + out.push(Finding::new( + "schedule_behind", + Subject::Schedule(s.id), + format!("\"{}\" cannot be planned: {e}", s.description), + )); + continue; + } + }; + if let Some((first, _)) = months.first() { + let total: Decimal = months.iter().map(|(_, a)| *a).sum(); + out.push(Finding::new( + "schedule_behind", + Subject::Schedule(s.id), + format!( + "\"{}\" has {} month(s) due and not posted, from {first}, {total} in all", + s.description, + months.len() + ), + )); + } + } + out +} + +fn assets(ledger: &Ledger, through: NaiveDate) -> Vec { + match crate::reports::fixed_asset_register(ledger, through) { + Ok(reg) => check_register(®), + Err(e) => vec![Finding::new( + "register_differs", + Subject::Books, + format!("the fixed asset register cannot be made: {e}"), + )], + } +} + +/// The register against the books' cost and accumulated depreciation. +pub fn check_register(reg: &crate::reports::AssetRegister) -> Vec { + let mut out = Vec::new(); + if reg.unposted_depreciation() > Decimal::ZERO { + out.push(Finding::new( + "depreciation_behind", + Subject::Books, + format!( + "the schedules say {} of depreciation is due and the books carry {}", + reg.total_accumulated_scheduled, reg.ledger_accumulated + ), + )); + } + if !reg.unregistered_cost().is_zero() { + out.push(Finding::new( + "register_differs", + Subject::Books, + format!( + "the register holds {} of cost and the cost accounts {}", + reg.total_cost, reg.ledger_cost + ), + )); + } + out +} + +fn evidence(ledger: &Ledger, input: &AuditInput<'_>, through: NaiveDate) -> Vec { + let policy = Policy::from_settings(input.settings); + let mut out = Vec::new(); + for e in ledger.posted_entries().filter(|e| e.date() <= through) { + if input.receipts.contains(&e.id()) { + continue; + } + match crate::evidence::requirement(ledger, e, input.settings) { + Requirement::Required { reason } => out.push(Finding::new( + "evidence_missing", + Subject::Entry(e.id()), + format!("entry {} ({}) has no receipt: {reason}", e.id(), e.date()), + )), + Requirement::Exempt { .. } if e.reversal_of().is_some() => { + if policy.receipt_required(ledger, e) { + out.push(Finding::new( + "legacy_reversal_exempt", + Subject::Entry(e.id()), + format!( + "entry {} reverses entry {}: its evidence is the original's", + e.id(), + e.reversal_of().unwrap_or_default() + ), + )); + } + } + Requirement::Exempt { .. } => {} + } + } + out +} + +fn drafts(ledger: &Ledger, through: NaiveDate) -> Vec { + ledger + .drafts() + .into_iter() + .filter(|e| e.date() <= through) + .map(|e| { + Finding::new( + "draft_open", + Subject::Entry(e.id()), + format!( + "draft {} ({}) \"{}\" is not posted", + e.id(), + e.date(), + e.description() + ), + ) + }) + .collect() +} + +fn tax_categories(ledger: &Ledger) -> Vec { + crate::tax::uncategorized_accounts(ledger) + .into_iter() + .map(|a| { + Finding::new( + "tax_category_missing", + Subject::Account(a.id), + format!("account {} {} has no tax category", a.code, a.name), + ) + }) + .collect() +} + +fn vendors_1099(undecided: &[(String, Decimal)]) -> Vec { + undecided + .iter() + .map(|(name, paid)| { + Finding::new( + "vendor_1099_undecided", + Subject::Vendor(name.clone()), + format!( + "{name} was paid {paid} in a way that could need a 1099 and has no decision" + ), + ) + }) + .collect() +} + +fn settings_problems(problems: &[String]) -> Vec { + problems + .iter() + .map(|p| Finding::new("settings_problem", Subject::Books, p.clone())) + .collect() +} + +// --------------------------------------------------------------------------- +// Scope, method and the attestation bar (rules.md B-45; D-9) +// --------------------------------------------------------------------------- + +/// What an audit covers. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Scope { + /// A month's books, for its month-end attestation. + Month, + /// A fiscal year. + Annual, + /// The year, for the CPA handoff. + CpaHandoff, + /// Everything since the first entry. + Full, +} + +impl Scope { + /// Its wire name. + pub fn key(self) -> &'static str { + match self { + Scope::Month => "month", + Scope::Annual => "annual", + Scope::CpaHandoff => "cpa_handoff", + Scope::Full => "full", + } + } + + /// The scope a word names. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "month" => Some(Scope::Month), + "annual" => Some(Scope::Annual), + "cpa_handoff" => Some(Scope::CpaHandoff), + "full" => Some(Scope::Full), + _ => None, + } + } + + /// Whether a period attested on this audit is held to the whole bar + /// (adversarial, several verifiers) and not the effort alone (D-9). + pub fn full_bar(self) -> bool { + !matches!(self, Scope::Month) + } + + /// Whether an audit of this scope is one the CPA package may rest on. + pub fn serves_handoff(self) -> bool { + matches!(self, Scope::CpaHandoff | Scope::Full) + } +} + +/// How hard the checker thought, as the client reports it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum Effort { + /// low + Low, + /// medium + Medium, + /// high + High, + /// xhigh + Xhigh, + /// max + Max, +} + +impl Effort { + /// Its wire name. + pub fn key(self) -> &'static str { + match self { + Effort::Low => "low", + Effort::Medium => "medium", + Effort::High => "high", + Effort::Xhigh => "xhigh", + Effort::Max => "max", + } + } + + /// The effort a word names. + pub fn parse(s: &str) -> Option { + match s.trim().to_ascii_lowercase().as_str() { + "low" => Some(Effort::Low), + "medium" => Some(Effort::Medium), + "high" => Some(Effort::High), + "xhigh" => Some(Effort::Xhigh), + "max" => Some(Effort::Max), + _ => None, + } + } +} + +/// How an attestation was made, as the client says (self-reported: the +/// books record the claim, never prove it; rules.md B-45). +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +pub struct Method { + /// The model that checked, as it names itself. + pub model: Option, + /// How hard it thought. + pub reasoning_effort: Option, + /// Whether the check was adversarial: a verifier set to find what is + /// wrong, not to confirm. + pub adversarial: Option, + /// How many independent verifier agents checked. + pub verifier_agents: Option, +} + +/// What counts as an attestation (`policy.attestation_bar`; D-9). The +/// default: reasoning effort high, two independent verifiers, adversarial. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize)] +pub struct AttestationBar { + /// The least effort that counts, for every attestation. + pub reasoning_effort: Effort, + /// The fewest independent verifier agents a year-level attestation + /// needs. + pub verifier_agents: u32, + /// Whether a year-level attestation must be adversarial. + pub adversarial: bool, +} + +impl Default for AttestationBar { + fn default() -> Self { + AttestationBar { + reasoning_effort: Effort::High, + verifier_agents: 2, + adversarial: true, + } + } +} + +impl AttestationBar { + /// The bar a setting's text names: `key=value` words in any order, + /// separated by spaces or commas (`effort=high verifiers=2 + /// adversarial=true`); a word left out keeps its default. + pub fn parse(s: &str) -> Result { + let mut bar = AttestationBar::default(); + for word in s.split(|c: char| c.is_whitespace() || c == ',') { + if word.is_empty() { + continue; + } + let (k, v) = word.split_once('=').ok_or_else(|| { + format!("{word:?}: expected key=value (effort, verifiers, adversarial)") + })?; + match k { + "effort" | "reasoning_effort" => { + bar.reasoning_effort = Effort::parse(v).ok_or_else(|| { + format!("effort {v:?} is not low, medium, high, xhigh or max") + })?; + } + "verifiers" | "verifier_agents" => { + bar.verifier_agents = v + .parse::() + .ok() + .filter(|n| (1..=64).contains(n)) + .ok_or_else(|| format!("verifiers {v:?} is not a count from 1 to 64"))?; + } + "adversarial" => { + bar.adversarial = match v { + "true" => true, + "false" => false, + _ => return Err(format!("adversarial {v:?} is not true or false")), + }; + } + other => { + return Err(format!( + "{other:?} is not a part of the bar: effort, verifiers, adversarial" + )); + } + } + } + Ok(bar) + } + + /// The bar in its one canonical spelling. + pub fn describe(&self) -> String { + format!( + "effort={} verifiers={} adversarial={}", + self.reasoning_effort.key(), + self.verifier_agents, + self.adversarial + ) + } + + /// Whether an attestation made `method` counts. A claim that + /// disagrees with the books never does. Every attestation needs the + /// effort; one given on a year-level audit (`full`) needs the whole + /// bar. Evaluated when asked, against the bar as it stands (D-9: no + /// bar versioning). + pub fn counts(&self, method: &Method, full: bool, disagrees: bool) -> bool { + if disagrees { + return false; + } + if !method + .reasoning_effort + .is_some_and(|e| e >= self.reasoning_effort) + { + return false; + } + if !full { + return true; + } + (!self.adversarial || method.adversarial == Some(true)) + && method + .verifier_agents + .is_some_and(|n| n >= self.verifier_agents) + } +} diff --git a/crates/ledger-core/src/conventions.rs b/crates/ledger-core/src/conventions.rs index 15a24b6..011d9cb 100644 --- a/crates/ledger-core/src/conventions.rs +++ b/crates/ledger-core/src/conventions.rs @@ -277,6 +277,30 @@ pub struct NewDecision { pub projects: Vec, } +/// Where a decision comes from: who decided, when, and what it rests on. +/// A correction of these three is journaled on its own +/// (`decision.source_revised`), the decision itself unchanged. +#[derive(Clone, Debug, PartialEq)] +pub struct DecisionSource { + /// Who decided (names), at least one. + pub decided_by: Vec, + /// When. + pub decided_on: NaiveDate, + /// The rule or document it rests on, if any. + pub citation: Option, +} + +impl NewDecision { + /// The decision's source. + pub fn source(&self) -> DecisionSource { + DecisionSource { + decided_by: self.decided_by.clone(), + decided_on: self.decided_on, + citation: self.citation.clone(), + } + } +} + /// A decision as the journal holds it. #[derive(Clone, Debug, PartialEq)] pub struct Decision { @@ -645,6 +669,39 @@ impl Conventions { Ok(()) } + /// Correct a convention's source as journaled (`convention.source_revised`): + /// who decided it, when and on what; its text, revision and line stay. + pub fn apply_convention_source(&mut self, id: &str, source: Source) -> Result<(), String> { + let c = self + .conventions + .get_mut(id) + .ok_or_else(|| format!("unknown convention {id:?}"))?; + c.source = source; + Ok(()) + } + + /// Correct a decision's source as journaled (`decision.source_revised`). + pub fn apply_decision_source(&mut self, id: u64, source: DecisionSource) -> Result<(), String> { + let d = self + .decisions + .get_mut(&id) + .ok_or_else(|| format!("unknown decision {id}"))?; + d.new.decided_by = source.decided_by; + d.new.decided_on = source.decided_on; + d.new.citation = source.citation; + Ok(()) + } + + /// Correct an open item's source as journaled (`open_item.source_revised`). + pub fn apply_open_item_source(&mut self, id: u64, source: Source) -> Result<(), String> { + let i = self + .open_items + .get_mut(&id) + .ok_or_else(|| format!("unknown open item {id}"))?; + i.new.source = source; + Ok(()) + } + /// Record an approval as journaled. pub fn apply_approval(&mut self, entry: EntryId, a: Approval) { self.approvals.entry(entry).or_default().push(a); @@ -800,6 +857,28 @@ pub fn check_decision(d: &NewDecision) -> Result { }) } +/// Check a decision's corrected source (the same rules as a new +/// decision's own). +pub fn check_decision_source(s: &DecisionSource) -> Result { + if s.decided_by.is_empty() { + return Err("decided_by names at least one person".into()); + } + crate::calendar::check(s.decided_on).map_err(|e| e.to_string())?; + Ok(DecisionSource { + decided_by: s + .decided_by + .iter() + .map(|n| line_text("decided_by", n, MAX_TEXT)) + .collect::>()?, + decided_on: s.decided_on, + citation: s + .citation + .as_deref() + .map(|c| long_text("citation", c, MAX_LONG_TEXT)) + .transpose()?, + }) +} + /// Check a new open item's text (INVARIANT 6). pub fn check_open_item(i: &NewOpenItem) -> Result { i.source.check()?; diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 9053143..cf75363 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -8753,6 +8753,135 @@ impl Ledger { .map_err(LedgerError::InvalidConvention) } + /// Check a correction of a convention's source before it is journaled + /// (P5; `convention.source_revised`): the convention is live, `c` says + /// exactly what it says now (title, text, structure; a change of any is + /// a revision, not a correction) and a source that differs from the + /// one it has. Returns the source as it will be journaled, with the + /// reason; changes nothing. + pub fn check_convention_source( + &self, + c: &crate::conventions::NewConvention, + reason: &str, + ) -> Result<(crate::settings::Source, String), LedgerError> { + let bad = LedgerError::InvalidConvention; + let c = crate::conventions::check_new(c).map_err(bad)?; + let old = self.conventions.convention(&c.id).ok_or_else(|| { + bad(format!( + "unknown convention {:?}: books.conventions lists them", + c.id + )) + })?; + if old.retired.is_some() { + return Err(bad(format!("convention {} is retired", c.id))); + } + if old.kind != c.kind { + return Err(bad(format!( + "convention {} is a {}; its kind can't change", + c.id, + old.kind.key() + ))); + } + if old.title != c.title || old.body_md != c.body_md || old.structured != c.structured { + return Err(bad(format!( + "convention {} changes more than its source: revise it without a reason, or give the text it has now", + c.id + ))); + } + if old.source == c.source { + return Err(bad(format!("convention {} already has that source", c.id))); + } + let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT) + .map_err(bad)?; + if reason.is_empty() { + return Err(bad("a source correction needs a reason".into())); + } + Ok((c.source, reason)) + } + + /// Apply a journaled `convention.source_revised`. + pub fn apply_convention_source( + &mut self, + id: &str, + source: crate::settings::Source, + ) -> Result<(), LedgerError> { + self.conventions + .apply_convention_source(id, source) + .map_err(LedgerError::InvalidConvention) + } + + /// Check a correction of a decision's source (`decision.source_revised`). + pub fn check_decision_source( + &self, + id: u64, + source: &crate::conventions::DecisionSource, + reason: &str, + ) -> Result<(crate::conventions::DecisionSource, String), LedgerError> { + let bad = LedgerError::InvalidConvention; + let old = self.conventions.decision(id).ok_or_else(|| { + bad(format!( + "decision {id} doesn't exist: books.decisions lists them" + )) + })?; + let source = crate::conventions::check_decision_source(source).map_err(bad)?; + if old.new.source() == source { + return Err(bad(format!("decision {id} already has that source"))); + } + let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT) + .map_err(bad)?; + if reason.is_empty() { + return Err(bad("a source correction needs a reason".into())); + } + Ok((source, reason)) + } + + /// Apply a journaled `decision.source_revised`. + pub fn apply_decision_source( + &mut self, + id: u64, + source: crate::conventions::DecisionSource, + ) -> Result<(), LedgerError> { + self.conventions + .apply_decision_source(id, source) + .map_err(LedgerError::InvalidConvention) + } + + /// Check a correction of an open item's source (`open_item.source_revised`). + pub fn check_open_item_source( + &self, + id: u64, + source: &crate::settings::Source, + reason: &str, + ) -> Result<(crate::settings::Source, String), LedgerError> { + let bad = LedgerError::InvalidConvention; + source.check().map_err(bad)?; + let old = self.conventions.open_item(id).ok_or_else(|| { + bad(format!( + "unknown open item {id}: books.open_items lists them" + )) + })?; + if old.new.source == *source { + return Err(bad(format!("open item {id} already has that source"))); + } + let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT) + .map_err(bad)?; + if reason.is_empty() { + return Err(bad("a source correction needs a reason".into())); + } + Ok((source.clone(), reason)) + } + + /// Apply a journaled `open_item.source_revised`. + pub fn apply_open_item_source( + &mut self, + id: u64, + source: crate::settings::Source, + ) -> Result<(), LedgerError> { + self.conventions + .apply_open_item_source(id, source) + .map_err(LedgerError::InvalidConvention) + } + /// Check a retirement before it is journaled: the convention is live, /// the reason and source are sound. pub fn check_retire_convention( diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 6bea26e..ec6579b 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -20,6 +20,7 @@ pub mod account; pub mod analysis; pub mod arap; pub mod assets; +pub mod audit; pub mod calendar; pub mod cashflow; pub mod classes; diff --git a/crates/ledger-core/src/settings.rs b/crates/ledger-core/src/settings.rs index 7387878..1f20afb 100644 --- a/crates/ledger-core/src/settings.rs +++ b/crates/ledger-core/src/settings.rs @@ -146,6 +146,9 @@ pub enum Kind { Terms, /// Payment methods (A-3): a list of `{kind, label, lines, link?, qr?}`. PaymentMethods, + /// What counts as an attestation ([`crate::audit::AttestationBar`]), + /// in its one canonical spelling. + AttestationBar, } /// One setting the books know. @@ -170,6 +173,7 @@ const PARTY_POLICY: &[(&str, &[&str])] = const APPROVAL_MODE: &[(&str, &[&str])] = &[("off", &[]), ("enforce", &[])]; const TAX_CATEGORY_POLICY: &[(&str, &[&str])] = &[("warn", &[]), ("refuse", &["required", "require"])]; +const AUDIT_REQUIRED: &[(&str, &[&str])] = &[("refuse", &["required", "require"]), ("warn", &[])]; const SCHEDULE_CONVENTION: &[(&str, &[&str])] = &[("daily", &[]), ("monthly_even", &[])]; /// A return's key as a setting keeps it. @@ -401,6 +405,21 @@ pub static KEYS: &[Key] = &[ sensitivity: Sensitivity::Public, doc: "off: approval_rule conventions are recorded but never block a post. enforce: an entry a rule covers posts only once its approver approves it (books.approve_entry). Unset: off.", }, + // P8: audits and attestations. + Key { + name: "policy.attestation_bar", + toml: ("books", "attestation_bar"), + kind: Kind::AttestationBar, + sensitivity: Sensitivity::Public, + doc: "What counts as an attestation (plan D-9): the least reasoning effort a checker reports (low, medium, high, xhigh, max), and for a year-level audit the fewest independent verifier agents and whether the check was adversarial, written like effort=high verifiers=2 adversarial=true (a word left out keeps its default). The client reports how it checked; the books record that claim and say whether it meets this bar when asked, so a change here changes what counts, earlier attestations too. Unset: effort=high verifiers=2 adversarial=true.", + }, + Key { + name: "policy.cpa_package_requires_audit", + toml: ("books", "cpa_package_requires_audit"), + kind: Kind::Choice(AUDIT_REQUIRED), + sensitivity: Sensitivity::Public, + doc: "Whether books.export_cpa_package waits for an audit: refuse (the package is refused unless an audit for the handoff or a full audit at the current books has its period attested; a reason given for the export overrides it and is recorded as a decision) or warn (the package is made and says so). Unset: refuse once the books hold an audit run, warn before.", + }, Key { name: "backup.git_remote", toml: ("backup", "git_remote"), @@ -1103,6 +1122,11 @@ pub fn check(key: &Key, v: &Value) -> Result { Ok(json!(terms.describe())) } Kind::PaymentMethods => payment_methods(name, want_list(name, v)?), + Kind::AttestationBar => { + let bar = crate::audit::AttestationBar::parse(want_str(name, v)?) + .map_err(|e| format!("{name}: {e}"))?; + Ok(json!(bar.describe())) + } Kind::TimeZone => { let s = want_str(name, v)?.trim(); let zone = jiff::tz::TimeZone::get(s).map_err(|_| { @@ -1230,6 +1254,9 @@ pub fn shape(key: &Key, v: &Value) -> Result<(), String> { .ok_or_else(|| format!("{name} {s:?} is not a decimal")) } Kind::Count => count_of(name, v).map(|_| ()), + Kind::AttestationBar => crate::audit::AttestationBar::parse(want_str(name, v)?) + .map(|_| ()) + .map_err(|e| format!("{name}: {e}")), Kind::PaymentMethods => { for (i, m) in want_list(name, v)?.iter().enumerate() { let n = format!("{name} method {}", i + 1); @@ -1605,6 +1632,25 @@ impl Settings { } } + /// `policy.attestation_bar` (unset: the default bar, D-9). + pub fn attestation_bar(&self) -> crate::audit::AttestationBar { + self.str("policy.attestation_bar") + .and_then(|s| crate::audit::AttestationBar::parse(s).ok()) + .unwrap_or_default() + } + + /// `policy.cpa_package_requires_audit`: `Some(true)` for refuse, + /// `Some(false)` for warn, `None` when unset (the package then refuses + /// once the books hold an audit run, and warns before: the owner has + /// not begun the programme). + pub fn cpa_package_requires_audit(&self) -> Option { + match self.str("policy.cpa_package_requires_audit") { + Some("warn") => Some(false), + Some(_) => Some(true), + None => None, + } + } + /// `backup.git_remote`, if set. pub fn backup_git_remote(&self) -> Option<&str> { self.str("backup.git_remote") diff --git a/crates/ledger-core/tests/audit.rs b/crates/ledger-core/tests/audit.rs new file mode 100644 index 0000000..8be2111 --- /dev/null +++ b/crates/ledger-core/tests/audit.rs @@ -0,0 +1,439 @@ +//! The audit's statements checks (plan P8; rules.md B-45): one test per +//! finding code, each seeding exactly the defect the code names. + +use std::collections::BTreeSet; + +use chrono::NaiveDate; +use ledger_core::arap::{ControlCheck, DocAttention, UnattachedLine}; +use ledger_core::audit::{ + self, AttestationBar, AuditInput, CODES, Effort, Finding, Method, Severity, +}; +use ledger_core::cashflow::CashFlowFooting; +use ledger_core::periods::MemberSplit; +use ledger_core::reports::{AssetRegister, TrialBalance}; +use ledger_core::settings::Settings; +use ledger_core::{ + AccountType, Currency, DocRef, DocStatus, EntryId, Ledger, Money, NewAsset, NewSchedule, + ScheduleConvention, ScheduleKind, Side, +}; +use rust_decimal::Decimal; + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn dec(s: &str) -> Decimal { + s.parse().unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(dec(s), Currency::USD) +} + +struct Books { + l: Ledger, + cash: u64, + capital: u64, + expense: u64, + revenue: u64, +} + +/// A bank, capital, an expense and a revenue account, all categorized. +fn books() -> Books { + let mut l = Ledger::new(Currency::USD); + let mut a = |code: &str, name: &str, t| l.add_account(code, name, t, None, false).unwrap(); + let cash = a("1000", "Bank", AccountType::Asset); + let capital = a("3000", "Capital", AccountType::Equity); + let revenue = a("4000", "Sales", AccountType::Revenue); + let expense = a("6000", "Supplies", AccountType::Expense); + l.set_account_tax(revenue, Some("gross_receipts"), None) + .unwrap(); + l.set_account_tax(expense, Some("supplies"), None).unwrap(); + Books { + l, + cash, + capital, + expense, + revenue, + } +} + +fn post(l: &mut Ledger, date: NaiveDate, dr: u64, cr: u64, amt: &str) -> EntryId { + let e = l.create_entry(date, "x"); + l.add_line(e, dr, Side::Debit, usd(amt)).unwrap(); + l.add_line(e, cr, Side::Credit, usd(amt)).unwrap(); + l.post_entry(e).unwrap(); + e +} + +fn run(l: &Ledger, receipts: &BTreeSet, through: NaiveDate) -> Vec { + let settings = Settings::default(); + audit::statements( + l, + &AuditInput { + settings: &settings, + receipts, + cash_flow: None, + settings_problems: &[], + undecided_1099: &[], + }, + through, + ) +} + +/// Every posted entry has its receipt. +fn receipted(l: &Ledger) -> BTreeSet { + l.posted_entries().map(|e| e.id()).collect() +} + +fn codes(f: &[Finding]) -> Vec<&'static str> { + f.iter().map(|f| f.code).collect() +} + +fn control(difference: &str) -> ControlCheck { + ControlCheck { + role: "receivables".into(), + account: 7, + balance: dec("100.00"), + documents_total: dec("100.00"), + unattached: vec![], + difference: dec(difference), + attention: vec![], + } +} + +/// Every code's seed: the defect, run, and the codes it yields. +fn seeds() -> Vec<(&'static str, Vec)> { + let mut out = Vec::new(); + let none = BTreeSet::new(); + + out.push(( + "trial_balance_unbalanced", + audit::check_trial_balance(&TrialBalance { + lines: vec![], + total_debits: dec("10.00"), + total_credits: dec("9.99"), + }) + .into_iter() + .collect(), + )); + + let b = books(); + let mut bs = ledger_core::reports::balance_sheet_as_of(&b.l, d(12, 31)).unwrap(); + bs.total_assets = dec("1.00"); + out.push(( + "equation_broken", + audit::check_equation(&bs).into_iter().collect(), + )); + + out.push(( + "pnl_not_articulated", + audit::check_articulation(dec("100.00"), dec("40.00"), dec("50.00")) + .into_iter() + .collect(), + )); + + // A statement the engine refuses (a liability named as cash) and one + // that does not foot are both unfooted. + let settings = Settings::default(); + let refused: Result = Err("account 9 is not an asset".into()); + let off: Result = Ok(CashFlowFooting { + sections_sum: dec("10.00"), + net_change: dec("9.00"), + difference: dec("1.00"), + }); + let b = books(); + for made in [&refused, &off] { + let f = audit::statements( + &b.l, + &AuditInput { + settings: &settings, + receipts: &none, + cash_flow: Some(made), + settings_problems: &[], + undecided_1099: &[], + }, + d(12, 31), + ); + assert_eq!(codes(&f), vec!["cash_flow_unfooted"]); + } + out.push(( + "cash_flow_unfooted", + audit::check_cash_flow(Some(&off)).into_iter().collect(), + )); + + out.push(( + "control_difference", + audit::check_control(&[control("0.01")]), + )); + let mut c = control("0.00"); + c.unattached = vec![UnattachedLine { + entry: 3, + line: 0, + date: d(2, 1), + amount: dec("5.00"), + }]; + out.push(("unattached_control_line", audit::check_control(&[c]))); + let mut c = control("0.00"); + c.attention = vec![DocAttention { + doc: DocRef::Invoice(1), + status: DocStatus::Overpaid, + total: dec("10.00"), + outstanding: dec("-5.00"), + }]; + out.push(("document_overpaid", audit::check_control(&[c]))); + + out.push(( + "close_split_drifts", + audit::check_split( + 2, + "2026-10", + &[MemberSplit { + capital: 9, + share: dec("0.5"), + part: dec("1.00"), + running: dec("1.00"), + exact: dec("1.05"), + }], + dec("0.01"), + ), + )); + + // A prepaid plan with a month due and nothing posted. + let mut b = books(); + let prepaid = + b.l.add_account("1300", "Prepaid", AccountType::Asset, None, false) + .unwrap(); + b.l.register_schedule(NewSchedule { + description: "Insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd("1200.00"), + start: d(1, 1), + months: 12, + balance_account: prepaid, + pl_account: b.expense, + convention: ScheduleConvention::MonthlyEven, + }) + .unwrap(); + out.push(("schedule_behind", run(&b.l, &receipted(&b.l), d(3, 31)))); + + // A registered asset, no depreciation run. + let mut b = books(); + let (cost, accum, dep) = asset_accounts(&mut b); + post(&mut b.l, d(1, 2), cost, b.capital, "6000.00"); + b.l.register_asset(NewAsset { + description: "Van".into(), + in_service_date: d(1, 15), + cost: usd("6000.00"), + salvage_value: usd("0"), + useful_life_months: 60, + cost_account: cost, + accum_depr_account: accum, + expense_account: dep, + }) + .unwrap(); + out.push(("depreciation_behind", run(&b.l, &receipted(&b.l), d(6, 30)))); + + // Cost posted to the asset's account that the register doesn't hold. + post(&mut b.l, d(2, 1), cost, b.capital, "100.00"); + let f = run(&b.l, &receipted(&b.l), d(6, 30)); + out.push(( + "register_differs", + f.into_iter() + .filter(|f| f.code == "register_differs") + .collect(), + )); + + // A 40.00 hand entry (threshold 25.00) with no receipt, then reversed. + let mut b = books(); + let e = post(&mut b.l, d(2, 1), b.expense, b.cash, "40.00"); + let f = run(&b.l, &none, d(12, 31)); + out.push(("evidence_missing", f)); + let r = b.l.reverse_entry(e, d(2, 2), "undo").unwrap(); + let mut with_original = BTreeSet::new(); + with_original.insert(e); + let f = run(&b.l, &with_original, d(12, 31)); + assert!(f.iter().any(|f| f.subject == audit::Subject::Entry(r))); + out.push(("legacy_reversal_exempt", f)); + + let mut b = books(); + let e = b.l.create_entry(d(3, 3), "draft"); + b.l.add_line(e, b.expense, Side::Debit, usd("1.00")) + .unwrap(); + b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap(); + out.push(("draft_open", run(&b.l, &none, d(12, 31)))); + + let mut b = books(); + b.l.add_account("4100", "Other sales", AccountType::Revenue, None, false) + .unwrap(); + out.push(("tax_category_missing", run(&b.l, &none, d(12, 31)))); + + let b = books(); + let settings = Settings::default(); + out.push(( + "vendor_1099_undecided", + audit::statements( + &b.l, + &AuditInput { + settings: &settings, + receipts: &none, + cash_flow: None, + settings_problems: &[], + undecided_1099: &[("Tobin Harlow Supply".into(), dec("900.00"))], + }, + d(12, 31), + ), + )); + out.push(( + "settings_problem", + audit::statements( + &b.l, + &AuditInput { + settings: &settings, + receipts: &none, + cash_flow: None, + settings_problems: &["books.close_into names 9999".into()], + undecided_1099: &[], + }, + d(12, 31), + ), + )); + let _ = (b.revenue, AccountType::Asset, Side::Debit); + out +} + +fn asset_accounts(b: &mut Books) -> (u64, u64, u64) { + let cost = + b.l.add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let accum = + b.l.add_account( + "1590", + "Accumulated depreciation", + AccountType::Asset, + None, + false, + ) + .unwrap(); + let dep = + b.l.add_account("6500", "Depreciation", AccountType::Expense, None, false) + .unwrap(); + b.l.set_account_tax(dep, Some("supplies"), None).unwrap(); + (cost, accum, dep) +} + +#[test] +fn audit_detects_each_seeded_defect() { + let seeds = seeds(); + for row in CODES { + let (_, found) = seeds + .iter() + .find(|(c, _)| *c == row.code) + .unwrap_or_else(|| panic!("no seeding fixture for {}", row.code)); + let mine: Vec<&Finding> = found.iter().filter(|f| f.code == row.code).collect(); + assert!(!mine.is_empty(), "{} was not found", row.code); + assert!( + mine.iter().all(|f| f.severity == row.severity), + "{}", + row.code + ); + // Exactly that finding: nothing else but what the seed implies. + let others: Vec<_> = found.iter().filter(|f| f.code != row.code).collect(); + let implied = match row.code { + // A 40.00 reversal chain, or a draft's unbalanced side, leaves + // the neighbouring checks quiet. + "legacy_reversal_exempt" => vec!["legacy_reversal_exempt", "evidence_missing"], + _ => vec![], + }; + for o in others { + assert!( + implied.contains(&o.code), + "{}: also found {} ({})", + row.code, + o.code, + o.message + ); + } + } + for (c, _) in &seeds { + assert!(CODES.iter().any(|r| r.code == *c), "{c} is not in CODES"); + } +} + +#[test] +fn a_whole_book_gives_no_failure_or_warning() { + let mut b = books(); + post(&mut b.l, d(1, 5), b.cash, b.capital, "1000.00"); + post(&mut b.l, d(1, 6), b.cash, b.revenue, "20.00"); + let f = run(&b.l, &receipted(&b.l), d(12, 31)); + assert!( + f.iter().all(|f| f.severity == Severity::Info), + "{:?}", + codes(&f) + ); +} + +#[test] +fn findings_come_in_one_order() { + let mut b = books(); + post(&mut b.l, d(2, 1), b.expense, b.cash, "40.00"); + let e = b.l.create_entry(d(3, 3), "draft"); + b.l.add_line(e, b.expense, Side::Debit, usd("1.00")) + .unwrap(); + b.l.add_line(e, b.cash, Side::Credit, usd("1.00")).unwrap(); + let a = run(&b.l, &BTreeSet::new(), d(12, 31)); + let c = run(&b.l, &BTreeSet::new(), d(12, 31)); + assert_eq!(a, c); + assert_eq!(codes(&a), vec!["evidence_missing", "draft_open"]); + let _ = AssetRegister::unposted_depreciation; +} + +fn method(effort: Option<&str>, adversarial: Option, agents: Option) -> Method { + Method { + model: None, + reasoning_effort: effort.map(|e| Effort::parse(e).unwrap()), + adversarial, + verifier_agents: agents, + } +} + +/// D-9: effort at the bar counts for a receipt or a month; a year-level +/// attestation also needs the verifiers and the adversarial check; less +/// than the bar, or no claim at all, never counts. +#[test] +fn below_bar_not_counted() { + let bar = AttestationBar::default(); + assert_eq!(bar.describe(), "effort=high verifiers=2 adversarial=true"); + assert!(bar.counts(&method(Some("high"), None, None), false, false)); + assert!(bar.counts(&method(Some("max"), None, None), false, false)); + assert!(!bar.counts(&method(Some("medium"), None, None), false, false)); + assert!(!bar.counts(&method(None, Some(true), Some(3)), false, false)); + assert!(bar.counts(&method(Some("high"), Some(true), Some(2)), true, false)); + assert!(!bar.counts(&method(Some("high"), Some(true), Some(1)), true, false)); + assert!(!bar.counts(&method(Some("high"), Some(false), Some(2)), true, false)); + assert!(!bar.counts(&method(Some("high"), None, Some(2)), true, false)); + // A bar change changes what counts, earlier claims too (no + // versioning, D-9). + let lax = AttestationBar::parse("effort=low verifiers=1 adversarial=false").unwrap(); + assert!(lax.counts(&method(Some("low"), Some(false), Some(1)), true, false)); + assert!(AttestationBar::parse("effort=wild").is_err()); + assert!(AttestationBar::parse("verifiers=0").is_err()); + assert!(AttestationBar::parse("nonsense").is_err()); + assert_eq!( + AttestationBar::parse("adversarial=false, effort=xhigh") + .unwrap() + .describe(), + "effort=xhigh verifiers=2 adversarial=false" + ); +} + +/// A reading that disagrees with the books never counts, however hard the +/// checker says it thought. +#[test] +fn amount_mismatch_never_counts() { + let bar = AttestationBar::default(); + let top = method(Some("max"), Some(true), Some(9)); + assert!(bar.counts(&top, true, false)); + assert!(!bar.counts(&top, false, true)); + assert!(!bar.counts(&top, true, true)); +} diff --git a/crates/ledger-core/tests/bookspec.rs b/crates/ledger-core/tests/bookspec.rs index 624b82e..27ee83e 100644 --- a/crates/ledger-core/tests/bookspec.rs +++ b/crates/ledger-core/tests/bookspec.rs @@ -33,7 +33,7 @@ use std::borrow::Borrow; use std::collections::{BTreeMap, BTreeSet}; -use std::path::{Path, PathBuf}; +use std::path::Path; use chrono::{Datelike, NaiveDate}; use ledger_core::calendar::{PeriodDefaults, period_defaults}; @@ -214,8 +214,14 @@ fn store_refusal(e: &StoreError) -> String { } } -fn examples_dir() -> PathBuf { - Path::new(env!("CARGO_MANIFEST_DIR")).join("../../bookspec/examples") +/// The text of the bookspec example file `file`, carried by the pinned +/// `bookspec-checker` crate. +fn example_text(file: &str) -> &'static str { + bookspec_checker::examples() + .into_iter() + .find(|(name, _)| *name == file) + .unwrap_or_else(|| panic!("no bookspec example {file}")) + .1 } fn date(s: &str, what: &str) -> NaiveDate { @@ -3840,16 +3846,9 @@ fn run(ex: &Example) -> Vec<(String, Option<&'static str>)> { /// this proves the trait path, which is what another engine supplies. #[test] fn the_runner_reports_every_claimed_rule_green() { - let mut files: Vec = std::fs::read_dir(examples_dir()) - .expect("bookspec/examples") - .map(|e| e.unwrap().path()) - .filter(|p| p.extension().is_some_and(|x| x == "toml")) - .collect(); - files.sort(); let mut examples = Vec::new(); - for f in &files { - let text = std::fs::read_to_string(f).unwrap(); - examples.push(Example::from_toml(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display()))); + for (name, text) in bookspec_checker::examples() { + examples.push(Example::from_toml(text).unwrap_or_else(|e| panic!("{name}: {e}"))); } assert!(examples.len() >= 56, "the seed has at least 56 examples"); let report = bookspec_runner::run(&examples, bookspec_checker::rules(), Books::blank); @@ -3877,19 +3876,13 @@ fn the_runner_reports_every_claimed_rule_green() { #[test] fn every_bookspec_example_holds() { - let mut files: Vec = std::fs::read_dir(examples_dir()) - .expect("bookspec/examples") - .map(|e| e.unwrap().path()) - .filter(|p| p.extension().is_some_and(|x| x == "toml")) - .collect(); - files.sort(); + let files = bookspec_checker::examples(); let mut ids = BTreeSet::new(); let mut checked: BTreeMap = BTreeMap::new(); - for f in &files { - let text = std::fs::read_to_string(f).unwrap(); - let ex = Example::from_toml(&text).unwrap_or_else(|e| panic!("{}: {e}", f.display())); - let stem = f.file_stem().unwrap().to_string_lossy(); - assert_eq!(ex.id, stem, "{}: the file is named for its id", f.display()); + for (name, text) in &files { + let ex = Example::from_toml(text).unwrap_or_else(|e| panic!("{name}: {e}")); + let stem = name.strip_suffix(".toml").unwrap(); + assert_eq!(ex.id, stem, "{name}: the file is named for its id"); assert!(ids.insert(ex.id.clone()), "{}: id used twice", ex.id); assert!( !ex.title.is_empty() && !ex.rule.is_empty() && !ex.tags.is_empty(), @@ -4038,19 +4031,16 @@ fn reverses_zero_is_refused() { /// ignored: an example can't silently run with other roles than it says. #[test] fn an_unknown_key_is_refused() { - let text = std::fs::read_to_string( - examples_dir().join("cash-flow.reversed-property-contribution.toml"), - ) - .unwrap() - .replace("cash_accounts = ", "cash_account = "); + let text = example_text("cash-flow.reversed-property-contribution.toml") + .replace("cash_accounts = ", "cash_account = "); assert!(toml::from_str::(&text).is_err()); } /// The example `file`'s text, changed by `edit`, loaded and its /// expectations checked as `every_bookspec_example_holds` checks them. fn run_edited(file: &str, edit: impl Fn(String) -> String) { - let text = edit(std::fs::read_to_string(examples_dir().join(file)).unwrap()); - let before = std::fs::read_to_string(examples_dir().join(file)).unwrap(); + let before = example_text(file).to_string(); + let text = edit(before.clone()); assert_ne!(text, before, "the edit changed nothing"); let ex: Example = toml::from_str(&text).unwrap(); run(&ex); @@ -4587,3 +4577,71 @@ fn an_invoice_lines_project_is_what_the_project_counts() { }, ); } + +/// Whitespace and typographic quotes made plain, so a pin copied from a +/// wrapped or curly-quoted source still matches. +fn squash(s: &str) -> String { + let plain = s + .replace(['\u{2019}', '\u{2018}'], "'") + .replace(['\u{201c}', '\u{201d}'], "\""); + plain.split_whitespace().collect::>().join(" ") +} + +/// Every vendored citation in the specification names a file in +/// `docs/spec/sources`, and each quoted pin is verbatim in it. (The +/// specification crate checks the citations' form; the vendored texts live +/// here.) +#[test] +fn every_vendored_pin_is_verbatim() { + let sources = Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/sources"); + let mut vendored = 0; + let mut bad = Vec::new(); + for (name, text) in bookspec_checker::spec_files() { + for line in text.lines() { + let Some(rest) = line.strip_prefix(" - vendored `") else { + continue; + }; + let (file, rest) = rest + .split_once("`:") + .unwrap_or_else(|| panic!("{name}: {line}")); + let path = sources.join(file.strip_prefix("sources/").unwrap_or(file)); + let hay = squash( + &std::fs::read_to_string(&path) + .unwrap_or_else(|_| panic!("{name}: {file} is not vendored")), + ); + for q in rest.split('"').skip(1).step_by(2) { + if !hay.contains(&squash(q)) { + bad.push(format!("{name}: {q:?} is not in {file}")); + } + } + vendored += 1; + } + } + assert!(bad.is_empty(), "pins not found:\n{}", bad.join("\n")); + assert!(vendored >= 30, "vendored citations: {vendored}"); +} + +/// `docs/spec/rules.md` carries every stated rule's id under its heading, +/// and no longer calls a choice a decision (P13 step 3). +#[test] +fn rules_md_carries_every_id() { + let text = std::fs::read_to_string( + Path::new(env!("CARGO_MANIFEST_DIR")).join("../../docs/spec/rules.md"), + ) + .expect("docs/spec/rules.md"); + for rule in bookspec_checker::rules() + .iter() + .filter(|r| r.status.is_some()) + { + assert!( + text.contains(&format!("**bookspec:** `{}`", rule.id)), + "B-{} carries {}", + rule.b, + rule.id + ); + } + assert!( + !text.contains("DECISION"), + "rules.md speaks bookspec's CHOICE" + ); +} diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 42566f8..b75865d 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -449,6 +449,55 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { format!("Draft #{id} discarded: {}", s(p, "reason")), None, ), + // Audits and attestations (plan P8): claims about the books, not + // figures. + "audit.run" => ev( + icon::MAGNIFYING_GLASS, + format!( + "Audit {} through {}", + s(p, "scope").replace('_', " "), + s(p, "through") + ), + None, + ), + "receipt.attested" => ev( + icon::CHECK_CIRCLE, + if p.get("disagrees").and_then(|v| v.as_bool()) == Some(true) { + format!( + "{} read {} against entry #{}: it disagrees", + s(p, "by"), + attachment_name(b, att), + p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0) + ) + } else { + format!( + "{} attested {} for entry #{}", + s(p, "by"), + attachment_name(b, att), + p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0) + ) + }, + Some(Route::Document(att)), + ), + "entry.reviewed" => ev( + icon::CHECK_CIRCLE, + format!( + "{} reviewed entry #{}", + s(p, "by"), + p.get("entry_id").and_then(|v| v.as_u64()).unwrap_or(0) + ), + p.get("entry_id").and_then(|v| v.as_u64()).map(Route::Entry), + ), + "period.attested" => ev( + icon::CHECK_CIRCLE, + format!( + "{} attested period #{} on audit #{}", + s(p, "by"), + p.get("period_id").and_then(|v| v.as_u64()).unwrap_or(0), + p.get("audit_run_id").and_then(|v| v.as_u64()).unwrap_or(0) + ), + None, + ), "entry.settles" => { let settles: Vec = p .get("settles") @@ -1172,6 +1221,26 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { format!("Open item #{id} resolved: {}", s(p, "resolution")), Some(Route::Company), ), + "convention.source_revised" => ev( + icon::SCROLL, + format!( + "Source of convention {} corrected to {}: {}", + s(p, "id"), + s(&p["source"], "decided_by"), + s(p, "reason") + ), + Some(Route::Company), + ), + "decision.source_revised" => ev( + icon::GAVEL, + format!("Source of decision #{id} corrected: {}", s(p, "reason")), + Some(Route::Company), + ), + "open_item.source_revised" => ev( + icon::CHECK_CIRCLE, + format!("Source of open item #{id} corrected: {}", s(p, "reason")), + Some(Route::Company), + ), "entry.approved" => ev( icon::SEAL_CHECK, format!( diff --git a/crates/ledger-gui/tests/newer_journal.rs b/crates/ledger-gui/tests/newer_journal.rs index 5b8f95d..905b335 100644 --- a/crates/ledger-gui/tests/newer_journal.rs +++ b/crates/ledger-gui/tests/newer_journal.rs @@ -88,7 +88,11 @@ fn says_update(text: &str) { #[test] fn the_viewer_says_update_for_newer_books_and_corrupt_for_damaged_ones() { for (tag, line_type, payload) in [ - ("type", "period.attested", json!({"key": "company.name"})), + ( + "type", + "ledger.not_yet_written", + json!({"key": "company.name"}), + ), ( "format", "journal.format_set", diff --git a/crates/ledger-host/src/host.rs b/crates/ledger-host/src/host.rs index c3a3706..11c1f65 100644 --- a/crates/ledger-host/src/host.rs +++ b/crates/ledger-host/src/host.rs @@ -203,19 +203,39 @@ fn children_executable() -> PathBuf { } } -/// A program file's identity: what changes when the file is replaced. +/// A program file's identity: device and inode, size and last write. A +/// replaced file differs in the inode, and a rewritten one in the rest. +#[cfg(unix)] +type Identity = (u64, u64, u64, i64, i64); +/// Windows: size and last write (nanoseconds). +#[cfg(windows)] type Identity = (u64, u64); +#[cfg(unix)] +fn identity_from(meta: &std::fs::Metadata) -> Identity { + ( + meta.dev(), + meta.ino(), + meta.len(), + meta.mtime(), + meta.mtime_nsec(), + ) +} + /// The running executable's file identity, and its file's now: they -/// differ once the file was replaced (INVARIANT 7). +/// differ once the file was replaced (INVARIANT 7). An AppImage is its +/// image file (`APPIMAGE`), not the program inside the mount. #[cfg(unix)] fn running_identity() -> Option { + if let Some(image) = crate::start::own_appimage(std::env::var_os("APPIMAGE").as_deref()) { + return Some(identity_from(&std::fs::metadata(image).ok()?)); + } let running = if cfg!(target_os = "linux") { std::fs::metadata("/proc/self/exe").ok()? } else { std::fs::metadata(std::env::current_exe().ok()?).ok()? }; - Some((running.dev(), running.ino())) + Some(identity_from(&running)) } /// Windows can't replace a program that runs, so an update puts the new @@ -232,12 +252,12 @@ fn file_identity() -> Option { identity_of(&crate::start::this_executable().ok()?) } -/// The identity (device, inode) of the program at `path`: a file someone -/// may run; else None. +/// The identity of the program at `path`: a file someone may run; else +/// None. #[cfg(unix)] fn identity_of(path: &Path) -> Option { let meta = std::fs::metadata(path).ok()?; - (meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| (meta.dev(), meta.ino())) + (meta.is_file() && meta.permissions().mode() & 0o111 != 0).then(|| identity_from(&meta)) } /// The identity (size, last write in nanoseconds) of the program at @@ -521,6 +541,13 @@ impl Host { let started_as = running_identity(); let mut exe_poll = tokio::time::interval(self.knobs.exe_poll); exe_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + // The daily look for a newer release (P11a step 4): the work is + // a thread of its own, one at a time, and only stages a file. + let mut update_poll = + tokio::time::interval((self.knobs.update_check / 4).max(Duration::from_millis(10))); + update_poll.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Delay); + #[cfg(unix)] + let looking = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)); let mut idle_at = Some(Instant::now() + self.knobs.close_after); let server_start = tokio::time::sleep(self.knobs.server_delay); tokio::pin!(server_start); @@ -678,6 +705,25 @@ impl Host { } } } + _ = update_poll.tick(), if matches!(self.phase, Phase::Open) => { + // Staging an update replaces the program's file, which + // Windows forbids while it runs: P11a step 5. + #[cfg(unix)] + { + use std::sync::atomic::Ordering; + if !looking.swap(true, Ordering::SeqCst) { + let (root, every, looking) = (self.root.clone(), self.knobs.update_check, looking.clone()); + std::thread::spawn(move || { + match crate::update::stage_if_due(&root, crate::update::program_version(), every) { + Ok(Some(version)) => eprintln!("wanderbooks host: version {version} is downloaded and checked; it starts the next time the books open, or at `wanderbooks update`"), + Ok(None) => {} + Err(why) => eprintln!("wanderbooks host: the look for a newer version failed: {why}"), + } + looking.store(false, Ordering::SeqCst); + }); + } + } + } _ = exe_poll.tick(), if matches!(self.phase, Phase::Open) => { if started_as.is_some() && file_identity().is_some_and(|now| Some(now) != started_as) { eprintln!("wanderbooks host: a new version is installed: restarting"); diff --git a/crates/ledger-host/src/knobs.rs b/crates/ledger-host/src/knobs.rs index 04f24ad..bd7dd0c 100644 --- a/crates/ledger-host/src/knobs.rs +++ b/crates/ledger-host/src/knobs.rs @@ -33,6 +33,9 @@ pub struct Knobs { /// How long after books open the server first looks for a drill due /// (60 s), passed to the server as `--drill-first`. pub drill_first: Duration, + /// How long between the host's looks for a newer release (plan + /// P11a step 4: a day). + pub update_check: Duration, } impl Default for Knobs { @@ -47,6 +50,7 @@ impl Default for Knobs { open_wait: Duration::from_secs(120), backup_quiet: Duration::from_secs(30), drill_first: Duration::from_secs(60), + update_check: Duration::from_secs(24 * 60 * 60), } } } @@ -76,6 +80,7 @@ impl Knobs { "open_wait" => k.open_wait = d, "backup_quiet" => k.backup_quiet = d, "drill_first" => k.drill_first = d, + "update_check" => k.update_check = d, other => return Err(format!("{FLAG}: there is no knob {other:?}")), } } @@ -97,6 +102,7 @@ impl Knobs { ("open_wait", self.open_wait, d.open_wait), ("backup_quiet", self.backup_quiet, d.backup_quiet), ("drill_first", self.drill_first, d.drill_first), + ("update_check", self.update_check, d.update_check), ] { if now != default { parts.push(format!("{name}={}", now.as_millis())); @@ -130,6 +136,7 @@ mod tests { assert_eq!(k.open_wait, Duration::from_secs(120)); assert_eq!(k.backup_quiet, Duration::from_secs(30)); assert_eq!(k.drill_first, Duration::from_secs(60)); + assert_eq!(k.update_check, Duration::from_secs(86_400)); assert_eq!(k.to_arg(), None); assert!(k.args().is_empty()); assert_eq!(Knobs::parse("").unwrap(), k); @@ -138,7 +145,7 @@ mod tests { #[test] fn knobs_pass_on_exactly() { let k = Knobs::parse( - "close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50", + "close_after=500,backoff=20,server_delay=3000,init_wait=100,queue_wait=900,exe_poll=50,open_wait=70,backup_quiet=40,drill_first=50,update_check=60", ) .unwrap(); assert_eq!(k.close_after, Duration::from_millis(500)); @@ -150,6 +157,7 @@ mod tests { assert_eq!(k.open_wait, Duration::from_millis(70)); assert_eq!(k.backup_quiet, Duration::from_millis(40)); assert_eq!(k.drill_first, Duration::from_millis(50)); + assert_eq!(k.update_check, Duration::from_millis(60)); let args = k.args(); assert_eq!(args[0], FLAG); assert_eq!(Knobs::parse(&args[1]).unwrap(), k); diff --git a/crates/ledger-host/src/start.rs b/crates/ledger-host/src/start.rs index 56bb78b..931fc2e 100644 --- a/crates/ledger-host/src/start.rs +++ b/crates/ledger-host/src/start.rs @@ -13,7 +13,8 @@ //! it names a socket this account owns (the owner's ssh agent, so the //! server's backups reach a private git repository, D-30). Nothing //! else passes (a scope's own identifiers, such as INVOCATION_ID, are -//! systemd's). +//! systemd's). One more: APPIMAGE, only when it names an absolute +//! file, so the host knows its own AppImage (below). //! 2. Its standard input and output are null, its errors go to //! run/host.log (0600); it starts a new session with umask 077. Every //! other descriptor is closed (std opens them close-on-exec, and the @@ -23,7 +24,11 @@ //! scope couldn't be made (systemd-run failed without the host ever //! writing its first line), or there is no such scope, directly. A //! host that ran and stopped is never started a second time. -//! 4. It runs this executable's file as it is on disk now. +//! 4. It runs this executable's file as it is on disk now; for an +//! AppImage that is the image file (APPIMAGE), not the program inside +//! its mount, which goes away with the image it came from. The host +//! reads that path about itself and never tells it to an agent (I-2), +//! so a replaced image restarts the books like any replaced program. //! 5. The opener doesn't wait for it, and reaps it when it ends. //! ``` @@ -65,6 +70,9 @@ const LOG_LIMIT: u64 = 8 * 1024 * 1024; /// replaced file reads as "… (deleted)"; the path, not the old file, is /// what a new host runs. pub fn this_executable() -> Result { + if let Some(image) = own_appimage(std::env::var_os("APPIMAGE").as_deref()) { + return Ok(PathBuf::from(image)); + } let exe = std::env::current_exe().map_err(|e| format!("can't find this program: {e}"))?; let text = exe.to_string_lossy(); if let Some(path) = text.strip_suffix(" (deleted)") @@ -75,6 +83,13 @@ pub fn this_executable() -> Result { Ok(exe) } +/// The AppImage this process runs from: `APPIMAGE` when it is an +/// absolute path to a file. +pub fn own_appimage(value: Option<&std::ffi::OsStr>) -> Option { + let path = Path::new(value?); + (path.is_absolute() && path.is_file()).then(|| path.as_os_str().to_os_string()) +} + /// This account's user ID. #[cfg(unix)] fn own_uid() -> u32 { @@ -151,7 +166,11 @@ fn own_agent(named: Option<&std::ffi::OsStr>) -> Option { #[cfg(unix)] /// The host's environment (INVARIANT 1), given the opener's /// `SSH_AUTH_SOCK`. -fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, std::ffi::OsString)> { +fn host_env( + home: &Path, + ssh_agent: Option<&std::ffi::OsStr>, + appimage: Option<&std::ffi::OsStr>, +) -> Vec<(String, std::ffi::OsString)> { let mut env = vec![ ("PATH".to_string(), PATH.into()), ("HOME".to_string(), home.as_os_str().to_os_string()), @@ -165,6 +184,9 @@ fn host_env(home: &Path, ssh_agent: Option<&std::ffi::OsStr>) -> Vec<(String, st if let Some(agent) = own_agent(ssh_agent) { env.push(("SSH_AUTH_SOCK".into(), agent)); } + if let Some(image) = own_appimage(appimage) { + env.push(("APPIMAGE".into(), image)); + } env } @@ -209,6 +231,10 @@ pub fn start_host( let found = ledger_store::root::Root::find(root).map_err(|e| e.to_string())?; let run = found.run_dir().map_err(|e| e.to_string())?; let home = ledger_store::root::account_home().map_err(|e| e.to_string())?; + // A staged update is applied here and nowhere while books are open: + // no host runs now, so no call is cut off (update.rs INVARIANT 9). A + // failure leaves the program as it was. + let _ = crate::update::apply_staged(&found, crate::update::program_version()); let exe = this_executable()?; let mut args: Vec = vec!["host".into()]; // An explicit root goes on as found (absolute): the host starts in /. @@ -253,6 +279,7 @@ pub fn start_host( .envs(host_env( &home, std::env::var_os("SSH_AUTH_SOCK").as_deref(), + std::env::var_os("APPIMAGE").as_deref(), )) .current_dir("/") .stdin(Stdio::null()) @@ -518,7 +545,7 @@ mod tests { #[test] fn the_host_environment_carries_nothing_else() { - let env = host_env(Path::new("/home/someone"), None); + let env = host_env(Path::new("/home/someone"), None, None); let names: Vec<&str> = env.iter().map(|(n, _)| n.as_str()).collect(); assert_eq!(&names[..2], ["PATH", "HOME"]); for n in &names[2..] { @@ -526,7 +553,8 @@ mod tests { [ "XDG_RUNTIME_DIR", "DBUS_SESSION_BUS_ADDRESS", - "SSH_AUTH_SOCK" + "SSH_AUTH_SOCK", + "APPIMAGE" ] .contains(n), "{n}" @@ -536,6 +564,22 @@ mod tests { assert_eq!(env[1].1, "/home/someone"); } + #[test] + fn only_an_absolute_file_passes_as_the_appimage() { + let dir = tempfile::tempdir().unwrap(); + let image = dir.path().join("W.AppImage"); + std::fs::write(&image, b"x").unwrap(); + let home = Path::new("/home/someone"); + let has = |v: Option<&std::ffi::OsStr>| { + host_env(home, None, v).iter().any(|(n, _)| n == "APPIMAGE") + }; + assert!(has(Some(image.as_os_str()))); + assert!(!has(None)); + assert!(!has(Some("W.AppImage".as_ref())), "relative"); + assert!(!has(Some(dir.path().as_os_str())), "a folder"); + assert!(!has(Some(dir.path().join("gone").as_os_str()))); + } + #[test] fn only_an_own_ssh_agent_socket_passes() { let dir = tempfile::tempdir().unwrap(); @@ -546,7 +590,7 @@ mod tests { Some(sock.clone().into_os_string()) ); // It reaches the host's environment, once. - let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str())); + let env = host_env(Path::new("/home/someone"), Some(sock.as_os_str()), None); let agents: Vec<_> = env.iter().filter(|(n, _)| n == "SSH_AUTH_SOCK").collect(); assert_eq!(agents.len(), 1); assert_eq!(agents[0].1, sock.as_os_str()); @@ -555,7 +599,7 @@ mod tests { let link = dir.path().join("link.sock"); std::os::unix::fs::symlink(&sock, &link).unwrap(); assert_eq!(own_agent(Some(link.as_os_str())), None); - let env = host_env(Path::new("/home/someone"), Some(link.as_os_str())); + let env = host_env(Path::new("/home/someone"), Some(link.as_os_str()), None); assert!(env.iter().all(|(n, _)| n != "SSH_AUTH_SOCK")); // Named relative to here: not taken, even where it leads to one. let here = std::env::current_dir().unwrap(); diff --git a/crates/ledger-host/src/update.rs b/crates/ledger-host/src/update.rs index b1f2266..6d2bf37 100644 --- a/crates/ledger-host/src/update.rs +++ b/crates/ledger-host/src/update.rs @@ -36,7 +36,17 @@ //! newer one: the only way back is a restore, plan 3.9). //! 7. A package-managed install is never touched. //! 8. A program with no release key compiled in updates from nothing -//! but a local (`file://`) feed whose key the owner names. +//! but a local (`file://`) feed whose key the owner names: on the +//! command line, or in `/updates/feed` (the feed's address on +//! the first line, the key file's path on the second). +//! 9. The host looks for a newer release at most once a day while the +//! books are open, and only downloads and verifies it (the same +//! checks as INVARIANT 1) into `/updates/staged/`. It never +//! swaps: a staged update is applied only (a) by `wanderbooks +//! update`, the owner's say-so, or (b) by an opener just before it +//! starts a host, when no host is running, so no call is ever cut +//! off. A staged file is checked again against the key before it is +//! applied; one that fails, or does not start, is dropped. //! SCENARIOS: //! 1. IF the feed offers a higher version, signed by our key: SHOULD //! replace the program and keep the old one. @@ -44,6 +54,10 @@ //! is by another key or missing: SHOULD refuse, changing nothing. //! 3. IF the new program can't start: SHOULD put the old one back. //! 4. IF a book consented to a newer format: SHOULD refuse to roll back. +//! 5. IF the host's daily look finds a signed newer release: SHOULD +//! stage it and change nothing running; the next host start runs it. +//! 6. IF the daily look finds a bad signature or checksum: SHOULD stage +//! nothing. //! ``` use serde::Deserialize; @@ -72,6 +86,26 @@ const UPDATES: &str = "updates"; const PREVIOUS: &str = "previous"; const PROGRAM: &str = "wanderbooks"; const META: &str = "meta.json"; +const STAGED: &str = "staged"; +const FEED_FILE: &str = "feed"; +const CHECKED: &str = "checked"; + +/// The version of the program being run, set by its `main` (this crate's +/// own is the fallback: the packages rewrite only the program's). +static PROGRAM_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new(); + +/// Say which version this program is. +pub fn set_program_version(version: &'static str) { + let _ = PROGRAM_VERSION.set(version); +} + +/// This program's version. +pub fn program_version() -> &'static str { + PROGRAM_VERSION + .get() + .copied() + .unwrap_or(env!("CARGO_PKG_VERSION")) +} const MAX_MANIFEST: usize = 1 << 20; const MAX_PROGRAM: u64 = 1 << 30; @@ -244,11 +278,18 @@ pub fn update( Install::SelfUpdating(target) => target, }; let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; - let feed = flags.feed.as_deref().unwrap_or(DEFAULT_FEED); - let key = trusted_key(feed, flags.key_file.as_deref())?; - let manifest_bytes = fetch(feed, MAX_MANIFEST as u64)?; - let manifest: Manifest = serde_json::from_slice(&manifest_bytes) - .map_err(|e| format!("the release manifest isn't one we can read: {e}"))?; + let found_root = ledger_store::root::Root::find(flags.root.as_deref()).ok(); + let local = found_root + .as_ref() + .and_then(|r| local_feed(&r.path().join(UPDATES))); + let (feed, key_file) = match (&flags.feed, &local) { + (Some(feed), _) => (feed.clone(), flags.key_file.clone()), + (None, Some((feed, key))) => (feed.clone(), Some(key.clone())), + (None, None) => (DEFAULT_FEED.to_string(), flags.key_file.clone()), + }; + let feed = feed.as_str(); + let key = trusted_key(feed, key_file.as_deref())?; + let manifest = fetch_manifest(feed)?; let offered = parse_version(&manifest.version).ok_or_else(|| { format!( "the manifest's version {:?} isn't one of ours", @@ -272,9 +313,14 @@ pub fn update( .get(&platform) .ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?; if flags.check { + let ready = found_root + .as_ref() + .and_then(|r| staged_bytes(&r.path().join(UPDATES), &manifest.version)) + .is_some(); println!( - "wanderbooks {} is available (this is {current}). Run `{}` to install it.", + "wanderbooks {} is available{} (this is {current}). Run `{}` to install it.", manifest.version, + if ready { ", already downloaded" } else { "" }, prog.trim_end_matches(" --check") ); if !manifest.notes.trim().is_empty() { @@ -284,9 +330,14 @@ pub fn update( } let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?; let updates = updates_dir(&root)?; - let _lock = take_lock(&updates)?; + let _lock = take_lock_within(&updates, Duration::from_secs(10))?; - let bytes = fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?; + // A copy the host already downloaded and verified is used as it is + // found (and verified again below); else the download. + let bytes = match staged_bytes(&updates, &manifest.version) { + Some(bytes) => bytes, + None => fetch(&resolve(feed, &entry.url)?, MAX_PROGRAM)?, + }; verify(&bytes, entry, &key)?; // Verified: from here the files are ours to write. Staged, then the @@ -313,6 +364,7 @@ pub fn update( .into()); } let _ = std::fs::remove_dir_all(&staged_dir); + let _ = std::fs::remove_dir_all(updates.join(STAGED)); println!( "Updated wanderbooks from {current} to {}.", manifest.version @@ -323,6 +375,182 @@ pub fn update( Ok(()) } +/// The manifest at `feed`. +fn fetch_manifest(feed: &str) -> Result { + let bytes = fetch(feed, MAX_MANIFEST as u64)?; + serde_json::from_slice(&bytes) + .map_err(|e| format!("the release manifest isn't one we can read: {e}").into()) +} + +/// The feed the owner named for this root (INVARIANT 8): a `file://` +/// address, and the file of the key to check it against. +fn local_feed(updates: &Path) -> Option<(String, PathBuf)> { + let text = std::fs::read_to_string(updates.join(FEED_FILE)).ok()?; + let mut lines = text.lines().map(str::trim).filter(|l| !l.is_empty()); + let feed = lines.next()?; + let key = lines.next()?; + feed.starts_with("file://") + .then(|| (feed.to_string(), PathBuf::from(key))) +} + +/// The staged program, if it is the one for `version` and whole. +fn staged_bytes(updates: &Path, version: &str) -> Option> { + let dir = updates.join(STAGED); + let meta: serde_json::Value = + serde_json::from_slice(&std::fs::read(dir.join(META)).ok()?).ok()?; + if meta["version"].as_str() != Some(version) { + return None; + } + let bytes = std::fs::read(dir.join(PROGRAM)).ok()?; + sha256_hex(&bytes) + .eq_ignore_ascii_case(meta["sha256"].as_str()?) + .then_some(bytes) +} + +/// The version staged, if any. +pub fn staged_version(root: &ledger_store::root::Root) -> Option { + let meta = std::fs::read(root.path().join(UPDATES).join(STAGED).join(META)).ok()?; + let meta: serde_json::Value = serde_json::from_slice(&meta).ok()?; + meta["version"].as_str().map(str::to_string) +} + +/// INVARIANT 9, the host's daily look: stage a newer release if there is +/// one and `every` has passed since the last look. Returns the version +/// newly staged. Changes nothing that runs. +pub fn stage_if_due( + root: &ledger_store::root::Root, + current: &str, + every: Duration, +) -> Result, Failure> { + let Install::SelfUpdating(_) = this_install()? else { + return Ok(None); + }; + let updates = updates_dir(root)?; + let checked = updates.join(CHECKED); + if std::fs::metadata(&checked) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| t.elapsed().ok()) + .is_some_and(|age| age < every) + { + return Ok(None); + } + // Marked first: a failed look waits a day too. + std::fs::write(&checked, b"").map_err(|e| format!("can't note the look: {}", e.kind()))?; + let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; + let (feed, key_file) = match local_feed(&updates) { + Some((feed, key)) => (feed, Some(key)), + None => (DEFAULT_FEED.to_string(), None), + }; + let key = trusted_key(&feed, key_file.as_deref())?; + let Ok(_lock) = take_lock(&updates) else { + return Ok(None); + }; + let manifest = fetch_manifest(&feed)?; + let offered = + parse_version(&manifest.version).ok_or("the manifest's version isn't one of ours")?; + if offered <= current_v { + let _ = std::fs::remove_dir_all(updates.join(STAGED)); + return Ok(None); + } + if staged_bytes(&updates, &manifest.version).is_some() { + return Ok(None); + } + let platform = format!("{}-{}", std::env::consts::OS, std::env::consts::ARCH); + let entry = manifest + .platforms + .get(&platform) + .ok_or_else(|| format!("release {} has no build for {platform}", manifest.version))?; + let bytes = fetch(&resolve(&feed, &entry.url)?, MAX_PROGRAM)?; + verify(&bytes, entry, &key)?; + let dir = updates.join(STAGED); + let _ = std::fs::remove_dir_all(&dir); + private_dir(&dir)?; + write_program(&dir.join(PROGRAM), &bytes)?; + let meta = serde_json::json!({ + "version": manifest.version, + "sha256": sha256_hex(&bytes), + "signature": entry.signature, + "notes": manifest.notes, + }); + std::fs::write(dir.join(META), meta.to_string()) + .map_err(|e| format!("can't stage the update: {}", e.kind()))?; + Ok(Some(manifest.version)) +} + +/// INVARIANT 9, applying: swap in the staged program, if there is one +/// for a newer version. The caller guarantees no host is running (it is +/// about to start one) or is the owner running `update`. Returns the +/// version applied. +pub fn apply_staged( + root: &ledger_store::root::Root, + current: &str, +) -> Result, Failure> { + let updates = root.path().join(UPDATES); + let dir = updates.join(STAGED); + if !dir.join(META).is_file() { + return Ok(None); + } + let Install::SelfUpdating(target) = this_install()? else { + return Ok(None); + }; + let Ok(_lock) = take_lock(&updates) else { + return Ok(None); + }; + let meta: serde_json::Value = std::fs::read(dir.join(META)) + .ok() + .and_then(|b| serde_json::from_slice(&b).ok()) + .unwrap_or_default(); + let drop_staged = || { + let _ = std::fs::remove_dir_all(&dir); + }; + let (Some(version), Some(sha256), Some(signature)) = ( + meta["version"].as_str(), + meta["sha256"].as_str(), + meta["signature"].as_str(), + ) else { + drop_staged(); + return Ok(None); + }; + let current_v = parse_version(current).ok_or("this program's version isn't one of ours")?; + if parse_version(version).is_none_or(|v| v <= current_v) { + drop_staged(); + return Ok(None); + } + let local = local_feed(&updates); + let key = trusted_key( + local.as_ref().map_or(DEFAULT_FEED, |(f, _)| f.as_str()), + local.as_ref().map(|(_, k)| k.as_path()), + )?; + let Some(bytes) = staged_bytes(&updates, version) else { + drop_staged(); + return Err("the staged update is damaged: dropped".into()); + }; + let entry = Entry { + url: String::new(), + sha256: sha256.to_string(), + signature: signature.to_string(), + }; + if let Err(e) = verify(&bytes, &entry, &key) { + drop_staged(); + return Err(e); + } + drop(bytes); + keep_previous(&updates, &target, current)?; + swap_in(&target, &dir.join(PROGRAM))?; + if let Err(why) = healthy(&target) { + let back = swap_in(&target, &updates.join(PREVIOUS).join(PROGRAM)); + drop_staged(); + return Err(match back { + Ok(()) => format!("the staged version {version} didn't start ({why}); the old one is back"), + Err(e) => format!("the staged version {version} didn't start ({why}) and the old one couldn't be put back ({e})"), + } + .into()); + } + drop_staged(); + Ok(Some(version.to_string())) +} + /// `wanderbooks rollback`: go back to the version the last update /// replaced. pub fn rollback(prog: &str, args: impl IntoIterator) -> Result<(), Failure> { @@ -337,7 +565,7 @@ pub fn rollback(prog: &str, args: impl IntoIterator) -> Result<() }; let root = ledger_store::root::Root::find(flags.root.as_deref()).map_err(|e| e.to_string())?; let updates = updates_dir(&root)?; - let _lock = take_lock(&updates)?; + let _lock = take_lock_within(&updates, Duration::from_secs(10))?; let previous = updates.join(PREVIOUS); let meta: serde_json::Value = std::fs::read(previous.join(META)) .ok() @@ -562,6 +790,24 @@ fn private_dir(dir: &Path) -> Result<(), Failure> { /// One update or rollback at a time. fn take_lock(updates: &Path) -> Result { + take_lock_within(updates, Duration::ZERO) +} + +/// The same, waiting up to `wait` for another to finish (the host's +/// look at the feed is a short holder). +fn take_lock_within(updates: &Path, wait: Duration) -> Result { + let deadline = Instant::now() + wait; + loop { + match take_lock_once(updates) { + Err(e) if Instant::now() < deadline && e.to_string().contains("running") => { + std::thread::sleep(Duration::from_millis(50)); + } + other => return other, + } + } +} + +fn take_lock_once(updates: &Path) -> Result { let file = std::fs::OpenOptions::new() .create(true) .append(true) diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index c71333d..b382d93 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -25,6 +25,7 @@ pub mod labels; pub mod pdf; pub mod report; pub mod templates; +pub mod workpaper; pub mod xlsx; pub use asset_register::{asset_register_pdf, asset_register_xlsx}; @@ -43,4 +44,5 @@ pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf}; pub use pdf::{PdfError, statement_pdf}; pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount}; pub use templates::{ChartTemplate, EntityType, chart_template}; +pub use workpaper::{Workpaper, WorkpaperCheck, WorkpaperFinding, workpaper_pdf}; pub use xlsx::{XlsxReport, report_to_xlsx}; diff --git a/crates/ledger-import/src/workpaper.rs b/crates/ledger-import/src/workpaper.rs new file mode 100644 index 0000000..ea472ac --- /dev/null +++ b/crates/ledger-import/src/workpaper.rs @@ -0,0 +1,145 @@ +//! The audit workpaper as a PDF (plan P8; rules.md B-45). +//! +//! ```text +//! GOAL: one document a CPA or the owner can read that says what was +//! checked, through which date and state of the books, and every +//! finding; the same audit gives the same bytes. +//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit +//! trail); CONVENTION (the statements' type and page). +//! INVARIANTS: +//! 1. Deterministic bytes: no clock, no random id; every text is data, +//! never markup. +//! 2. The findings appear in the order given, each with severity, code, +//! subject and message; the checks table lists every check the +//! programme runs, found or not. +//! ``` + +use crate::pdf::PdfError; +use serde::Serialize; +use std::sync::OnceLock; +use typst::foundations::{Dict, IntoValue}; +use typst_as_lib::{TypstEngine, TypstTemplateMainFile}; + +/// One finding as the page shows it. +#[derive(Clone, Debug, Serialize)] +pub struct WorkpaperFinding { + /// failure, warning or info. + pub severity: String, + /// The finding code. + pub code: String, + /// What it is about, in words. + pub subject: String, + /// What was found. + pub message: String, +} + +/// One check the programme runs. +#[derive(Clone, Debug, Serialize)] +pub struct WorkpaperCheck { + /// The finding code. + pub code: String, + /// failure, warning or info. + pub severity: String, + /// When it is reported. + pub predicate: String, +} + +/// Everything the page shows. +#[derive(Clone, Debug, Serialize)] +pub struct Workpaper { + /// The company's name. + pub company: String, + /// The page's title. + pub title: String, + /// The audit's scope. + pub scope: String, + /// The date it covers. + pub through: String, + /// The books' figure head. + pub through_seq: u64, + /// The programme's version. + pub program_version: String, + /// The trial balance's hash. + pub tb_hash: String, + /// The findings' hash. + pub findings_sha256: String, + /// One line: how many of each severity. + pub summary: String, + /// The findings. + pub findings: Vec, + /// The checks. + pub checks: Vec, +} + +static TEMPLATE: &str = include_str!("../templates/workpaper.typ"); + +fn engine() -> &'static TypstEngine { + static ENGINE: OnceLock> = OnceLock::new(); + ENGINE.get_or_init(|| { + TypstEngine::builder() + .main_file(TEMPLATE) + .fonts(crate::fonts::typst_fonts()) + .build() + }) +} + +/// Render the workpaper as PDF bytes. +pub fn workpaper_pdf(w: &Workpaper) -> Result, PdfError> { + let data = serde_json::to_string(w).map_err(|e| PdfError::Compile(e.to_string()))?; + let mut inputs = Dict::new(); + inputs.insert("data".into(), data.into_value()); + let doc = engine() + .compile_with_input(inputs) + .output + .map_err(|e| PdfError::Compile(format!("{e:?}")))?; + typst_pdf::pdf(&doc, &typst_pdf::PdfOptions::default()) + .map_err(|e| PdfError::Save(format!("{e:?}"))) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Workpaper { + Workpaper { + company: "Wanderland Labs LLC".into(), + title: "Audit workpaper".into(), + scope: "month".into(), + through: "2026-10-31".into(), + through_seq: 412, + program_version: "audit-0.1.0-1".into(), + tb_hash: "ab".repeat(32), + findings_sha256: "cd".repeat(32), + summary: "1 failure, 0 warnings".into(), + findings: vec![WorkpaperFinding { + severity: "failure".into(), + code: "evidence_missing".into(), + subject: "entry 7".into(), + message: "entry 7 (2026-10-02) has no receipt: *not markup* #strong[x]".into(), + }], + checks: vec![WorkpaperCheck { + code: "evidence_missing".into(), + severity: "failure".into(), + predicate: "a posted entry the policy covers has no receipt".into(), + }], + } + } + + /// The same workpaper renders to the same bytes, a PDF. + #[test] + fn workpaper_pdf_is_deterministic() { + let a = workpaper_pdf(&sample()).unwrap(); + let b = workpaper_pdf(&sample()).unwrap(); + assert!(a.starts_with(b"%PDF")); + assert_eq!(a, b); + } + + /// A different finding is a different document. + #[test] + fn a_different_finding_is_a_different_document() { + let mut w = sample(); + let a = workpaper_pdf(&w).unwrap(); + w.findings.clear(); + assert_ne!(a, workpaper_pdf(&w).unwrap()); + } +} diff --git a/crates/ledger-import/templates/workpaper.typ b/crates/ledger-import/templates/workpaper.typ new file mode 100644 index 0000000..974371e --- /dev/null +++ b/crates/ledger-import/templates/workpaper.typ @@ -0,0 +1,77 @@ +// An audit workpaper: what was checked, through which date and which +// books' state, and every finding with its code and severity. +// +// Everything arrives as data in sys.inputs.data (JSON), built by +// ledger-import::workpaper. The template only typesets: no arithmetic, and +// no text is ever parsed as markup. It holds no clock: the same data gives +// the same bytes. +// +// Shape: { company, title, scope, through, through_seq, program_version, +// tb_hash, findings_sha256, summary: str, +// findings: [{ severity, code, subject, message }], +// checks: [{ code, severity, predicate }] } + +#let d = json(bytes(sys.inputs.data)) +#let ink = luma(25) +#let quiet = luma(110) +#let hair = 0.5pt + luma(160) + +#set document(title: d.company + " — " + d.title, author: d.company) +#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 9pt, fill: ink, number-width: "tabular", hyphenate: false) +#set page( + paper: "us-letter", + margin: (x: 0.8in, top: 0.8in, bottom: 0.8in), + footer: context { + set text(7.5pt, fill: quiet) + grid( + columns: (1fr, auto), + [#d.program_version · head #d.through_seq], + align(right)[Page #counter(page).display() of #counter(page).final().first()], + ) + }, +) + +#align(center)[ + #text(15pt, fill: luma(45), d.company) \ + #v(4pt) + #text(10.5pt, weight: "bold", d.title) \ + #v(2pt) + Through #d.through · scope #d.scope +] +#v(12pt) + +#grid( + columns: (auto, 1fr), + row-gutter: 5pt, + column-gutter: 12pt, + text(fill: quiet)[Books' figure head], [#d.through_seq], + text(fill: quiet)[Trial balance hash], text(size: 7.5pt)[#d.tb_hash], + text(fill: quiet)[Findings hash], text(size: 7.5pt)[#d.findings_sha256], + text(fill: quiet)[Result], [#d.summary], +) +#v(12pt) + +#text(10pt, weight: "bold")[Findings] +#v(4pt) +#if d.findings.len() == 0 [ + No findings. +] else [ + #table( + columns: (auto, auto, auto, 1fr), + stroke: (x: none, y: hair), + inset: (x: 4pt, y: 4pt), + table.header([*Severity*], [*Code*], [*About*], [*Finding*]), + ..d.findings.map(f => (f.severity, f.code, f.subject, f.message)).flatten(), + ) +] +#v(14pt) + +#text(10pt, weight: "bold")[What was checked] +#v(4pt) +#table( + columns: (auto, auto, 1fr), + stroke: (x: none, y: hair), + inset: (x: 4pt, y: 4pt), + table.header([*Code*], [*Severity*], [*True when it is reported*]), + ..d.checks.map(c => (c.code, c.severity, c.predicate)).flatten(), +) diff --git a/crates/ledger-mcp/handbook/1099.md b/crates/ledger-mcp/handbook/1099.md index 33fb3db..8eca584 100644 --- a/crates/ledger-mcp/handbook/1099.md +++ b/crates/ledger-mcp/handbook/1099.md @@ -5,3 +5,7 @@ - Tag what members paid for the company with the card or the cash they used (books.set_entry_instrument): their card is excluded, their own check counts. A card that only funded a friends-and-family transfer is their cash. Tag what a payment platform's balance paid the same way: goods and services excluded, friends-and-family counts; untagged, both stay undetermined. - Card and payment-network payments are excluded (the processor's Form 1099-K), whoever's card it was. A bank payment with no method recorded counts: tag a debit-card payment with the card. - books.vendor_1099 is the year-end worksheet for the CPA. undetermined means "tell me how it was paid" or "decide this vendor". With include_tins: true it carries the full TINs from the vault; csv: true also hands it over as a CSV file (run its download command to save it). + +## How the worksheet is built (books.vendor_1099) + +Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). It lists those payments, the W-9s missing, and each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold is not known is refused: pass threshold from the IRS. For the CPA to confirm. diff --git a/crates/ledger-mcp/handbook/attestations-and-audit.md b/crates/ledger-mcp/handbook/attestations-and-audit.md new file mode 100644 index 0000000..8041133 --- /dev/null +++ b/crates/ledger-mcp/handbook/attestations-and-audit.md @@ -0,0 +1,33 @@ +# Attestations and audit + +An audit is the books read as an auditor reads them, through a day, in one run that gives the same findings for the same books. An attestation is a claim by a checker that it looked at something and found it right. The audit finds; the attestations stand behind it. The CPA package waits for both. + +The receipt rule in force: {evidence_rule} + +## Running an audit + +- books.audit { through, scope } without confirmed lists the checks and changes nothing; show it to the human. Then call it again with confirmed: true and the preview. It files the workpaper (JSON and PDF, category workpaper) and journals the run. books.workpaper { id } gives it back: the JSON, and the PDF handed over (run its download command to save it). +- scope: month (a month-end), annual (a fiscal year), cpa_handoff (the year, for the CPA package), full (everything from the start of the books). +- It checks the trial balance, the accounting equation, the profit and loss against equity, the cash flow's footing, the A/R and A/P control accounts, closes, schedules and depreciation, the asset register, every posted entry against the receipt rule, open drafts, tax categories, 1099 decisions and the settings; then the journal's chain, the stored files, the access log and the backups. A finding is a failure (the books are wrong or cannot be shown right), a warning (unfinished or a rule not met) or a note (listed so the workpaper is whole: a reversal rests on its original's receipt). +- Fix findings with journaled corrections (a reversal, a receipt attached, a decision recorded), never by editing: posted entries stay as they are. Then audit again. +- The same books, day and scope give the run that exists: asking again files nothing. The books are "the same" while no entry, document, close, settings or role change has been made; filing a receipt, an audit or an attestation does not change them. +- Books that have not declared audits refuse a confirmed audit and every attestation with format_upgrade_pending until the owner's upgrade (books.start says so). Nothing else about them changes. + +## Attesting + +Use separate verifier agents; none of them should be the one that made the entry. For each receipt: + +1. books.get_receipt for the extracted text, then its page image, and read the total and the date off the receipt itself. +2. books.attest_receipt { attachment_id, entry_id, amount, date, method, note }. The books compare what you read with the entry's amounts and date. A difference is recorded as disagrees and never counts: never bend your reading to fit. +3. books.review_entry { entry_id, method } for each posted entry, once its lines, accounts, party and evidence have been read whole. +4. books.attest_period { period, method } last, on a stand-still: it is refused unless an audit through the period's end still stands, which it does only while nothing has been posted, created, closed or set since the audit. If it is refused, audit again. + +method is your own claim: model, reasoning_effort (low, medium, high, xhigh, max), adversarial (true when the verifier was set to find what is wrong), verifier_agents (how many independent ones checked). The books record it and cannot verify it; say what is true. + +## What counts + +books.attestations lists every claim with counted: whether it meets policy.attestation_bar as the bar stands when you ask. The default bar is reasoning effort high; a year-level audit (annual, cpa_handoff, full) also needs the check to be adversarial and at least two independent verifier agents. A bar change changes what counts, earlier claims too: nothing is versioned. A claim whose receipt reading disagrees with the entry never counts. + +## The CPA package + +Under policy.cpa_package_requires_audit = refuse (the default once the books hold an audit run; before one, the package is made and warns), books.export_cpa_package is refused unless an audit for the handoff (scope cpa_handoff or full) stands at the current books with a period attested on it at the bar. Pass without_audit_reason only when the human says to hand it over anyway; the reason is journaled as a decision. Under warn the package is made and says so. diff --git a/crates/ledger-mcp/handbook/cpa-handoff.md b/crates/ledger-mcp/handbook/cpa-handoff.md index 2732580..f9f10a8 100644 --- a/crates/ledger-mcp/handbook/cpa-handoff.md +++ b/crates/ledger-mcp/handbook/cpa-handoff.md @@ -5,3 +5,9 @@ - tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again. - Everything in it is as of its as_of: the 1099 worksheet counts the year's payments through that day, and general-journal.iif (the QuickBooks Desktop file) every posted entry from the first through it, closing entries left out. That file is plain ASCII within QuickBooks Desktop's limits: warnings (and the README) say what it writes differently from the books ("Café" as "Cafe", a name cut to 31 characters, a subaccount of another type at the top level, a project's name as its QuickBooks class, the memos grouped); tell the human. Each line's project is its CLASS, the projects declared first as QuickBooks classes. It names no customer or vendor: the README lists the entries on accounts receivable or payable, where QuickBooks may ask the CPA for one. - With members, the K-1 worksheet is two files: k1-members.csv (each member's capital on the books, with their TIN) and k1-tax-basis-capital.csv (the same capital on the tax basis, as Schedule K-1 Item L reports it: property contributed at the member's adjusted basis, IRC §723, a prepaid contract handed over at its book amount for the CPA to confirm, and the reconciliation from book to tax capital). A line the books can't know (the share of income as figured for tax, always; tax depreciation; a disposal's tax gain; the basis of property no asset records) is left open with what the books have: tell the human the CPA supplies it; never fill it in. + +## What the package holds (books.export_cpa_package) + +A zip, handed over as a download (run the command the result gives; never file it into the books). It holds: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), the statement of changes in members' equity (multi-member LLCs), the tax summary and the trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits; warnings, and the README, say what is written differently from the books); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, beginning capital, contributions in cash and in property, share of income, distributions and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its section 723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, is left open and named); every receipt behind the year's entries (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. + +By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins false gives last fours only. A locked vault or a missing TIN does not stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (for example ["1000"]) is required, since the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date. diff --git a/crates/ledger-mcp/handbook/files-in-out.md b/crates/ledger-mcp/handbook/files-in-out.md index 8aa7ba9..6f220b0 100644 --- a/crates/ledger-mcp/handbook/files-in-out.md +++ b/crates/ledger-mcp/handbook/files-in-out.md @@ -5,3 +5,11 @@ - If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy. - A file goes out by download alone. Files the books make (books.export in every format, books.asset_labels, books.export_cpa_package, books.vendor_1099 csv, books.get_w9) are handed over: run the download command each returns to save it in a folder. They are kept 15 minutes, for the token that asked. No file with a full TIN goes into the books, however it is filed: the server refuses every file it read or wrote with full TINs (the W-9 books.set_w9 took, and each of those outputs, or a file taken out of a CPA package), and any text file holding one: a TIN the vault keeps, written as nine digits or in the usual groups (3-2-4 with hyphens, spaces or dots; 2-7 with a hyphen or a space), or a number printed as an SSN or EIN is (3-2-4 or 2-7 digits joined by hyphens, not part of a longer number). Other numbers pass, so bank exports (routing and account numbers, long references) go in. It can't recognize an altered copy of a scan, or a TIN the vault doesn't keep written any other way, so never file a W-9 or a TIN worksheet yourself. - Receipts and documents come back out through books.get_receipt view bytes, handed over the same way: run its download command to save a copy in a folder you name. + +## Importing a bank CSV (books.import_bank_csv) + +A QuickBooks-style bank or credit-card CSV: 3 columns Date, Description, Amount (payments negative), or 4 columns Date, Description, money out, money in (Intuit heads them Credit, Debit). Slash dates must settle month-first or day-first from the file itself or from date_order ("month_first" for mm/dd, "day_first" for dd/mm); ask the human when the tool says the dates read either way. It creates one DRAFT entry per transaction, all or none: the bank account on one side, a categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt). + +## Exporting a report (books.export) + +Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today). diff --git a/crates/ledger-mcp/handbook/format-upgrade.md b/crates/ledger-mcp/handbook/format-upgrade.md index 603d65e..5402645 100644 --- a/crates/ledger-mcp/handbook/format-upgrade.md +++ b/crates/ledger-mcp/handbook/format-upgrade.md @@ -8,4 +8,4 @@ - After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours. - This upgrade is `documents_v2`: invoice numbers, terms and line accounts (S4), sent records (S5), payments that settle several documents with early-payment discounts and a processor's fee (S6), and write-offs (S7). Until it is given, those writes are refused and everything else works as before. -- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles), and `drafts_v2` (P4: a draft's state, awaiting its evidence, and a discarded draft). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books. +- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles), `drafts_v2` (P4: a draft's state, awaiting its evidence, and a discarded draft), and `source_revisions` (P5: correcting who decided a convention, a decision or an open item, and when). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books. diff --git a/crates/ledger-mcp/handbook/parties-and-tins.md b/crates/ledger-mcp/handbook/parties-and-tins.md index 1b6598d..bcf77ca 100644 --- a/crates/ledger-mcp/handbook/parties-and-tins.md +++ b/crates/ledger-mcp/handbook/parties-and-tins.md @@ -13,3 +13,15 @@ - Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault, and every member's and every 1099 payee's belongs there: books.set_tin (party, tin, tin_type: ssn, ein, itin; any role, so each member's SSN for their K-1) and books.set_w9 with tin and tin_type for a payee's W-9, and the signed form with w9_upload_id (books.begin_upload purpose w9; never books.file_document or books.attach_receipt: the form shows the TIN). - A full TIN lives in the vault: keep it out of descriptions, notes, file names and version-history messages. books.get_w9 hands the signed form over (run its download command to save it for the human); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. - books.parties lists the directory (each party's TIN last four from its TIN or W-9 facts), books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history. + +## Tagging an entry with several parties (books.set_entry_party) + +Pass party (an id or a name), or parties: [{party, amount? | percent?}] for several. Each share is an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their company.members share). Once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to company.members never re-splits posted history. Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. + +Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that do not add up to what it moved. A party with a vendor record also tags the entry's vendor; a party without one, or clearing, clears the vendor tag. Metadata only: amounts never change. An entry and its reversal carry the same parties (tagging either tags both), so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). The result is the entry as books.get_entry gives it, each party's share resolved, with its direction and what of it was money or property in kind. + +## Recording a W-9 (books.set_w9) + +Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to "wanderbooks vault". The CPA package carries the full TIN to the CPA. + +Give the signed form (a PDF, PNG or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. The result is the party (the TIN's last four only). diff --git a/crates/ledger-mcp/handbook/reconciliation.md b/crates/ledger-mcp/handbook/reconciliation.md index 255dcff..500af02 100644 --- a/crates/ledger-mcp/handbook/reconciliation.md +++ b/crates/ledger-mcp/handbook/reconciliation.md @@ -4,3 +4,7 @@ - Never confirm by forcing the numbers; find the difference. - books.void_reconciliation (with a reason) undoes a mistake; books.void_statement (with a reason) a mis-recorded statement, to record it again. - books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month. + +## What books.reconcile does + +Give statement_attachment (a statement recorded with books.record_statement: its account, period end and closing balance), or account, statement_date and statement_balance for a quick check against any balance. It returns the book balance, the cleared balance, the statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed it is a preview and records nothing. With confirmed and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first. diff --git a/crates/ledger-mcp/handbook/viewer.md b/crates/ledger-mcp/handbook/viewer.md index 5be87ec..dfff959 100644 --- a/crates/ledger-mcp/handbook/viewer.md +++ b/crates/ledger-mcp/handbook/viewer.md @@ -3,3 +3,7 @@ - The human usually has the read-only viewer open beside you. When they ask to see something, and after you post, call books.show so it's on their screen: an entry, an account register, a report for any dates (by month, quarter or year, against the prior period or year), a receipt or document, an asset, a party, a reconciliation, or a search. - books.show says which dates it showed (without dates, the fiscal year to date; a register opens on it too, with "Show all dates" one click away for the human). It refuses a start after the end, and options its target ignores (dates for an entry, a split for the trial balance, a start for a report as of a day, a split into more than 240 columns): pass only what the target takes. - The viewer never changes the books; every change goes through these tools. + +## What books.show can open + +Exactly one target: an entry (with its lines, receipt and history); an account's register (optionally for start/end dates); a report (optionally for start/end dates, split into month, quarter or year columns, compared with the prior period or prior year); a stored receipt or document; a fixed asset (by tag or id); a party (by id or name: who they are and everything the company did with them); a project (by id or name); a reconciliation (by id: its account with its statements and reconciliations); a search of the transactions; or a page. Work out dates yourself ("last quarter" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. It changes nothing in the books. diff --git a/crates/ledger-mcp/prompts/annual_compliance.md b/crates/ledger-mcp/prompts/annual_compliance.md new file mode 100644 index 0000000..6ec2ece --- /dev/null +++ b/crates/ledger-mcp/prompts/annual_compliance.md @@ -0,0 +1,8 @@ +Go through the year-end compliance work for {{year}} for {{company}}. + +1. books.start, and books.open_items: what is already open, with its due dates. +2. Read books.handbook sections 1099, parties-and-tins and rules-that-change. Work out the calendar for this entity type and list the filings and dates that apply; where you are unsure of a rule, raise it with books.raise_item for the human or the CPA rather than guessing. +3. 1099 work: books.vendor_1099 for {{year}}; collect missing W-9s (books.set_w9; a full TIN goes only into the vault, never into your messages). +4. Fixed assets: books.asset_schedule and books.run_depreciation for the year, previewed. +5. Every month of {{year}} closed (the month_end_close prompt) and reconciled. +6. Record each decision the human makes with books.record_decision, and each follow-up with books.raise_item. diff --git a/crates/ledger-mcp/prompts/audit.md b/crates/ledger-mcp/prompts/audit.md new file mode 100644 index 0000000..c21e4ed --- /dev/null +++ b/crates/ledger-mcp/prompts/audit.md @@ -0,0 +1,8 @@ +Audit the books for scope {{scope}} (month, annual, cpa_handoff or full). Treat the books as something to disprove: look for what is wrong. + +1. books.audit with scope {{scope}} and the day the audit covers: preview it, show the human what it will check, then run it with the preview. It checks the statements, the control accounts, closes, schedules, assets, every posted entry against the receipt policy, drafts, tax categories, 1099 decisions, settings, the chain, the stored files, the access log and the backups. books.workpaper gives the findings back. +2. books.verify and books.control_check: the chain, the replay and every stored file, and the receivable and payable controls against their documents. +3. books.digest for the scope's period, and read every posted entry in it against its evidence: books.search_entries, books.get_entry, books.get_receipt (its text first, then the page image). An entry that needs a receipt and has none, or whose receipt shows another amount, date or party, is a finding. +4. Use separate verifier agents, none of them the one that made the entry. For each receipt, books.attest_receipt with the amount and date as read off it; for each entry, books.review_entry; say truthfully how each checked (method). A reading that differs from the entry never counts: never bend it to fit. +5. books.trial_balance, books.profit_and_loss and books.balance_sheet for the period; compare them with the previous period and explain each large movement. +6. Report findings to the human as a list: what, where (entry or account), why it matters, and the tool that fixes it. Fix nothing without the human's confirmation; corrections are reversing entries, not edits. Then audit again, and when it is clean books.attest_period; books.attestations shows which claims count at the bar. diff --git a/crates/ledger-mcp/prompts/cpa_handoff.md b/crates/ledger-mcp/prompts/cpa_handoff.md new file mode 100644 index 0000000..79aa0c5 --- /dev/null +++ b/crates/ledger-mcp/prompts/cpa_handoff.md @@ -0,0 +1,8 @@ +Prepare the hand-off to the CPA for {{year}}. + +1. books.start, then books.verify: the journal's chain, the replay and every stored file must be clean before anything leaves. +2. Check each of the year's months is closed (books.list_periods) and reconciled (books.reconciliations); list what is not and stop if any is missing. +3. books.control_check, and the 1099 work: books.vendor_1099 for the year, with W-9s in the vault (books.handbook section 1099). +4. Read books.handbook section cpa-handoff, then tell the human what the package holds and whether the full TINs go in it (they go in by default; the human decides). +5. books.export_cpa_package for the year. The package is handed over as a download: run the command the result gives, in a folder the human names, within 15 minutes. +6. Offer books.digest for the year. diff --git a/crates/ledger-mcp/prompts/month_end_close.md b/crates/ledger-mcp/prompts/month_end_close.md new file mode 100644 index 0000000..735a556 --- /dev/null +++ b/crates/ledger-mcp/prompts/month_end_close.md @@ -0,0 +1,11 @@ +Close the month {{month}} for {{company}}. The close is data-driven: do each step in order, show the human what each one did, and stop at the first one that is not clean. + +1. books.start for the rules, then books.conventions with kind close_step: this company's own close steps, in the order it keeps them. Follow them. +2. Reconcile every tracked account for the month (the reconcile prompt, one account at a time); books.start lists the accounts that are behind. +3. Review the financing accounts and the open drafts: books.list_drafts. A draft is posted, with its evidence, or discarded; none may stand in a closed month. +4. Run what the month is owed: books.run_schedules, books.run_recurring and books.run_depreciation, each previewed first. +5. Check the figures: books.trial_balance, books.profit_and_loss and books.balance_sheet for the month; books.control_check for the receivable and payable controls; books.verify for the chain. +6. Only then books.close_period for the month, previewed and confirmed by the human. +7. Offer books.digest of the month. + +A closed month is never edited: a correction is a reversing entry in an open month. diff --git a/crates/ledger-mcp/prompts/onboard.md b/crates/ledger-mcp/prompts/onboard.md new file mode 100644 index 0000000..d101fc1 --- /dev/null +++ b/crates/ledger-mcp/prompts/onboard.md @@ -0,0 +1,11 @@ +You are setting up the books for {{company}} with the human. Work in this order and show the human each preview before anything is confirmed. + +1. Call books.start and read the rules. If state is active, stop: these books are already set up; ask what the human wants instead. +2. Ask for the company's facts (legal name, entity type, fiscal year end, time zone, base currency) and record them with books.set_setting; each call is previewed, then confirmed. +3. Choose the chart of accounts: books.chart_template lists the templates by entity type; import a chart from a spreadsheet with books.import_chart_csv if the human has one. Read books.handbook section entries first. +4. Record the members or owners, and the evidence policy (which entries need a receipt: books.handbook section evidence-and-receipts), the parties setting, and the attestation bar the human wants. +5. Enter the opening balances as one dated entry through books.draft_entry and books.add_line, preview it with books.post_entry, and post it only after the human confirms. +6. Check the opening trial balance with books.trial_balance against the human's own figures; every difference is explained or fixed before going on. +7. Offer the viewer (books.handbook section viewer) and a backup (books.handbook section backups). + +Never guess a figure, a date or a name: ask. Amounts are decimal strings. Anything you cannot find in the handbook, ask the human. diff --git a/crates/ledger-mcp/prompts/reconcile.md b/crates/ledger-mcp/prompts/reconcile.md new file mode 100644 index 0000000..e2e749d --- /dev/null +++ b/crates/ledger-mcp/prompts/reconcile.md @@ -0,0 +1,10 @@ +Reconcile account {{account}} for {{month}} against the bank's statement. + +1. Call books.start, then books.reconciliations to see what is already reconciled for this account. +2. Ask the human for the statement (the period's opening and closing balances and the statement file). Upload it as books.handbook section files-in-out says; never paste a file's contents. +3. Record it with books.record_statement. If the statement's opening balance is not the last reconciled balance, stop and find out why before going on. +4. Mark each transaction that appears on the statement as cleared with books.mark_cleared, matching by amount and date. Transactions on the statement with no entry are drafted (books.draft_entry), not forced. +5. Run books.reconcile for the statement. It succeeds only when the cleared items agree with the statement's closing balance exactly. If it does not, list the difference and the candidates (uncleared entries, statement lines with no entry); do not plug it. +6. Show the human the result with books.show, and offer books.digest. + +A mistaken reconciliation is voided with books.void_reconciliation, never edited. diff --git a/crates/ledger-mcp/src/access.rs b/crates/ledger-mcp/src/access.rs index 7631d56..31915e2 100644 --- a/crates/ledger-mcp/src/access.rs +++ b/crates/ledger-mcp/src/access.rs @@ -86,6 +86,7 @@ pub(crate) struct Call { action: &'static str, subject: String, args_digest: Option, + agent: Option, } /// Why a call wasn't run, for its record and its answer. @@ -124,6 +125,7 @@ impl Call { action, subject: subject.to_string(), args_digest: None, + agent: None, } } @@ -140,6 +142,16 @@ impl Call { self.args_digest = args_digest; } + /// This call, with what the agent said about itself (`books.start`). + pub(crate) fn with_agent(&mut self, agent: Option) { + self.agent = agent; + } + + /// The client program's name, as it named itself at `initialize`. + pub(crate) fn client_name(&self) -> Option { + self.client.as_ref().map(|c| c.name.clone()) + } + /// The call's caller, if the door named one. pub(crate) fn caller(&self) -> Option<&Caller> { self.caller.as_ref() @@ -172,6 +184,7 @@ impl Call { args_digest: self.args_digest.clone(), result, head_after, + agent: self.agent.clone(), }; match self.log.record(&access) { Ok(_) => Ok(()), diff --git a/crates/ledger-mcp/src/guards.rs b/crates/ledger-mcp/src/guards.rs new file mode 100644 index 0000000..5d04eb0 --- /dev/null +++ b/crates/ledger-mcp/src/guards.rs @@ -0,0 +1,180 @@ +//! The guards: the named ways the books refuse a change, each with the +//! tool that remedies it. +//! +//! ```text +//! GOAL: an agent that is refused is told which rule refused it and which +//! tool to call next, in the same words every time. +//! SOURCE: plan/served.md 4.6 (the result envelope: `guards` carry a code, +//! a message and a remedy tool); DECISION (P5). +//! INVARIANTS: +//! 1. Every guard names a remedy tool that exists (test +//! `every_guard_names_an_existing_tool`). +//! 2. A code is spelled one way: the enum's `code()`; a refusal whose +//! text is `{"code": .., "message": ..}` JSON or says a code in words +//! becomes the guard of that code, and any other refusal has none. +//! ``` + +use serde_json::{Value, json}; + +/// Every guard code the books know. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum Guard { + /// The preview a confirmation carries is not the one the books would + /// show now. + StalePreview, + /// The books wait for the owner's format upgrade. + FormatUpgradePending, + /// The evidence policy wants a receipt before this posts. + ReceiptRequired, + /// An approval rule wants an approver's go-ahead. + ApprovalRequired, + /// The entry's party shares do not add up. + PartyShares, + /// The parties setting requires a party (or a no-party reason). + PartyRequired, + /// Drafts stand in the way of a close. + OpenDrafts, + /// A line on a control account carries no document tag. + UntaggedControlLine, + /// A vendor already has a bill with this reference. + DuplicateVendorRef, + /// The first invoice number cannot change once one is issued. + FirstNumberLocked, + /// No audit stands at the books' current head to rest this on (plan + /// P8): run `books.audit`. + AuditRequired, +} + +impl Guard { + /// Every guard, for the registry test. + pub(crate) const ALL: [Guard; 11] = [ + Guard::StalePreview, + Guard::FormatUpgradePending, + Guard::ReceiptRequired, + Guard::ApprovalRequired, + Guard::PartyShares, + Guard::PartyRequired, + Guard::OpenDrafts, + Guard::UntaggedControlLine, + Guard::DuplicateVendorRef, + Guard::FirstNumberLocked, + Guard::AuditRequired, + ]; + + /// The code an agent reads. + pub(crate) fn code(self) -> &'static str { + match self { + Guard::StalePreview => "stale_preview", + Guard::FormatUpgradePending => "format_upgrade_pending", + Guard::ReceiptRequired => "receipt_required", + Guard::ApprovalRequired => "approval_required", + Guard::PartyShares => "party_shares", + Guard::PartyRequired => "party_required", + Guard::OpenDrafts => "open_drafts", + Guard::UntaggedControlLine => "untagged_control_line", + Guard::DuplicateVendorRef => "duplicate_vendor_ref", + Guard::FirstNumberLocked => "first_number_locked", + Guard::AuditRequired => "audit_required", + } + } + + /// The guard of a code. + pub(crate) fn from_code(code: &str) -> Option { + Self::ALL.into_iter().find(|g| g.code() == code) + } + + /// The tool that remedies it; `tool` is the call that was refused (a + /// stale preview is remedied by previewing it again). + pub(crate) fn remedy_tool(self, tool: &str) -> String { + match self { + Guard::StalePreview => return tool.to_string(), + Guard::FormatUpgradePending => "books.start", + Guard::ReceiptRequired => "books.attach_receipt", + Guard::ApprovalRequired => "books.approve_entry", + Guard::PartyShares | Guard::PartyRequired => "books.set_entry_party", + Guard::OpenDrafts => "books.list_drafts", + Guard::UntaggedControlLine => "books.control_check", + Guard::DuplicateVendorRef => "books.search_entries", + Guard::FirstNumberLocked => "books.settings", + Guard::AuditRequired => "books.audit", + } + .to_string() + } +} + +/// The guards a refusal's text carries, as the envelope shows them: +/// `[{code, message, remedy_tool, remedy_args?}]`. `tool` is the refused +/// call. +pub(crate) fn from_refusal(tool: &str, text: &str) -> Vec { + // A structured refusal: the JSON the tool built. + if let Ok(Value::Object(o)) = serde_json::from_str::(text) + && let Some(code) = o.get("code").and_then(Value::as_str) + { + let remedy = o + .get("remedy_tool") + .and_then(Value::as_str) + .map(str::to_string) + .or_else(|| Guard::from_code(code).map(|g| g.remedy_tool(tool))); + if let Some(remedy) = remedy { + let mut g = json!({ + "code": code, + "message": o.get("message").and_then(Value::as_str).unwrap_or(code), + "remedy_tool": remedy, + }); + if let Some(entry) = o.get("entry_id") { + g["remedy_args"] = json!({ "entry_id": entry }); + } + return vec![g]; + } + return Vec::new(); + } + // A refusal that names its code in words (the store's). + for g in Guard::ALL { + if text.starts_with(g.code()) { + return vec![json!({ + "code": g.code(), + "message": text, + "remedy_tool": g.remedy_tool(tool), + })]; + } + } + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::server::BooksServer; + + #[test] + fn every_guard_names_an_existing_tool() { + let names: Vec = BooksServer::tool_router() + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + for g in Guard::ALL { + let remedy = g.remedy_tool("books.post_entry"); + assert!( + names.contains(&remedy), + "guard {} names {remedy}, which is no tool", + g.code() + ); + assert_eq!(Guard::from_code(g.code()), Some(g)); + } + } + + #[test] + fn a_refusal_becomes_its_guard() { + let text = json!({"code":"approval_required","message":"needs approval","entry_id":7,"remedy_tool":"books.approve_entry"}).to_string(); + let g = from_refusal("books.post_entry", &text); + assert_eq!(g[0]["code"], "approval_required"); + assert_eq!(g[0]["remedy_args"]["entry_id"], 7); + let g = from_refusal( + "books.create_invoice", + "format_upgrade_pending: this needs documents_v2", + ); + assert_eq!(g[0]["remedy_tool"], "books.start"); + assert!(from_refusal("books.x", "no such account").is_empty()); + } +} diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index 7742024..cefc8fe 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -87,6 +87,10 @@ pub const SECTIONS: &[(&str, &str)] = &[ ), ("1099", include_str!("../handbook/1099.md")), ("cpa-handoff", include_str!("../handbook/cpa-handoff.md")), + ( + "attestations-and-audit", + include_str!("../handbook/attestations-and-audit.md"), + ), ( "settings-and-conventions", include_str!("../handbook/settings-and-conventions.md"), diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 32316be..4a59158 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -1121,6 +1121,7 @@ async fn refused_at_door( args_digest: None, result: Outcome::Refused(code.into()), head_after: None, + agent: None, }; let written = tokio::task::spawn_blocking(move || access.record(&record)).await; if !matches!(written, Ok(Ok(_))) { @@ -1350,6 +1351,7 @@ async fn upload( args_digest: None, result: outcome.clone(), head_after: None, + agent: None, }; let recorded = access.record(&record).is_ok(); if !recorded && received.is_ok() { @@ -1524,6 +1526,7 @@ async fn fetch( Err((_, code, _)) => Outcome::Refused((*code).into()), }, head_after: None, + agent: None, }; let recorded = access.record(&record).is_ok(); (found, recorded) diff --git a/crates/ledger-mcp/src/next_steps.rs b/crates/ledger-mcp/src/next_steps.rs new file mode 100644 index 0000000..59aaa64 --- /dev/null +++ b/crates/ledger-mcp/src/next_steps.rs @@ -0,0 +1,605 @@ +//! The result envelope: what every tool result carries besides its own +//! fields (plan 4.6). +//! +//! ```text +//! GOAL: an agent never has to guess what to do after a call: every result +//! says what follows (`next_steps`), what refused it and which tool +//! remedies that (`guards`), what to tell the human (`warnings`), +//! and, on a tool that can change the books, where the journal ends +//! now (`head`). +//! SOURCE: plan/served.md 4.6 and P5 step 1; DECISION (P5). +//! INVARIANTS: +//! 1. The envelope is applied in one place (`apply`, called by +//! `call_tool`), so no tool can forget it. +//! 2. Every result of a tool that is not read-only has a `head` (the +//! journal after the call's last write) and at least one next step; a +//! read has `next_steps` when something follows it and nothing else (a +//! resource and its tool agree). `guards` is on a refusal, and a +//! success without warnings has no `warnings` key. +//! 3. A tool's own fields are never overwritten: a payload that already +//! has `next_steps`, `guards`, `warnings` or `head` keeps it. +//! 4. A refused call has no head and no steps; a refusal that is a +//! guard (guards.rs) carries it as `structuredContent.guards`. +//! 5. Next steps from the books' attention list are the viewer's own +//! items (ledger_package::attention), filtered to the subject the +//! call touched. +//! ``` + +use super::BooksServer; +use super::guards; +use ledger_package::{Integrity, Severity, Target}; +use rmcp::model::{CallToolResponse, ContentBlock}; +use serde_json::{Map, Value, json}; +use std::collections::HashSet; +use std::sync::OnceLock; + +/// One thing to do next. +#[derive(Clone, Debug)] +pub(crate) struct Step { + /// The tool to call. + pub tool: Option, + /// Or the prompt to run. + pub prompt: Option, + /// Or a command for the human's computer (a download). + pub command: Option, + /// Its arguments. + pub args: Option, + /// Why. + pub why: String, + /// Whether the work is not done without it. + pub required: bool, +} + +impl Step { + pub(crate) fn tool(tool: &str, args: Option, why: &str, required: bool) -> Step { + Step { + tool: Some(tool.into()), + prompt: None, + command: None, + args, + why: why.into(), + required, + } + } + + fn to_json(&self) -> Value { + let mut o = Map::new(); + if let Some(t) = &self.tool { + o.insert("tool".into(), json!(t)); + } + if let Some(p) = &self.prompt { + o.insert("prompt".into(), json!(p)); + } + if let Some(c) = &self.command { + o.insert("command".into(), json!(c)); + } + if let Some(a) = &self.args { + o.insert("args".into(), a.clone()); + } + o.insert("why".into(), json!(self.why)); + o.insert("required".into(), json!(self.required)); + Value::Object(o) + } +} + +/// The tools that are not read-only: their results carry a `head`. +pub(crate) fn mutating_tools() -> &'static HashSet { + static SET: OnceLock> = OnceLock::new(); + SET.get_or_init(|| { + BooksServer::tool_router() + .list_all() + .into_iter() + .filter(|t| { + !t.annotations + .as_ref() + .and_then(|a| a.read_only_hint) + .unwrap_or(false) + }) + .map(|t| t.name.to_string()) + .collect() + }) +} + +/// The steps a tool's own result implies (no books needed): the confirm +/// after a preview, and what follows a particular act. +pub(crate) fn tool_steps( + tool: &str, + args: Option<&Map>, + result: &Value, +) -> Vec { + let mut steps = Vec::new(); + let confirmed = args + .and_then(|a| a.get("confirmed")) + .and_then(Value::as_bool) + .unwrap_or(false); + let entry = result + .get("entry_id") + .or_else(|| args.and_then(|a| a.get("entry_id"))) + .and_then(Value::as_u64); + let with_entry = |id: u64| Some(json!({ "entry_id": id })); + // A preview: what is missing first, then the human confirms, then the + // same call with the digest. + // An audit already run is not previewed: its run is the answer. + let already_run = tool == "books.audit" + && result.get("status").and_then(Value::as_str) == Some("already_run"); + if let (false, Some(preview), Some(args)) = ( + confirmed || already_run, + result.get("preview").and_then(Value::as_str), + args, + ) { + if tool == "books.post_entry" + && result.get("receipt_missing").and_then(Value::as_bool) == Some(true) + && let Some(id) = entry + { + steps.push(Step::tool( + "books.attach_receipt", + with_entry(id), + "This entry needs a receipt before it posts: attach the file (upload it first).", + true, + )); + steps.push(Step::tool( + "books.set_draft_state", + Some(json!({ "entry_id": id, "state": "awaiting_evidence" })), + "Or leave the draft waiting, with a note saying what for, until the receipt arrives.", + false, + )); + } + let mut confirm = args.clone(); + confirm.insert("confirmed".into(), json!(true)); + confirm.insert("preview".into(), json!(preview)); + steps.push(Step::tool( + tool, + Some(Value::Object(confirm)), + "Show this preview to the human; only when they agree, make the same call with confirmed true and this preview.", + true, + )); + return steps; + } + match tool { + "books.set_setting" | "books.add_account" => { + steps.push(Step::tool( + "books.add_account", + None, + "Add the accounts the human's chart needs, one at a time.", + false, + )); + steps.push(Step::tool( + "books.draft_entry", + None, + "Or record a transaction: draft it, add its lines, preview it, then post.", + false, + )); + } + "books.draft_entry" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.add_line", + with_entry(id), + "Add the draft's lines: each is an account, a side and an amount; debits must equal credits.", + true, + )); + } + } + "books.add_line" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.add_line", + with_entry(id), + "Add the next line of the draft.", + false, + )); + steps.push(Step::tool( + "books.post_entry", + with_entry(id), + "When the lines balance, preview the post for the human.", + false, + )); + } + } + "books.set_draft_state" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.attach_receipt", + with_entry(id), + "When the document arrives, upload and attach it to this draft.", + false, + )); + } + } + "books.attach_receipt" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.post_entry", + with_entry(id), + "The evidence is attached: preview the post again for the human.", + false, + )); + } + } + "books.post_entry" | "books.reverse_entry" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.show", + with_entry(id), + "Show the human the entry and what it moved.", + false, + )); + if tool == "books.post_entry" { + steps.push(Step::tool( + "books.reverse_entry", + with_entry(id), + "Only if this was posted in error: reverse it (posted entries are never edited).", + false, + )); + } + } + } + "books.draft_from_template" => { + if let Some(id) = entry { + steps.push(Step::tool( + "books.post_entry", + with_entry(id), + "Preview the draft for the human, who then confirms it.", + false, + )); + } + } + "books.record_statement" => { + steps.push(Step::tool( + "books.mark_cleared", + None, + "Mark each transaction the statement shows as cleared.", + true, + )); + let (account, end, close, attachment) = ( + result.get("account_code"), + result.get("period_end"), + result.get("closing_balance"), + result.get("attachment_id"), + ); + if let (Some(account), Some(end), Some(close), Some(attachment)) = + (account, end, close, attachment) + { + steps.push(Step::tool( + "books.reconcile", + Some(json!({ + "account": account, + "statement_date": end, + "statement_balance": close, + "statement_attachment": attachment, + })), + "When every line is marked, preview the reconciliation against the statement.", + false, + )); + } + } + "books.mark_cleared" => steps.push(Step::tool( + "books.reconcile", + None, + "Mark the rest, then preview the reconciliation against the statement.", + false, + )), + "books.reconcile" => steps.push(Step::tool( + "books.reconciliations", + None, + "See the reconciliation among the account's others.", + false, + )), + "books.list_periods" => { + for p in result + .get("periods") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter(|p| p.get("status").and_then(Value::as_str) == Some("open")) + .take(3) + { + if let Some(id) = p.get("id").and_then(Value::as_u64) { + steps.push(Step::tool( + "books.close_period", + Some(json!({ "period_id": id })), + "Once everything in this period is recorded and reconciled, preview its close.", + false, + )); + } + } + } + "books.close_period" => { + let period = result + .get("period_id") + .or_else(|| args.and_then(|a| a.get("period_id"))) + .and_then(Value::as_u64); + // The chain first (cheap, and what every close has always + // offered: the month_end_close eval follows it), then the + // audit the attestation rests on (P8). + steps.push(Step::tool( + "books.verify", + None, + "Verify the journal's chain and the projection after the close.", + false, + )); + steps.push(Step::tool( + "books.audit", + Some(json!({ "scope": "month" })), + "Audit the books through the month's end; the attestation rests on that run.", + false, + )); + steps.push(Step::tool( + "books.attest_period", + period.map(|p| json!({ "period": p.to_string() })), + "Once the audit is run and the receipts read, attest the period you closed.", + false, + )); + } + "books.audit" => { + if let Some(id) = result.pointer("/run/id").and_then(Value::as_u64) { + steps.push(Step::tool( + "books.workpaper", + Some(json!({ "id": id })), + "Read the workpaper: what was checked and every finding. Fix findings with journaled corrections, then audit again.", + false, + )); + steps.push(Step::tool( + "books.attest_receipt", + None, + "Have separate verifier agents read each receipt (books.get_receipt, then its page image) and attest it.", + false, + )); + steps.push(Step::tool( + "books.review_entry", + None, + "Review each posted entry whole.", + false, + )); + steps.push(Step::tool( + "books.attest_period", + None, + "Then attest the period this audit covers.", + false, + )); + } + } + "books.attest_receipt" => steps.push(Step::tool( + "books.review_entry", + args.and_then(|a| a.get("entry_id")) + .map(|e| json!({ "entry_id": e })), + "Review the whole entry the receipt supports.", + false, + )), + "books.review_entry" => steps.push(Step::tool( + "books.attestations", + None, + "See which claims count at the bar.", + false, + )), + "books.attest_period" => { + steps.push(Step::tool( + "books.attestations", + None, + "See which claims count at the bar before the CPA package.", + false, + )); + steps.push(Step::tool( + "books.export_cpa_package", + None, + "When the year is audited and attested at the bar, hand the books to the CPA.", + false, + )); + } + "books.export_cpa_package" => { + if let Some(cmd) = result.pointer("/file/download").and_then(Value::as_str) { + steps.push(Step { + tool: None, + prompt: None, + command: Some(cmd.to_string()), + args: None, + why: "Run this to save the package in a folder you name, within 15 minutes." + .into(), + required: true, + }); + } + } + _ => {} + } + steps +} + +/// The attention items about the subject of the call, as steps. +fn attention_steps( + server: &BooksServer, + subject: Subject, + store: &ledger_store::Store, +) -> Vec { + let cfg = server.settings(); + let settings = ledger_package::Settings { + company: &cfg.company, + cash_accounts: &cfg.roles.cash, + financing_accounts: &cfg.roles.financing, + investing_accounts: &cfg.roles.investing, + }; + let today = ledger_core::settings::today_in(cfg.timezone.as_deref()); + let items = ledger_package::find_with(store, today, &settings, &Integrity::default()); + items + .into_iter() + .filter(|a| match (subject, a.target) { + (Subject::Entry(s), Target::Entry(t)) => s == t, + (Subject::Invoice(s), Target::Invoice(t)) => s == t, + _ => false, + }) + .filter_map(|a| { + let (tool, args) = a.tool?; + let args = if args.is_null() { + match (a.target, tool.as_str()) { + ( + Target::Entry(id), + "books.attach_receipt" + | "books.set_entry_party" + | "books.set_entry_settles", + ) => Some(json!({ "entry_id": id })), + _ => None, + } + } else { + Some(args) + }; + let title: String = a.title.chars().take(160).collect(); + Some(Step::tool( + &tool, + args, + &title, + a.severity == Severity::Problem, + )) + }) + .take(4) + .collect() +} + +/// What a call touched, for the attention filter. +#[derive(Clone, Copy)] +enum Subject { + Entry(u64), + Invoice(u64), +} + +fn subject_of(args: Option<&Map>, result: &Value) -> Option { + let pick = |k: &str| { + result + .get(k) + .or_else(|| args.and_then(|a| a.get(k))) + .and_then(Value::as_u64) + }; + pick("entry_id") + .map(Subject::Entry) + .or_else(|| pick("invoice_id").map(Subject::Invoice)) +} + +/// The fallback of a tool that changes things and has nothing particular +/// to follow it. +fn digest_step() -> Step { + Step::tool( + "books.digest", + None, + "See what changed and offer it to the human.", + false, + ) +} + +/// Put the envelope on a tool's answer (INVARIANT 1). `wrote` is the head +/// after the call's last write, when it wrote. +pub(crate) fn apply( + server: &BooksServer, + tool: &str, + args: Option<&Map>, + out: Result, +) -> Result { + let Ok(CallToolResponse::Complete(mut r)) = out else { + return out; + }; + if r.is_error == Some(true) { + // INVARIANT 4. + let text = r + .content + .iter() + .find_map(|c| match c { + ContentBlock::Text(t) => Some(t.text.as_str()), + _ => None, + }) + .unwrap_or(""); + let guards = guards::from_refusal(tool, text); + if !guards.is_empty() && r.structured_content.is_none() { + r.structured_content = Some(json!({ "guards": guards })); + } + return Ok(CallToolResponse::Complete(r)); + } + let Some(Value::Object(mut body)) = r.structured_content.take() else { + return Ok(CallToolResponse::Complete(r)); + }; + let before = Value::Object(body.clone()); + let mutating = mutating_tools().contains(tool); + let result = Value::Object(body.clone()); + let mut steps = tool_steps(tool, args, &result); + let head = if mutating { + server.lock().ok().map(|store| { + let (seq, hash) = store.head(); + if let Some(subject) = subject_of(args, &result) + && steps.iter().all(|s| !s.required) + { + steps.extend(attention_steps(server, subject, &store)); + } + json!({ "seq": seq, "hash": hash }) + }) + } else { + None + }; + if mutating && steps.is_empty() { + steps.push(digest_step()); + } + // A tool that can change the books always says what follows; a read + // says it only when something does, so what it says is what a resource + // of the same name says (`books://settings` is `books.settings`). + // `guards` is on a refusal (above) and `warnings` is the tool's own: + // a success that has none says none, as the tools always did. + if !body.contains_key("next_steps") && (mutating || !steps.is_empty()) { + body.insert( + "next_steps".into(), + Value::Array(steps.iter().map(Step::to_json).collect()), + ); + } + + if let Some(head) = head { + body.entry("head").or_insert(head); + } + let after = Value::Object(body); + // The same JSON in the text block (server.rs INVARIANT 1). + for c in &mut r.content { + if let ContentBlock::Text(t) = c + && serde_json::from_str::(&t.text).is_ok_and(|v| v == before) + { + t.text = after.to_string(); + } + } + r.structured_content = Some(after); + Ok(CallToolResponse::Complete(r)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_preview_gets_its_confirm_step() { + let args = json!({"entry_id": 3}).as_object().cloned().unwrap(); + let steps = tool_steps( + "books.post_entry", + Some(&args), + &json!({"entry_id": 3, "preview": "abc", "status": "preview"}), + ); + assert_eq!(steps.len(), 1); + let a = steps[0].args.as_ref().unwrap(); + assert_eq!(a["confirmed"], true); + assert_eq!(a["preview"], "abc"); + assert!(steps[0].required); + } + + #[test] + fn every_step_a_tool_names_exists() { + let names: HashSet = BooksServer::tool_router() + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + let result = json!({"entry_id": 1, "file": {"download": "x"}}); + for tool in mutating_tools() { + for s in tool_steps(tool, Some(&Map::new()), &result) + .into_iter() + .chain([digest_step()]) + { + if let Some(t) = s.tool { + assert!( + names.contains(&t), + "{tool}: step names {t}, which is no tool" + ); + } + } + } + } +} diff --git a/crates/ledger-mcp/src/opening.rs b/crates/ledger-mcp/src/opening.rs index 3f67e5f..5281f98 100644 --- a/crates/ledger-mcp/src/opening.rs +++ b/crates/ledger-mcp/src/opening.rs @@ -25,6 +25,7 @@ pub(crate) fn capabilities() -> ServerCapabilities { ServerCapabilities::builder() .enable_tools() .enable_resources() + .enable_prompts() .build() } @@ -85,5 +86,6 @@ mod tests { ); assert!(r["capabilities"]["tools"].is_object()); assert!(r["capabilities"]["resources"].is_object()); + assert!(r["capabilities"]["prompts"].is_object()); } } diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 991d2ce..56da9e0 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 133] = [ +pub const TABLE: [(&str, Needs); 140] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -200,7 +200,17 @@ pub const TABLE: [(&str, Needs); 133] = [ ("books.list_drafts", R), ("books.set_draft_state", W), ("books.discard_draft", C), + // P5 + ("books.journal_history", R), ("books.preview_entry", R), + // P8: audits and attestations (an audit previews, then runs; a claim + // that someone checked is a write, not a post). + ("books.audit", C), + ("books.workpaper", R), + ("books.attest_receipt", W), + ("books.review_entry", W), + ("books.attest_period", W), + ("books.attestations", R), ]; /// What `tool` needs, given its arguments; None for a tool this server diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 260283a..b250a40 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -584,6 +584,8 @@ impl BooksServer { + Self::tax_router() + Self::write_offs_router() + Self::drafts_router() + + Self::journal_router() + + Self::audit_router() } /// The settings in force for this request: the books' own, taken from @@ -1264,6 +1266,64 @@ fn resource_list() -> Vec { resources } +/// The advertised resource templates (plan 4.5; RFC 6570): the URIs a +/// client can build, each read by `resource_body`. +fn resource_templates() -> Vec { + use rmcp::model::ResourceTemplate; + let mk = |uri: &str, name: &str, title: &str, description: &str| { + ResourceTemplate::new(uri, name) + .with_title(title) + .with_description(description) + .with_mime_type("application/json") + }; + vec![ + mk( + "books://entries/{id}", + "entry", + "An entry", + "One entry with its lines, receipts and history (books.get_entry).", + ), + mk( + "books://attachments/{id}", + "attachment", + "A stored file", + "A receipt or document: its metadata and the start of its text (books.get_receipt); its pages are books://attachments/{id}/page/{n}.", + ), + mk( + "books://journal/{seq}", + "journal-line", + "A journal line", + "One journal line in words (books.journal_history).", + ), + mk( + "books://reports/{kind}{?start,end,as_of}", + "report", + "A report", + "trial-balance, balance-sheet or profit-and-loss, for a day (as_of) or a range (start, end); without them as of today, or the year to date.", + ), + mk( + "books://digest{?since,since_seq,as_of}", + "digest", + "The activity digest", + "What changed, since a date, since a journal line (since_seq), as of a day; bare, this month so far.", + ), + ] +} + +/// Prompts are listed and read by a token that may read the books. +fn prompt_reader(context: &RequestContext) -> Result<(), rmcp::ErrorData> { + match crate::access::caller(context) { + None => Err(rmcp::ErrorData::invalid_request( + crate::access::UNAUTHORIZED, + None, + )), + Some(c) if !c.may(ledger_store::auth::Scope::Read) => Err( + rmcp::ErrorData::invalid_request(crate::access::INSUFFICIENT_SCOPE, None), + ), + Some(_) => Ok(()), + } +} + /// The subject kinds, for messages. const SUBJECT_KINDS: &str = "entry, account, asset, party, project, invoice, bill, period, payroll_run, schedule, reconciliation, company"; @@ -1321,6 +1381,13 @@ thread_local! { /// The journal's head after the last write made by the request /// running on this thread (StoreGuard's drop), for its access record. static WROTE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// Who the request running on this thread is, as `books.start` shows + /// it: `{principal, kind, scopes, client_id}`. The token's own facts + /// and the client's name; set at the start of the request. + static YOU: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; + /// What the agent said about itself in a `books.start` this request is + /// running (`agent`), for the call's access record. + static AGENT_NOTE: std::cell::RefCell> = const { std::cell::RefCell::new(None) }; /// The company's time zone for the request running on this thread /// (set at its start from the settings in force: one version per /// request), so every "today" in it agrees. @@ -1358,6 +1425,16 @@ fn today() -> NaiveDate { TIMEZONE.with(|z| ledger_core::settings::today_in(z.borrow().as_deref())) } +/// A resource URI's query parameter `key`, when given and not empty. +fn query_param(query: &str, key: &str) -> Option { + query + .split('&') + .find_map(|kv| kv.strip_prefix(&format!("{key}="))) + .map(str::trim) + .filter(|v| !v.is_empty()) + .map(str::to_string) +} + /// The `since` of a `books://digest` read: the query's `since=`, or, bare /// or with an empty `since=`, the first day of `today`'s month (this /// month so far). @@ -1397,12 +1474,20 @@ mod access_tools; mod backup_tools; #[path = "tools/files.rs"] mod files; +#[path = "guards.rs"] +mod guards; #[path = "tools/instruments.rs"] mod instruments; +#[path = "tools/journal.rs"] +mod journal_tools; #[path = "tools/kept.rs"] mod kept; +#[path = "next_steps.rs"] +mod next_steps; #[path = "tools/projects.rs"] mod projects; +#[path = "tools/prompts.rs"] +mod prompts; pub use kept::SettingKeyOut; // The tool groups: one file each (plan/served.md 8.2 U0a). A unit adds a @@ -1412,6 +1497,8 @@ pub use kept::SettingKeyOut; mod assets; #[path = "tools/attachments.rs"] mod attachments; +#[path = "tools/audit.rs"] +mod audit_tools; #[path = "tools/chart.rs"] mod chart; #[path = "tools/documents.rs"] @@ -1500,14 +1587,17 @@ impl BooksServer { } "books://reports/trial-balance" => { let Json(out) = self - .trial_balance(Parameters(TrialBalanceParams::default())) + .trial_balance(Parameters(TrialBalanceParams { + as_of: query_param(query, "as_of"), + ..TrialBalanceParams::default() + })) .await?; json(&out) } "books://reports/balance-sheet" => { let Json(out) = self .balance_sheet(Parameters(BalanceSheetParams { - as_of: None, + as_of: query_param(query, "as_of"), strict: None, })) .await?; @@ -1516,8 +1606,8 @@ impl BooksServer { "books://reports/profit-and-loss" => { let Json(out) = self .profit_and_loss(Parameters(ProfitAndLossParams { - start: None, - end: None, + start: query_param(query, "start"), + end: query_param(query, "end"), class: None, strict: None, })) @@ -1525,16 +1615,56 @@ impl BooksServer { json(&out) } "books://digest" => { - let since = digest_since(query, today()); + // `since_seq` (what was posted since a journal line) wins + // over `since`; with neither, this month so far. + let since_seq = query_param(query, "since_seq") + .map(|n| { + n.parse::() + .map_err(|_| format!("bad since_seq in {uri}")) + }) + .transpose()?; + let since = if since_seq.is_some() { + None + } else { + Some(digest_since(query, today())) + }; let Json(out) = self .digest(Parameters(DigestParams { - since: Some(since), - since_seq: None, - as_of: None, + since, + since_seq, + as_of: query_param(query, "as_of"), })) .await?; json(&out) } + _ if path.starts_with("books://entries/") => { + let id: u64 = path["books://entries/".len()..] + .parse() + .map_err(|_| format!("bad entry resource URI: {uri}"))?; + let Json(out) = self + .get_entry(Parameters(GetEntryParams { entry_id: id })) + .await?; + json(&out) + } + _ if path.starts_with("books://journal/") => { + let seq: u64 = path["books://journal/".len()..] + .parse() + .map_err(|_| format!("bad journal resource URI: {uri}"))?; + if seq == 0 { + return Err("journal seqs start at 1".into()); + } + let Json(out) = self + .journal_history(Parameters(journal_tools::JournalHistoryParams { + after_seq: Some(seq - 1), + limit: Some(1), + ..Default::default() + })) + .await?; + if out.lines.first().is_none_or(|l| l.seq != seq) { + return Err(format!("the journal has no line {seq}")); + } + json(&out) + } handbook::URI => { let Json(out) = self.handbook(Parameters(HandbookParams::default())).await?; json(&out) @@ -1704,6 +1834,8 @@ impl ServerHandler for BooksServer { let running_call = call.clone(); // Kept for the record of a call the server stopped before running. let request_args = request.arguments.clone(); + let tool_name = request.name.to_string(); + let envelope_args = request.arguments.clone(); let result = run_blocking(&self.store, move || { Box::pin(async move { WROTE.with(|w| w.borrow_mut().take()); @@ -1712,6 +1844,17 @@ impl ServerHandler for BooksServer { .caller() .map(|c| (c.token.id.clone(), c.token.name.clone())); CALLER.with(|c| *c.borrow_mut() = caller); + AGENT_NOTE.with(|a| a.borrow_mut().take()); + YOU.with(|y| { + *y.borrow_mut() = running_call.caller().map(|c| { + serde_json::json!({ + "principal": c.token.name, + "kind": "agent", + "scopes": c.token.scopes.iter().map(|s| s.as_str()).collect::>(), + "client_id": running_call.client_name(), + }) + }) + }); // Counted as running by now (run_blocking): hashed here. let digest = Call::digest_of(request.arguments.as_ref()); let tcc = @@ -1740,8 +1883,14 @@ impl ServerHandler for BooksServer { std::panic::resume_unwind(panic) } }; + // The result envelope (next_steps.rs INVARIANT 1). + let out = next_steps::apply(&server, &tool_name, envelope_args.as_ref(), out); + // Who asked is the request's alone: a later request on this + // thread (a resource read) must not see it. + YOU.with(|y| y.borrow_mut().take()); let mut c = running_call; c.digested(digest); + c.with_agent(AGENT_NOTE.with(|a| a.borrow_mut().take())); let recorded = c.record_blocking(outcome_of(&out), wrote); (out, recorded) }) @@ -1825,6 +1974,36 @@ impl ServerHandler for BooksServer { Ok(ListResourcesResult::with_all_items(resource_list())) } + async fn list_resource_templates( + &self, + _request: Option, + _context: RequestContext, + ) -> Result { + Ok(rmcp::model::ListResourceTemplatesResult::with_all_items( + resource_templates(), + )) + } + + async fn list_prompts( + &self, + _request: Option, + context: RequestContext, + ) -> Result { + prompt_reader(&context)?; + Ok(Self::prompt_list()) + } + + async fn get_prompt( + &self, + request: rmcp::model::GetPromptRequestParams, + context: RequestContext, + ) -> Result { + prompt_reader(&context)?; + self.prompt_get(&request) + .map(Into::into) + .map_err(|e| rmcp::ErrorData::invalid_params(e, None)) + } + async fn read_resource( &self, request: ReadResourceRequestParams, @@ -1922,7 +2101,10 @@ impl BooksServer { let server = self.clone(); let at = uri.clone(); let body = run_blocking(&self.store, move || { - Box::pin(async move { in_timezone_async(timezone, server.resource_body(&at)).await }) + Box::pin(async move { + YOU.with(|y| y.borrow_mut().take()); + in_timezone_async(timezone, server.resource_body(&at)).await + }) }) .await?; // A refusal quotes the URI it was given: masked like a tool's. diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index e21c92a..af2ab17 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -9,6 +9,25 @@ use super::*; use crate::server::test_support::*; +/// Plan 4.2 (P5 step 2): `books.start` is loaded up front by clients that +/// defer tools, and no other tool asks for that. +#[test] +fn books_start_is_always_loaded() { + for tool in BooksServer::tool_router().list_all() { + let always = tool + .meta + .as_ref() + .and_then(|m| m.0.get("anthropic/alwaysLoad")) + .and_then(serde_json::Value::as_bool); + assert_eq!( + always == Some(true), + tool.name == "books.start", + "{}", + tool.name + ); + } +} + /// An upload's limit, 25 MiB exactly (plan 4.7); nothing comes inline /// (D-46). #[test] @@ -42,7 +61,7 @@ fn annotations_complete() { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 133] = [ + let table: [(&str, Hints); 140] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -178,7 +197,15 @@ fn annotations_complete() { ("books.list_drafts", R), ("books.set_draft_state", W), ("books.discard_draft", D), + ("books.journal_history", R), ("books.preview_entry", R), + // P8: audits and attestations. + ("books.audit", W), + ("books.workpaper", R), + ("books.attest_receipt", W), + ("books.review_entry", W), + ("books.attest_period", W), + ("books.attestations", R), ]; let tools = BooksServer::tool_router().list_all(); assert_eq!(tools.len(), table.len()); @@ -701,23 +728,10 @@ fn tool_text_lint() { } } -/// Tool descriptions are at most 700 characters (plan I-12). The -/// ones over it today are listed, each to be shortened in P5 when its -/// text moves into the handbook; no new one may join them, and one -/// that shrinks under the cap must leave the list. +/// Tool descriptions are at most 700 characters (plan I-12); the long +/// text lives in the handbook (P5 moved the last eight there). #[test] fn tool_descriptions_fit_700_characters() { - // Shorten in P5. - const OVER: &[&str] = &[ - "books.export", - "books.export_cpa_package", - "books.import_bank_csv", - "books.reconcile", - "books.set_entry_party", - "books.set_w9", - "books.show", - "books.vendor_1099", - ]; let mut over: Vec = BooksServer::tool_router() .list_all() .iter() @@ -725,7 +739,10 @@ fn tool_descriptions_fit_700_characters() { .map(|t| t.name.to_string()) .collect(); over.sort(); - assert_eq!(over, OVER, "tool descriptions over 700 characters"); + assert!( + over.is_empty(), + "tool descriptions over 700 characters: {over:?}" + ); } /// The tools docs/mcp-interface.md documents: a line (or a bullet) @@ -1528,3 +1545,100 @@ fn a_tool_list_is_cached_only_for_its_caller() { } }); } + +/// Plan 4.6 (P5 step 1): the envelope is put on every result in one +/// place; a tool that can change the books answers with the journal's +/// `head` and at least one next step, whatever it said itself, and the +/// text block says the same as the structured content. +#[test] +fn every_mutating_result_has_head_and_next_steps() { + use rmcp::model::{CallToolResponse, CallToolResult, ContentBlock}; + let dir = unit_dir("envelope"); + let server = served(store(&dir)); + let (seq, hash) = server.lock().unwrap().head(); + let mutating = next_steps::mutating_tools(); + assert!(mutating.contains("books.post_entry") && !mutating.contains("books.trial_balance")); + let mut read_only = 0; + for tool in BooksServer::tool_router().list_all() { + let name = tool.name.to_string(); + let answer = CallToolResponse::Complete(CallToolResult::structured( + serde_json::json!({"status": "ok"}), + )); + let CallToolResponse::Complete(r) = + next_steps::apply(&server, &name, None, Ok(answer)).expect("an answer stays an answer") + else { + panic!("{name}: not a complete answer"); + }; + let body = r.structured_content.expect("structured"); + assert!( + body.get("guards").is_none() && body.get("warnings").is_none(), + "{name}: a success without them says none" + ); + let ContentBlock::Text(t) = &r.content[0] else { + panic!("{name}: no text block"); + }; + assert_eq!( + serde_json::from_str::(&t.text).unwrap(), + body, + "{name}: the text says what the structure says" + ); + if mutating.contains(&name) { + assert_eq!(body["head"]["seq"], seq, "{name}"); + assert_eq!(body["head"]["hash"], hash.as_str(), "{name}"); + let steps = body["next_steps"].as_array().expect("next_steps"); + assert!(!steps.is_empty(), "{name}: no next step"); + for s in steps { + assert!(s["why"].is_string() && s["required"].is_boolean(), "{name}"); + } + } else { + read_only += 1; + assert!(body.get("head").is_none(), "{name}: a read has no head"); + } + } + assert!(read_only > 20); +} + +/// A tool's own `next_steps`, `warnings` and `head` are kept (the +/// envelope fills only what the result lacks), and a refusal that is a +/// guard carries it. +#[test] +fn the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard() { + use rmcp::model::{CallToolResponse, CallToolResult}; + let dir = unit_dir("envelope-keeps"); + let server = served(store(&dir)); + let own = serde_json::json!({ + "next_steps": [{"tool": "books.digest", "why": "mine", "required": false}], + "warnings": ["careful"], + "head": {"seq": 0, "hash": "x"}, + }); + let CallToolResponse::Complete(r) = next_steps::apply( + &server, + "books.post_entry", + None, + Ok(CallToolResponse::Complete(CallToolResult::structured( + own.clone(), + ))), + ) + .unwrap() else { + panic!() + }; + let body = r.structured_content.unwrap(); + assert_eq!(body["next_steps"], own["next_steps"]); + assert_eq!(body["warnings"], own["warnings"]); + assert_eq!(body["head"], own["head"]); + let refused = CallToolResponse::Complete(CallToolResult::error(vec![ + rmcp::model::ContentBlock::text( + serde_json::json!({"code": "approval_required", "message": "needs approval", "remedy_tool": "books.approve_entry"}) + .to_string(), + ), + ])); + let CallToolResponse::Complete(r) = + next_steps::apply(&server, "books.post_entry", None, Ok(refused)).unwrap() + else { + panic!() + }; + assert_eq!(r.is_error, Some(true)); + let g = &r.structured_content.unwrap()["guards"][0]; + assert_eq!(g["code"], "approval_required"); + assert_eq!(g["remedy_tool"], "books.approve_entry"); +} diff --git a/crates/ledger-mcp/src/tools/audit.rs b/crates/ledger-mcp/src/tools/audit.rs new file mode 100644 index 0000000..b878785 --- /dev/null +++ b/crates/ledger-mcp/src/tools/audit.rs @@ -0,0 +1,780 @@ +//! The audit group (plan P8; rules.md B-45): an audit run over the books, +//! its workpaper, and the claims that someone checked. +//! +//! ```text +//! GOAL: the whole re-audit through the tools: one programme of checks +//! that gives the same findings and the same workpaper for the same +//! books, and attestations that rest on a head that has not moved. +//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit +//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53 +//! (an audit is a run, not a job), IZ-1k (the head). +//! INVARIANTS: +//! 1. `books.audit` previews (what it will check; nothing changes), +//! then with the preview's digest runs the checks, files the +//! workpaper (JSON and PDF) as documents and journals `audit.run`. +//! 2. The same books (the figure head), day, scope and programme give +//! the run that exists: nothing is filed or journaled twice. +//! 3. An attestation is a claim by the caller named by its token; the +//! method it gives is self-reported and the books say so. +//! 4. `counted` is read against `policy.attestation_bar` as it stands +//! when asked, never stored. +//! 5. A period is attested only on an audit at the current head. +//! 6. Nothing here moves a figure. +//! ``` + +use super::*; +use ledger_core::audit::{self, AttestationBar, AuditInput, Effort, Finding, Method, Scope}; +use ledger_store::audit::{self as saudit, Attested, AuditRun, BackupStanding, Claim}; +use sha2::{Digest, Sha256}; + +fn sha_hex(bytes: &[u8]) -> String { + Sha256::digest(bytes) + .iter() + .map(|b| format!("{b:02x}")) + .collect() +} + +fn err(e: impl std::fmt::Display) -> String { + e.to_string() +} + +/// The caller's token name, else the default token's. +fn caller_name() -> String { + CALLER + .with(|c| c.borrow().as_ref().map(|(_, name)| name.clone())) + .unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string()) +} + +fn method_of(m: Option) -> Result { + let m = m.unwrap_or_default(); + let reasoning_effort = match m.reasoning_effort.as_deref() { + None => None, + Some(e) => Some(Effort::parse(e).ok_or_else(|| { + format!("reasoning_effort {e:?} is not low, medium, high, xhigh or max") + })?), + }; + if m.verifier_agents == Some(0) { + return Err("verifier_agents is at least 1".into()); + } + Ok(Method { + model: m + .model + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()), + reasoning_effort, + adversarial: m.adversarial, + verifier_agents: m.verifier_agents, + }) +} + +fn method_out(m: &Method) -> AttestMethodParam { + AttestMethodParam { + model: m.model.clone(), + reasoning_effort: m.reasoning_effort.map(|e| e.key().to_string()), + adversarial: m.adversarial, + verifier_agents: m.verifier_agents, + } +} + +fn finding_out(f: &Finding) -> AuditFindingOut { + AuditFindingOut { + severity: f.severity.key().into(), + code: f.code.into(), + subject: f.subject.describe(), + message: f.message.clone(), + } +} + +/// Every check the programme runs. +fn checks() -> Vec { + audit::CODES + .iter() + .chain(saudit::CODES) + .map(|r| AuditCheckOut { + code: r.code.into(), + severity: r.severity.key().into(), + predicate: r.predicate.into(), + }) + .collect() +} + +fn run_out(store: &Store, run: &AuditRun) -> Result { + Ok(AuditRunOut { + id: run.id, + through: run.through.to_string(), + through_seq: run.through_seq, + scope: run.scope.key().into(), + program_version: run.program_version.clone(), + tb_hash: run.tb_hash.clone(), + findings_sha256: run.findings_sha256.clone(), + workpaper_attachment: run.workpaper_attachment, + workpaper_pdf: run.workpaper_pdf, + at_current_head: store.audit_is_current(run).map_err(err)?, + }) +} + +fn summary(findings: &[Finding]) -> String { + let n = |s: audit::Severity| findings.iter().filter(|f| f.severity == s).count(); + format!( + "{} failure(s), {} warning(s), {} note(s)", + n(audit::Severity::Failure), + n(audit::Severity::Warning), + n(audit::Severity::Info) + ) +} + +/// Why an attestation does not count at this bar, in words. +fn why_not(bar: &AttestationBar, m: &Method, full: bool, disagrees: bool) -> Option { + if bar.counts(m, full, disagrees) { + return None; + } + if disagrees { + return Some( + "the amount or date read off the receipt differs from the entry's: it is recorded and never counts" + .into(), + ); + } + let mut why = Vec::new(); + if !m + .reasoning_effort + .is_some_and(|e| e >= bar.reasoning_effort) + { + why.push(format!( + "reasoning_effort must be at least {}", + bar.reasoning_effort.key() + )); + } + if full { + if bar.adversarial && m.adversarial != Some(true) { + why.push("the check must be adversarial".into()); + } + if !m.verifier_agents.is_some_and(|n| n >= bar.verifier_agents) { + why.push(format!( + "at least {} independent verifier agents", + bar.verifier_agents + )); + } + } + Some(format!( + "below policy.attestation_bar ({}): {}", + bar.describe(), + why.join("; ") + )) +} + +/// An attestation as the tools list it. +fn attestation_out( + store: &Store, + a: &saudit::Attestation, + bar: &AttestationBar, +) -> Result { + let mut out = AttestationOut { + seq: a.seq, + kind: a.kind().into(), + by: a.by.clone(), + at: a.at.clone(), + attachment_id: None, + entry_id: None, + period_id: None, + audit_run_id: None, + amount_checked: None, + date_checked: None, + disagrees: false, + method: method_out(&a.method), + note: a.note.clone(), + counted: false, + }; + let (full, disagrees) = match &a.what { + Attested::Receipt { + attachment_id, + entry_id, + amount_checked, + date_checked, + disagrees, + } => { + out.attachment_id = Some(*attachment_id); + out.entry_id = Some(*entry_id); + out.amount_checked = Some(amount_checked.to_string()); + out.date_checked = Some(date_checked.to_string()); + out.disagrees = *disagrees; + (false, *disagrees) + } + Attested::Entry { entry_id } => { + out.entry_id = Some(*entry_id); + (false, false) + } + Attested::Period { + period_id, + audit_run_id, + } => { + out.period_id = Some(*period_id); + out.audit_run_id = Some(*audit_run_id); + let scope = store + .audit_run(*audit_run_id) + .map_err(err)? + .map(|r| r.scope); + (scope.is_some_and(Scope::full_bar), false) + } + }; + out.counted = bar.counts(&a.method, full, disagrees); + Ok(out) +} + +/// What an attestation's tool answers. +fn attest_out(store: &Store, a: &saudit::Attestation) -> Result { + let bar = store.ledger().settings().attestation_bar(); + let o = attestation_out(store, a, &bar)?; + let full = o + .audit_run_id + .and_then(|id| store.audit_run(id).ok().flatten()) + .is_some_and(|r| r.scope.full_bar()); + let why_not_counted = why_not(&bar, &a.method, full, o.disagrees); + Ok(AttestOut { + attestation: o, + bar: bar.describe(), + why_not_counted, + }) +} + +impl BooksServer { + /// Whether the CPA package may be made (plan P8 step 4, D-9): on books + /// that declared `audits` and a policy of refuse, an audit for the + /// handoff (scope cpa_handoff or full) at the current head must have a + /// period attested on it at the bar. `Ok(None)` when it may; the + /// refusal's words otherwise. + pub(crate) fn cpa_audit_standing(&self, store: &Store) -> Result, String> { + if !store + .journal_format() + .has_feature(ledger_store::format::AUDITS) + { + return Ok(None); + } + let settings = store.ledger().settings(); + let bar = settings.attestation_bar(); + let attestations = store.attestations().map_err(err)?; + for run in store.audit_runs().map_err(err)?.iter().rev() { + if !run.scope.serves_handoff() || !store.audit_is_current(run).map_err(err)? { + continue; + } + let counted = attestations.iter().any(|a| { + matches!(a.what, Attested::Period { audit_run_id, .. } if audit_run_id == run.id) + && bar.counts(&a.method, run.scope.full_bar(), false) + }); + if counted { + return Ok(None); + } + } + Ok(Some(format!( + "no audit for the handoff (books.audit with scope cpa_handoff or full) stands at the books' current head with a period attested at the bar ({}); run the audit, have it verified (books.attest_receipt, books.review_entry, books.attest_period), or pass without_audit_reason", + bar.describe() + ))) + } + + /// The findings the programme gives the books through `through`: the + /// statements, then what only the store, the backups and the settings + /// can see. Reads only. + fn audit_findings( + &self, + store: &Store, + through: NaiveDate, + ) -> Result<(Vec, String), String> { + let ledger = store.ledger(); + let cfg = self.settings(); + let ps = package_settings(&cfg); + let receipts = store.entries_with_receipts().map_err(err)?; + // The statement of cash flows, from the first entry, with the + // books' role settings; none when they name no cash accounts. + let first = ledger + .posted_entries() + .filter(|e| e.date() <= through) + .map(|e| e.date()) + .min(); + let cash = match first { + Some(start) if !ps.cash_accounts.is_empty() => Some( + ledger_package::cash_flow_for( + ledger, + &ps, + Some(&start.to_string()), + Some(&through.to_string()), + &[], + &[], + &[], + today(), + ) + .map(|s| s.reconciles), + ), + _ => None, + }; + // Vendors paid in a way that could need a 1099 and undecided; a + // year whose threshold isn't known is the stale-rule item's. + use chrono::Datelike; + let undecided: Vec<(String, rust_decimal::Decimal)> = + ledger_package::vendor_1099_report(ledger, &ps, through.year(), None, Some(through)) + .map(|r| { + r.vendors + .iter() + .filter(|v| v.decision_needed) + .map(|v| (v.name.clone(), v.paid)) + .collect() + }) + .unwrap_or_default(); + let problems = settings_problems(ledger, &cfg); + let mut findings = audit::statements( + ledger, + &AuditInput { + settings: ledger.settings(), + receipts: &receipts, + cash_flow: cash.as_ref(), + settings_problems: &problems, + undecided_1099: &undecided, + }, + through, + ); + findings.extend(store.integrity_findings()); + let standing = self.backups.standing(store); + findings.extend(saudit::backup_findings( + &standing + .targets + .iter() + .map(|t| BackupStanding { + target: format!("{} {}", t.target, t.at), + stale: t.stale, + last_error: t.last_error.clone(), + drill_error: t.drill_error.clone(), + }) + .collect::>(), + )); + audit::sort(&mut findings); + let tb = ledger_core::reports::trial_balance_as_of(ledger, through); + let tb_hash = sha_hex(&serde_json::to_vec(&tb).map_err(err)?); + Ok((findings, tb_hash)) + } +} + +#[tool_router(router = audit_router, vis = "pub(super)")] +impl BooksServer { + /// ```text + /// GOAL: the whole programme of checks over the books through a day, + /// its workpaper filed, the run journaled. + /// SOURCE: plan/served.md P8; B-45; D-9, D-53. + /// INVARIANTS: + /// 1. Without `confirmed`, a preview that lists the checks and + /// changes nothing; with it, the preview's digest is required. + /// 2. Idempotent on the figure head, day, scope and programme. + /// 3. The workpaper is JSON and PDF, filed as documents (category + /// workpaper), the PDF with deterministic bytes. + /// SCENARIOS: + /// 1. IF the books haven't declared audits: the run is refused with + /// format_upgrade_pending; the preview still lists the checks. + /// 2. IF the same audit is asked at the same head: SHOULD return + /// the first run, status already_run. + /// ``` + #[tool( + name = "books.audit", + title = "Audit the books", + description = "Audit the books through a day (through, default today) at a scope: month, annual, cpa_handoff (the year, for the CPA package) or full. It checks the statements, the A/R and A/P control accounts, closes, schedules, assets, each posted entry against the receipt policy, open drafts, tax categories, 1099 decisions and settings, then the journal, stored files, access log and backups. Without confirmed, a preview of the checks, changing nothing. With confirmed and the preview, it files the workpaper (books.workpaper) and journals the run; the same books, day and scope give the run that exists. Attestations rest on a run only while no entry, document, close, settings or role change follows it", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn audit(&self, params: Parameters) -> Result, String> { + let p = params.0; + let scope = Scope::parse(&p.scope).ok_or_else(|| { + format!( + "bad scope {:?}: expected month, annual, cpa_handoff or full", + p.scope + ) + })?; + let through = self.as_of(p.through.as_deref())?; + let confirmed = p.confirmed.unwrap_or(false); + let mut store = self.lock()?; + let head = store.figure_head().map_err(err)?; + let digest = self.preview_of(&store, "books.audit", confirmed, p.preview.as_deref())?; + if let Some(run) = store.find_audit_run(through, head, scope).map_err(err)? { + let findings = self.stored_findings(&store, &run)?; + return Ok(Json(AuditOut { + status: "already_run".into(), + preview: Some(digest), + head, + checks: checks(), + summary: Some(summary_of(&findings)), + run: Some(run_out(&store, &run)?), + findings, + })); + } + if !confirmed { + return Ok(Json(AuditOut { + status: "preview".into(), + preview: Some(digest), + head, + checks: checks(), + run: None, + findings: Vec::new(), + summary: None, + })); + } + store + .ensure_feature(ledger_store::format::AUDITS) + .map_err(err)?; + let (findings, tb_hash) = self.audit_findings(&store, through)?; + let findings_sha256 = sha_hex(&serde_json::to_vec(&findings).map_err(err)?); + let summary = summary(&findings); + let company = self.settings().company.name.clone(); + let rows: Vec = findings.iter().map(finding_out).collect(); + let check_rows = checks(); + let paper = serde_json::json!({ + "company": company, + "title": "Audit workpaper", + "scope": scope.key(), + "through": through.to_string(), + "through_seq": head, + "program_version": saudit::PROGRAM_VERSION, + "tb_hash": tb_hash, + "findings_sha256": findings_sha256, + "summary": summary, + "findings": rows, + "checks": check_rows, + }); + let json_bytes = serde_json::to_vec_pretty(&paper).map_err(err)?; + let pdf_bytes = ledger_import::workpaper_pdf(&ledger_import::Workpaper { + company, + title: "Audit workpaper".into(), + scope: scope.key().into(), + through: through.to_string(), + through_seq: head, + program_version: saudit::PROGRAM_VERSION.into(), + tb_hash: tb_hash.clone(), + findings_sha256: findings_sha256.clone(), + summary: summary.clone(), + findings: rows + .iter() + .map(|f| ledger_import::WorkpaperFinding { + severity: f.severity.clone(), + code: f.code.clone(), + subject: f.subject.clone(), + message: f.message.clone(), + }) + .collect(), + checks: check_rows + .iter() + .map(|c| ledger_import::WorkpaperCheck { + code: c.code.clone(), + severity: c.severity.clone(), + predicate: c.predicate.clone(), + }) + .collect(), + }) + .map_err(err)?; + let stem = format!("workpaper-{}-{through}-{head}", scope.key()); + let describe = format!( + "Audit workpaper, scope {}, through {through}, books at figure head {head} ({}).", + scope.key(), + saudit::PROGRAM_VERSION + ); + let (wp_json, _, _) = store + .file_document( + "workpaper", + &format!("{stem}.json"), + "application/json", + &json_bytes, + Some(&describe), + ) + .map_err(err)?; + let (wp_pdf, _, _) = store + .file_document( + "workpaper", + &format!("{stem}.pdf"), + "application/pdf", + &pdf_bytes, + Some(&describe), + ) + .map_err(err)?; + // A month's or a year's workpaper belongs with its period. + if matches!(scope, Scope::Month | Scope::Annual) + && let Some(period) = store + .ledger() + .list_periods() + .into_iter() + .filter(|q| q.start <= through && through <= q.end) + .min_by_key(|q| (q.end - q.start).num_days()) + .map(|q| q.id) + { + for a in [wp_json, wp_pdf] { + let _ = store.link_attachment(a, ledger_store::Subject::Period(period)); + } + } + let run = store + .record_audit_run(saudit::NewAuditRun { + through, + through_seq: head, + scope, + tb_hash, + findings_sha256, + workpaper_attachment: Some(wp_json), + workpaper_pdf: Some(wp_pdf), + }) + .map_err(err)?; + Ok(Json(AuditOut { + status: "audited".into(), + preview: Some(digest), + head, + checks: check_rows, + summary: Some(summary), + run: Some(run_out(&store, &run)?), + findings: rows, + })) + } + + /// ```text + /// GOAL: an audit run's workpaper, whole. + /// SOURCE: plan/served.md P8 step 3; B-45. + /// INVARIANTS: + /// 1. Read-only; the JSON is the one filed, the PDF is handed over. + /// ``` + #[tool( + name = "books.workpaper", + title = "An audit's workpaper", + description = "The workpaper of an audit run (books.audit's run.id): what was checked, through which day and state of the books, and every finding, as the JSON that was filed, with the PDF handed over (run its download command to save it in a folder). Changes nothing.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn workpaper( + &self, + params: Parameters, + ) -> Result, String> { + let store = self.lock()?; + let run = store.audit_run(params.0.id).map_err(err)?.ok_or_else(|| { + format!( + "no audit run {}: books.attestations lists the ones attested", + params.0.id + ) + })?; + let json_id = run + .workpaper_attachment + .ok_or("this audit run filed no workpaper")?; + let bytes = store.attachment_bytes(json_id).map_err(err)?; + let workpaper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?; + let pdf = match run.workpaper_pdf { + Some(id) => { + let bytes = store.attachment_bytes(id).map_err(err)?; + Some(self.hand_over( + &book_name(&store), + &format!( + "workpaper-{}-{}-{}.pdf", + run.scope.key(), + run.through, + run.through_seq + ), + "application/pdf", + bytes, + )?) + } + None => None, + }; + Ok(Json(WorkpaperOut { + run: run_out(&store, &run)?, + workpaper, + pdf, + })) + } + + /// ```text + /// GOAL: a checker's claim that a receipt supports an entry. + /// SOURCE: plan/served.md P8 step 4; B-45; D-9. + /// INVARIANTS: + /// 1. The reading is compared with the entry's amount and date; a + /// difference is recorded, marked, and never counts. + /// 2. The method is the client's claim, self-reported. + /// ``` + #[tool( + name = "books.attest_receipt", + title = "Attest a receipt", + description = "Record that you read a receipt and it supports a posted entry: amount and date as you read them off it (get_receipt text, then page_image), and how you checked (method: model, reasoning_effort, adversarial, verifier_agents; your own claim, which the books record but cannot verify). The books compare the reading with the entry's amounts and date; a difference is recorded as disagrees and never counts. Whether it counts is weighed against policy.attestation_bar when asked.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn attest_receipt( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let amount = parse_amount(&p.amount)?; + let date = NaiveDate::parse_from_str(p.date.trim(), "%Y-%m-%d") + .map_err(|_| format!("{:?} isn't a date: give YYYY-MM-DD", p.date))?; + let claim = Claim { + by: caller_name(), + method: method_of(p.method)?, + note: p.note, + }; + let mut store = self.lock()?; + let a = store + .attest_receipt(p.attachment_id, p.entry_id, amount, date, claim) + .map_err(err)?; + Ok(Json(attest_out(&store, &a)?)) + } + + /// ```text + /// GOAL: a checker's claim that it reviewed an entry whole. + /// SOURCE: plan/served.md P8 step 4; B-45. + /// ``` + #[tool( + name = "books.review_entry", + title = "Review an entry", + description = "Record that you reviewed a posted entry whole: its lines, accounts, party and evidence, and how you checked (method, as for books.attest_receipt; your own claim). Changes no figure.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn review_entry( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let claim = Claim { + by: caller_name(), + method: method_of(p.method)?, + note: p.note, + }; + let mut store = self.lock()?; + let a = store.review_entry(p.entry_id, claim).map_err(err)?; + Ok(Json(attest_out(&store, &a)?)) + } + + /// ```text + /// GOAL: a checker's claim that a period is right, resting on an + /// audit run at the books' current head. + /// SOURCE: plan/served.md P8 step 4; B-45; D-9; IZ-1k. + /// INVARIANTS: + /// 1. Refused unless an audit through the period's end stands at + /// the current head. + /// ``` + #[tool( + name = "books.attest_period", + title = "Attest a period", + description = "Record that you checked a period (its id or name from books.list_periods), resting on an audit (books.audit) through its end that still stands: refused when an entry, document, close, settings or role change has been made since the audit, or no audit covers it. Say how you checked (method, as for books.attest_receipt; your own claim). A year-level audit (annual, cpa_handoff, full) is held to the whole policy.attestation_bar: reasoning effort, adversarial, and the number of independent verifier agents.", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn attest_period( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let claim = Claim { + by: caller_name(), + method: method_of(p.method)?, + note: p.note, + }; + let mut store = self.lock()?; + let named = p.period.trim(); + let period = store + .ledger() + .list_periods() + .into_iter() + .find(|q| q.name == named || q.id.to_string() == named) + .map(|q| q.id) + .ok_or_else(|| format!("no period {named:?}: books.list_periods lists them"))?; + let (a, _) = store.attest_period(period, claim).map_err(|e| match e { + ledger_store::StoreError::AuditRefused(why) => serde_json::json!({ + "code": "audit_required", + "message": why, + "remedy_tool": "books.audit", + }) + .to_string(), + other => other.to_string(), + })?; + Ok(Json(attest_out(&store, &a)?)) + } + + /// ```text + /// GOAL: every claim that someone checked, and whether it counts. + /// SOURCE: plan/served.md P8 step 4; B-45; D-9. + /// INVARIANTS: + /// 1. `counted` is read against the bar as it stands now. + /// ``` + #[tool( + name = "books.attestations", + title = "Attestations", + description = "List the attestations (receipts read, entries reviewed, periods attested), oldest first, each with who made it, how they say they checked, and counted: whether it meets policy.attestation_bar as the bar stands now (a change to the bar changes what counts, earlier claims too). through limits to entries dated, and periods ending, on or before a day; kind to receipt, entry or period. Changes nothing.", + annotations(read_only_hint = true, open_world_hint = false) + )] + async fn attestations( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let through = p + .through + .as_deref() + .map(|t| self.as_of(Some(t))) + .transpose()?; + if let Some(k) = p.kind.as_deref() + && !["receipt", "entry", "period"].contains(&k) + { + return Err("kind is receipt, entry or period".into()); + } + let store = self.lock()?; + let ledger = store.ledger(); + let bar = ledger.settings().attestation_bar(); + let mut out = Vec::new(); + for a in store.attestations().map_err(err)? { + if p.kind.as_deref().is_some_and(|k| k != a.kind()) { + continue; + } + if let Some(t) = through { + let on = match a.what { + Attested::Receipt { entry_id, .. } | Attested::Entry { entry_id } => { + ledger.get_entry(entry_id).map(|e| e.date()) + } + Attested::Period { period_id, .. } => { + ledger.get_period(period_id).map(|q| q.end) + } + }; + if on.is_none_or(|d| d > t) { + continue; + } + } + out.push(attestation_out(&store, &a, &bar)?); + } + Ok(Json(AttestationsOut { + bar: bar.describe(), + counted: out.iter().filter(|a| a.counted).count(), + attestations: out, + })) + } +} + +impl BooksServer { + /// The findings of a run, as its filed workpaper holds them. + fn stored_findings( + &self, + store: &Store, + run: &AuditRun, + ) -> Result, String> { + let Some(id) = run.workpaper_attachment else { + return Ok(Vec::new()); + }; + let bytes = store.attachment_bytes(id).map_err(err)?; + let paper: serde_json::Value = serde_json::from_slice(&bytes).map_err(err)?; + serde_json::from_value(paper["findings"].clone()).map_err(err) + } +} + +fn summary_of(findings: &[AuditFindingOut]) -> String { + let n = |s: &str| findings.iter().filter(|f| f.severity == s).count(); + format!( + "{} failure(s), {} warning(s), {} note(s)", + n("failure"), + n("warning"), + n("info") + ) +} diff --git a/crates/ledger-mcp/src/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs index 56178db..c5cc3ed 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -455,17 +455,7 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let mut store = self.lock()?; - let customer = match (&p.customer, p.party_id) { - (Some(c), None) => c.trim().to_string(), - (None, Some(id)) => id.to_string(), - (Some(c), Some(id)) if c.trim() == id.to_string() => c.trim().to_string(), - (Some(_), Some(_)) => { - return Err("give the customer once: customer or party_id, not both".into()); - } - (None, None) => { - return Err("give customer: a customer id, a party id or a name".into()); - } - }; + let customer = p.customer.trim().to_string(); let customer_id = resolve_customer(&store, &customer)?; let date = parse_date(&p.date)?; let lines = document_lines(&store, p.lines)?; @@ -606,17 +596,7 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let mut store = self.lock()?; - let vendor = match (&p.vendor, p.party_id) { - (Some(v), None) => v.trim().to_string(), - (None, Some(id)) => id.to_string(), - (Some(v), Some(id)) if v.trim() == id.to_string() => v.trim().to_string(), - (Some(_), Some(_)) => { - return Err("give the vendor once: vendor or party_id, not both".into()); - } - (None, None) => { - return Err("give vendor: a vendor id, a party id or a name".into()); - } - }; + let vendor = p.vendor.trim().to_string(); let vendor_id = resolve_vendor_arg(&store, &vendor)?; let date = parse_date(&p.date)?; let received_on = p.received_on.as_deref().map(parse_date).transpose()?; diff --git a/crates/ledger-mcp/src/tools/entries.rs b/crates/ledger-mcp/src/tools/entries.rs index b4d00db..6e3c7fb 100644 --- a/crates/ledger-mcp/src/tools/entries.rs +++ b/crates/ledger-mcp/src/tools/entries.rs @@ -706,7 +706,7 @@ impl BooksServer { #[tool( name = "books.import_bank_csv", title = "Import a bank CSV as drafts", - description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Give the CSV as upload_id: upload it with books.begin_upload purpose import and `wanderbooks upload` (a file never passes through you). Review with books.digest, then post. File the export too (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt).", + description = "Import a bank or card CSV (Date, Description, Amount; or Date, Description, money out, money in) as one DRAFT entry per transaction, all or none: the bank account on one side, a categorized offset on the other. Give the CSV as upload_id (books.begin_upload purpose import; a file never passes through you). Ambiguous slash dates need date_order. Review with books.digest, then post. Also file the export (books.file_document category import_source) and pass source_attachment_id. Details in books.handbook section files-in-out.", annotations( read_only_hint = false, destructive_hint = false, diff --git a/crates/ledger-mcp/src/tools/journal.rs b/crates/ledger-mcp/src/tools/journal.rs new file mode 100644 index 0000000..fe40299 --- /dev/null +++ b/crates/ledger-mcp/src/tools/journal.rs @@ -0,0 +1,137 @@ +//! `books.journal_history`: the journal in words (plan P5 step 4). +//! +//! ```text +//! GOAL: an agent can trace what the books did, line by line, without the +//! journal file: the only way to "read the journal". +//! SOURCE: plan/served.md 4.3 (tracing figures), P5 step 4; the describer +//! is ledger_store::describe. +//! INVARIANTS: +//! 1. Read-only; needs books:read; at most 200 lines a call, 50 by +//! default, in journal order from the line after `after_seq`. +//! 2. A sentence quotes no setting value and no full TIN +//! (ledger_store::describe INVARIANT 2). +//! 3. `subject` is `kind:id`; a bad one is refused with the shapes. +//! ``` + +use super::*; +use ledger_store::describe::{HistoryFilter, parse_subject}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; + +/// The most lines one call returns. +const HISTORY_MAX: usize = 200; +/// The lines a call returns when it names no limit. +const HISTORY_DEFAULT: usize = 50; + +/// Parameters for `books.journal_history`. +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct JournalHistoryParams { + /// Only lines after this journal seq (default: from the first). + #[serde(default)] + pub after_seq: Option, + /// Only these line types: an exact type (`entry.posted`) or a group + /// ending in a dot (`entry.`). + #[serde(default)] + pub types: Vec, + /// Only lines about one subject: `entry:12`, `invoice:3`, `bill:3`, + /// `account:10`, `party:5` or `asset:2`. + #[serde(default)] + pub subject: Option, + /// At most this many lines (default 50, most 200). + #[serde(default)] + pub limit: Option, +} + +/// One journal line, in words. +#[derive(Debug, Serialize, JsonSchema)] +pub struct JournalLineOut { + /// Its seq. + pub seq: u64, + /// When it was written (UTC). + pub ts: String, + /// Its type, e.g. `entry.posted`. + #[serde(rename = "type")] + pub line_type: String, + /// What it did, in a sentence. + pub summary: String, + /// What it is about, as `kind:id`, if it is about one thing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub subject: Option, + /// The entry it belongs to, if it is about one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_id: Option, +} + +/// A torn journal tail set aside when the books opened. +#[derive(Debug, Serialize, JsonSchema)] +pub struct TailRepairOut { + /// When (UTC). + pub at: String, + /// How many bytes were set aside. + pub bytes: u64, +} + +/// Result of `books.journal_history`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct JournalHistoryOut { + /// The lines, in journal order. + pub lines: Vec, + /// Whether more lines match after the last (ask again with its seq as + /// after_seq). + pub more: bool, + /// The journal's last seq. + pub head_seq: u64, + /// Torn tails set aside on open, oldest first. + pub tail_repairs: Vec, +} + +#[tool_router(router = journal_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.journal_history", + title = "Journal history", + description = "The journal in words, in order: each line's seq, time, type and a sentence saying what it did, to what, and the entry it belongs to. Filter by after_seq, types (an exact type like entry.posted, or a group like entry.) and subject (entry:12, invoice:3, bill:3, account:10, party:5, asset:2). The way to trace a figure back to what produced it, and the only way to see what the journal holds. Read-only; at most 200 lines a call.", + annotations(read_only_hint = true, open_world_hint = false) + )] + pub(super) async fn journal_history( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let subject = p + .subject + .as_deref() + .map(|s| parse_subject(s).map(|(k, id)| format!("{k}:{id}"))) + .transpose()?; + let limit = (p.limit.unwrap_or(HISTORY_DEFAULT as u32) as usize).clamp(1, HISTORY_MAX); + let filter = HistoryFilter { + after_seq: p.after_seq.unwrap_or(0), + types: p.types, + subject, + limit, + }; + let store = self.lock()?; + let h = store.journal_history(&filter).map_err(|e| e.to_string())?; + Ok(Json(JournalHistoryOut { + lines: h + .lines + .into_iter() + .map(|d| JournalLineOut { + seq: d.seq, + ts: d.ts, + line_type: d.line_type, + summary: d.summary, + subject: d.subjects.first().cloned(), + entry_id: d.entry_id, + }) + .collect(), + more: h.more, + head_seq: h.head_seq, + tail_repairs: h + .tail_repairs + .into_iter() + .map(|(at, bytes)| TailRepairOut { at, bytes }) + .collect(), + })) + } +} diff --git a/crates/ledger-mcp/src/tools/kept.rs b/crates/ledger-mcp/src/tools/kept.rs index 7d4e859..cff182f 100644 --- a/crates/ledger-mcp/src/tools/kept.rs +++ b/crates/ledger-mcp/src/tools/kept.rs @@ -94,11 +94,35 @@ pub struct StartParams { /// (shapes next_steps). Default: daily. #[serde(default)] pub focus: Option, + /// What the agent says about itself: its model, reasoning effort and + /// harness. Self-reported and never checked; written to the access + /// log beside the call and used for nothing else. + #[serde(default)] + pub agent: Option, +} + +/// What an agent says about itself (`books.start`'s `agent`). +#[derive(Debug, Default, Deserialize, JsonSchema)] +pub struct AgentIn { + /// The model's name ("claude-opus-4"). + #[serde(default)] + pub model: Option, + /// The reasoning effort it runs at ("high"). + #[serde(default)] + pub reasoning_effort: Option, + /// The program driving it ("claude-code"). + #[serde(default)] + pub harness: Option, } /// The books, in brief. #[derive(Debug, Serialize, JsonSchema)] pub struct StartBookOut { + /// The books' id on this server. + pub id: String, + /// Where these books answer on this server (`/b/ID/mcp`): never a + /// path on any disk. + pub url: String, /// The company's name. pub name: String, /// Its time zone (IANA), or null: the computer's. @@ -222,12 +246,115 @@ pub struct FormatUpgradeOut { #[derive(Debug, Serialize, JsonSchema)] pub struct NextStepOut { /// The tool to call. - pub tool: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, + /// Or the prompt to run. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, /// Its arguments, if any. #[serde(default, skip_serializing_if = "Option::is_none")] pub args: Option, /// Why. pub why: String, + /// Whether the work is not done without it. + pub required: bool, +} + +/// Who is asking, as the books know them (the token's own facts). +#[derive(Debug, Serialize, JsonSchema)] +pub struct YouOut { + /// The token's name. + pub principal: String, + /// Always `agent` here. + pub kind: String, + /// What the token may do: books:read, books:write, books:post, + /// books:admin. + pub scopes: Vec, + /// The client program, as it named itself. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub client_id: Option, +} + +/// A draft waiting on its evidence. +#[derive(Debug, Serialize, JsonSchema)] +pub struct StartDraftOut { + /// The draft entry. + pub entry_id: u64, + /// Its date. + pub date: String, + /// Its total debits. + pub amount: String, + /// What it waits for. + pub note: String, +} + +/// The open drafts. +#[derive(Debug, Serialize, JsonSchema)] +pub struct StartDraftsOut { + /// Drafts being built. + pub building: u32, + /// Drafts finished and waiting for their documents (books.list_drafts + /// has every one). + pub awaiting_evidence: Vec, +} + +/// Where reconciliation stands. +#[derive(Debug, Serialize, JsonSchema)] +pub struct StartReconciliationOut { + /// The accounts reconciled to statements (codes). + pub tracked: Vec, + /// Of those, never reconciled. + pub never_reconciled: Vec, + /// Of those, reconciled but behind. + pub behind: Vec, +} + +/// Where the books' backups stand. +#[derive(Debug, Serialize, JsonSchema)] +pub struct StartBackupOut { + /// When the latest backup took (UTC), if any did. + pub last_at: Option, + /// The journal seq the latest holds. + pub through_seq: Option, + /// Whether one is stale or failing, or none is set up. + pub stale: bool, +} + +/// A prompt, in brief. +#[derive(Debug, Serialize, JsonSchema)] +pub struct StartPromptOut { + /// Its name. + pub name: String, + /// The arguments it takes. + pub args: Vec, +} + +/// One thing the books need. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AttentionBriefOut { + /// What it is, by kind and subject. + pub id: String, + /// problem, todo or info. + pub severity: String, + /// Its kind (`missing_receipt`, `reconcile`, ...). + pub kind: String, + /// What is wrong, in a line. + pub title: String, + /// The tool that starts the fix, if there is one. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool: Option, + /// Its arguments, if any. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub args: Option, +} + +/// What needs attention: the most urgent, and how many of each kind. +#[derive(Debug, Serialize, JsonSchema)] +pub struct NeedsAttentionOut { + /// The most urgent (problems first), at most 12. + pub items: Vec, + /// Every item by kind. + pub counts: std::collections::BTreeMap, } /// Result of `books.start`. @@ -264,6 +391,19 @@ pub struct StartOut { /// the owner gives the go-ahead. No tool can: tell the human. #[serde(default, skip_serializing_if = "Option::is_none")] pub format_upgrade: Option, + /// Who is asking. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub you: Option, + /// The drafts open. + pub drafts: StartDraftsOut, + /// Where reconciliation stands. + pub reconciliation: StartReconciliationOut, + /// Where the backups stand. + pub backup: StartBackupOut, + /// The prompts: whole procedures (`prompts/get`). + pub prompts: Vec, + /// What needs attention, most urgent first. + pub needs_attention: NeedsAttentionOut, /// What to do next, in order. pub next_steps: Vec, /// How the books are kept from agents: "same_user (advisory)" locally @@ -392,6 +532,11 @@ pub struct SetConventionParams { /// amount_over?}. Other kinds take none. #[serde(default)] pub structured: Option, + /// Only to correct who decided it, when or on what (the text exactly + /// as it stands): why the source is wrong. The correction is journaled + /// as such, the convention's revision unchanged. + #[serde(default)] + pub reason: Option, /// Who decided, and when. #[serde(flatten)] pub source: SourceIn, @@ -412,9 +557,12 @@ pub struct RetireConventionParams { /// Parameters for `books.record_decision`. #[derive(Debug, Deserialize, JsonSchema)] pub struct RecordDecisionParams { - /// What it is about ("Materiality policy"). + /// What it is about ("Materiality policy"). Left out when `revises` is + /// given. + #[serde(default)] pub subject: String, - /// The decision in words (Markdown). + /// The decision in words (Markdown). Left out when `revises` is given. + #[serde(default)] pub decision_md: String, /// Who decided (names), at least one. pub decided_by: Vec, @@ -432,6 +580,14 @@ pub struct RecordDecisionParams { /// The projects it is about (ids or names), if any. #[serde(default)] pub projects: Vec, + /// To correct only who decided an earlier decision, when and on what + /// (decided_by, decided_on, citation: the new source), give its id here + /// with `reason`; its words stay. Journaled as a source correction. + #[serde(default)] + pub revises: Option, + /// With `revises`: why the source was wrong. + #[serde(default)] + pub reason: Option, } /// A decision. @@ -487,7 +643,8 @@ pub struct DecisionsOut { /// Parameters for `books.raise_item`. #[derive(Debug, Deserialize, JsonSchema)] pub struct RaiseItemParams { - /// What is open, in a line. + /// What is open, in a line. Left out when `revises` is given. + #[serde(default)] pub title: String, /// More, if any (Markdown). #[serde(default)] @@ -495,6 +652,14 @@ pub struct RaiseItemParams { /// When it is due, YYYY-MM-DD, if it is. #[serde(default)] pub due: Option, + /// To correct only who raised an item, when and on what (the source + /// given here), give the item's id with `reason`; the item stays as it + /// is. Journaled as a source correction. + #[serde(default)] + pub revises: Option, + /// With `revises`: why the source was wrong. + #[serde(default)] + pub reason: Option, /// Who raised it, and when. #[serde(flatten)] pub source: SourceIn, @@ -693,6 +858,15 @@ fn open_item_out(i: &ledger_core::conventions::OpenItem) -> OpenItemOut { } } +/// `books.start`'s `_meta`: a client that defers tools (tool search) loads +/// this one up front, as the connect text says to call it first. +fn always_load() -> rmcp::model::MetaObject { + let mut meta = rmcp::model::MetaObject::new(); + meta.0 + .insert("anthropic/alwaysLoad".into(), serde_json::json!(true)); + meta +} + impl BooksServer { /// The books' `books.start` (INVARIANT 4). fn start_out(&self, store: &Store, focus: Option<&str>) -> StartOut { @@ -759,9 +933,11 @@ impl BooksServer { projects.truncate(START_PROJECTS); let focus = focus.unwrap_or("daily"); let step = |tool: &str, args: Option, why: &str| NextStepOut { - tool: tool.into(), + tool: Some(tool.into()), + prompt: None, args, why: why.into(), + required: false, }; // An attention title can carry a free-text description; keep the // step short so books.start stays within START_MAX (INVARIANT 4). @@ -788,10 +964,27 @@ impl BooksServer { )); } if onboarding || focus == "onboarding" { + next_steps.push(NextStepOut { + tool: None, + prompt: Some("onboard".into()), + args: None, + why: "New books: run the onboarding procedure with the human.".into(), + required: true, + }); + next_steps.push(step( + "books.set_setting", + None, + "Record the company's facts the human gives (name, entity type, fiscal year, time zone); each change is a preview first.", + )); + next_steps.push(step( + "books.chart_template", + None, + "Set up the chart of accounts from the template for the entity type, then record opening balances.", + )); next_steps.push(step( "books.handbook", Some(serde_json::json!({"section": "contract"})), - "New books: onboard first. Ask the human for the company's facts (books.set_setting), set up the chart (books.chart_template), and record opening balances.", + "The rules, before anything is recorded.", )); } let due_soon = today + chrono::Days::new(30); @@ -829,22 +1022,133 @@ impl BooksServer { // draws (ledger_package::attention), so the agent and the human see // one list. The most urgent first (the list is sorted by severity), // at most 12: `books.start` stays small (INVARIANT 4). - { + let attention = { let attention_settings = ledger_package::Settings { company: &cfg.company, cash_accounts: &cfg.roles.cash, financing_accounts: &cfg.roles.financing, investing_accounts: &cfg.roles.investing, }; - let attention = ledger_package::find(store, today, &attention_settings); - for a in attention.iter().filter(|a| a.tool.is_some()).take(12) { - let Some((tool, args)) = &a.tool else { - continue; - }; - let args = (!args.is_null()).then(|| args.clone()); - next_steps.push(step(tool, args, &step_title(&a.title))); - } + ledger_package::find(store, today, &attention_settings) + }; + for a in attention.iter().filter(|a| a.tool.is_some()).take(12) { + let Some((tool, args)) = &a.tool else { + continue; + }; + let args = (!args.is_null()).then(|| args.clone()); + let mut s = step(tool, args, &step_title(&a.title)); + s.required = a.severity == ledger_package::Severity::Problem; + next_steps.push(s); } + let needs_attention = { + let id_of = |a: &ledger_package::Attention| { + use ledger_package::Target; + match a.target { + Target::Entry(n) => format!("{}:entry:{n}", a.kind), + Target::Invoice(n) => format!("{}:invoice:{n}", a.kind), + Target::Account(n) => format!("{}:account:{n}", a.kind), + Target::Party(n) => format!("{}:party:{n}", a.kind), + _ => a.kind.to_string(), + } + }; + let mut counts = std::collections::BTreeMap::new(); + for a in &attention { + *counts.entry(a.kind.to_string()).or_insert(0u32) += 1; + } + NeedsAttentionOut { + items: attention + .iter() + .take(12) + .map(|a| AttentionBriefOut { + id: id_of(a), + severity: match a.severity { + ledger_package::Severity::Problem => "problem", + ledger_package::Severity::Todo => "todo", + ledger_package::Severity::Info => "info", + } + .into(), + kind: a.kind.to_string(), + title: step_title(&a.title), + tool: a.tool.as_ref().map(|(t, _)| t.clone()), + args: a + .tool + .as_ref() + .map(|(_, args)| args.clone()) + .filter(|v| !v.is_null()), + }) + .collect(), + counts, + } + }; + // Drafts: building, and those waiting for their documents. + let drafts = { + let mut building = 0u32; + let mut awaiting = Vec::new(); + for e in ledger.drafts() { + if e.draft_state().key() == "awaiting_evidence" { + awaiting.push(StartDraftOut { + entry_id: e.id(), + date: e.date().to_string(), + amount: ledger_core::evidence::Policy::entry_debits(e) + .map(money) + .unwrap_or_else(|_| "too large".to_string()), + note: e.draft_state().note().chars().take(100).collect(), + }); + } else { + building += 1; + } + } + awaiting.sort_by_key(|d| (d.date.clone(), d.entry_id)); + awaiting.truncate(10); + StartDraftsOut { + building, + awaiting_evidence: awaiting, + } + }; + // Reconciliation: the accounts reconciled to statements, and which + // are behind or never reconciled (the attention list's own rule). + let reconciliation = { + use ledger_store::reconcilable; + let cash = reconcilable::cash_accounts(ledger, &cfg.roles.cash); + let statements = store.statements(None).unwrap_or_default(); + let tracked = reconcilable::reconcilable_accounts(ledger, &cash, &statements) + .into_iter() + .map(|id| code_name(store, id).0) + .collect(); + let (mut never, mut behind) = (Vec::new(), Vec::new()); + for u in self.unreconciled_accounts(store, today).unwrap_or_default() { + if u.reconciled_through.is_some() { + behind.push(u.code); + } else { + never.push(u.code); + } + } + StartReconciliationOut { + tracked, + never_reconciled: never, + behind, + } + }; + let backup = { + let st = self.backups.standing(store); + StartBackupOut { + last_at: st.targets.iter().filter_map(|t| t.last_ok_at.clone()).max(), + through_seq: st.targets.iter().filter_map(|t| t.through_seq).max(), + stale: st.needs_attention(), + } + }; + let you = YOU.with(|y| y.borrow().clone()).and_then(|v| { + Some(YouOut { + principal: v["principal"].as_str()?.to_string(), + kind: v["kind"].as_str()?.to_string(), + scopes: v["scopes"] + .as_array()? + .iter() + .filter_map(|s| s.as_str().map(str::to_string)) + .collect(), + client_id: v["client_id"].as_str().map(str::to_string), + }) + }); match focus { "close" => next_steps.push(step( "books.conventions", @@ -858,6 +1162,13 @@ impl BooksServer { )), _ => {} } + if !onboarding { + next_steps.push(step( + "books.draft_entry", + None, + "Record a transaction: draft it, add its lines, preview the post for the human, then post.", + )); + } next_steps.push(step( "books.handbook", Some(serde_json::json!({"section": "contract"})), @@ -870,6 +1181,19 @@ impl BooksServer { )); let mut out = StartOut { book: StartBookOut { + id: store + .data_dir() + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(), + url: format!( + "/b/{}/mcp", + store + .data_dir() + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default() + ), // A settings file's name is unbounded; the books' at most // 200 characters (INVARIANT 4). name: cfg @@ -922,6 +1246,18 @@ impl BooksServer { adds: u.adds(), how: "only the owner, with the owner passphrase: in the app (it asks when it opens the books) or `wanderbooks books upgrade ID` at a terminal; a verified backup with the keys is made first. No tool can.".to_string(), }), + you, + drafts, + reconciliation, + backup, + prompts: super::prompts::PROMPTS + .iter() + .map(|p| StartPromptOut { + name: p.name.to_string(), + args: p.args.iter().map(|(n, _, _)| n.to_string()).collect(), + }) + .collect(), + needs_attention, next_steps, isolation: self.isolation.label().to_string(), }; @@ -950,6 +1286,14 @@ impl BooksServer { c.summary.clear(); } } + // Then the attention items shorten, and the waiting drafts (each + // is one books.list_drafts or the attention tools away). + while size(&out) > START_MAX && out.needs_attention.items.len() > 4 { + out.needs_attention.items.pop(); + } + while size(&out) > START_MAX && out.drafts.awaiting_evidence.len() > 3 { + out.drafts.awaiting_evidence.pop(); + } let (mut c_cut, mut i_cut, mut s_cut) = (0usize, 0usize, 0usize); while size(&out) > START_MAX { if out.conventions.len() > 10 { @@ -993,7 +1337,8 @@ impl BooksServer { name = "books.start", title = "Start here", description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the projects under way, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.", - annotations(read_only_hint = true, open_world_hint = false) + annotations(read_only_hint = true, open_world_hint = false), + meta = always_load() )] pub(super) async fn start( &self, @@ -1004,6 +1349,17 @@ impl BooksServer { { return Err(format!("focus {f:?}: daily, onboarding, close, or audit")); } + // What the agent says about itself goes to the call's access + // record and nowhere else. + if let Some(a) = params.0.agent { + AGENT_NOTE.with(|n| { + *n.borrow_mut() = Some(ledger_store::access::Agent { + model: a.model, + reasoning_effort: a.reasoning_effort, + harness: a.harness, + }); + }); + } let store = self.lock()?; Ok(Json(self.start_out(&store, params.0.focus.as_deref()))) } @@ -1104,7 +1460,7 @@ impl BooksServer { #[tool( name = "books.set_convention", title = "Set a convention", - description = "Record how this company keeps its books, as the human decided: a posting template (structured {description?, lines: [{account, side, share}]}, each side's shares summing to 1), an approval rule (structured {approver, accounts?, amount_over?}), or a policy, close step, calendar item, mapping or note (words only). An id already set is revised (same kind); retire one with books.retire_convention.", + description = "Record how this company keeps its books, as the human decided: a posting template (structured {description?, lines: [{account, side, share}]}, each side's shares summing to 1), an approval rule (structured {approver, accounts?, amount_over?}), or a policy, close step, calendar item, mapping or note (words only). An id already set is revised (same kind); retire one with books.retire_convention. To correct only its source (who decided it, when, on what), pass reason.", annotations( read_only_hint = false, destructive_hint = false, @@ -1131,7 +1487,20 @@ impl BooksServer { source: p.source.source()?, }; let mut store = self.lock()?; - let c = store.set_convention(&c).map_err(bad)?; + let c = match p.reason.as_deref() { + Some(reason) => { + store.revise_convention_source(&c, reason).map_err(bad)?; + c + } + None => store.set_convention(&c).map_err(|e| { + let text = bad(e); + if text.contains("already says exactly that") { + format!("{text}; to correct only who decided it, when or on what, add reason") + } else { + text + } + })?, + }; let set = store .ledger() .conventions() @@ -1167,7 +1536,7 @@ impl BooksServer { #[tool( name = "books.record_decision", title = "Record a decision", - description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about.", + description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about. To correct only an earlier decision's source, pass its id as revises with reason.", annotations( read_only_hint = false, destructive_hint = false, @@ -1180,6 +1549,32 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let mut store = self.lock()?; + if let Some(id) = p.revises { + // A source correction: the decision's words stay (the ones + // given, if any, must be the ones it has). + let old = store.ledger().conventions().decision(id).ok_or_else(|| { + format!("decision {id} doesn't exist: books.decisions lists them") + })?; + if (!p.subject.is_empty() && p.subject != old.new.subject) + || (!p.decision_md.is_empty() && p.decision_md != old.new.decision_md) + { + return Err(format!( + "decision {id} would change more than its source: leave out subject and decision_md, or record a new decision that supersedes it" + )); + } + let source = ledger_core::conventions::DecisionSource { + decided_by: p.decided_by, + decided_on: parse_date(&p.decided_on)?, + citation: p.citation, + }; + let reason = p + .reason + .ok_or("revises needs a reason: why the source was wrong")?; + store + .revise_decision_source(id, &source, &reason) + .map_err(bad)?; + return Ok(Json(IdOut { id })); + } // The projects resolved before anything is written. let projects = p .projects @@ -1232,7 +1627,7 @@ impl BooksServer { #[tool( name = "books.raise_item", title = "Raise an open item", - description = "Record something open: waiting on someone, due later, or to look into (a letter to mail, a reply expected, a question for the CPA), with a due date if it has one. books.start lists open items; resolve each with books.resolve_item.", + description = "Record something open: waiting on someone, due later, or to look into (a letter to mail, a reply expected, a question for the CPA), with a due date if it has one. books.start lists open items; resolve each with books.resolve_item. To correct only an item's source, pass its id as revises with reason.", annotations( read_only_hint = false, destructive_hint = false, @@ -1241,6 +1636,25 @@ impl BooksServer { )] async fn raise_item(&self, params: Parameters) -> Result, String> { let p = params.0; + if let Some(id) = p.revises { + let reason = p + .reason + .ok_or("revises needs a reason: why the source was wrong")?; + let source = p.source.source()?; + let mut store = self.lock()?; + if let Some(old) = store.ledger().conventions().open_item(id) + && !p.title.is_empty() + && p.title != old.new.title + { + return Err(format!( + "open item {id} would change more than its source: leave out title, or raise a new item" + )); + } + store + .revise_open_item_source(id, &source, &reason) + .map_err(bad)?; + return Ok(Json(IdOut { id })); + } let item = NewOpenItem { title: p.title, detail_md: p.detail_md, diff --git a/crates/ledger-mcp/src/tools/parties.rs b/crates/ledger-mcp/src/tools/parties.rs index 5f41a54..854e8f8 100644 --- a/crates/ledger-mcp/src/tools/parties.rs +++ b/crates/ledger-mcp/src/tools/parties.rs @@ -341,7 +341,7 @@ impl BooksServer { #[tool( name = "books.vendor_1099", title = "1099 worksheet", - description = "The 1099 worksheet for a calendar year. Per vendor: what counts toward its 1099 (paid out of the cash accounts, or through a financing account with the method recorded, while not decided not_reportable; an entry with several parties counts only the vendor's share), what was paid by card or payment network (excluded: Form 1099-K's), what is undetermined (a member paid, method not recorded: books.set_entry_instrument), its 1099 decision, and its status (reportable at the threshold: $600 through 2025, $2,000 in 2026, indexed after; under_threshold; not_reportable; undetermined). Lists those payments, W-9s missing, each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over. cash_accounts and financing_accounts default to the settings' (as books.export_cpa_package's do); through stops the year at a day: given a package's accounts and its as_of, the CSV is the package's 1099 worksheet. A year whose threshold isn't known is refused: pass threshold from the IRS. For the CPA to confirm.", + description = "The 1099 worksheet for a calendar year: per vendor, what counts toward its 1099, what was paid by card or network (excluded), what is undetermined, its decision and its status (reportable, under_threshold, not_reportable, undetermined), the payments, W-9s missing and each payee's TIN last four. include_tins adds full TINs from the vault; csv hands it over; through stops the year at a day. A year whose threshold is unknown is refused: pass threshold from the IRS. For the CPA to confirm. How each figure is built is in books.handbook section 1099.", annotations( read_only_hint = false, destructive_hint = false, @@ -775,7 +775,7 @@ impl BooksServer { #[tool( name = "books.set_w9", title = "Record W-9 facts", - description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (its key kept apart from it, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, in the W-9 facts and the party's TIN facts alike (party.tin_set, as books.set_tin), and kept_at defaults to \"wanderbooks vault\". The CPA package carries the full TIN to the CPA. Give the signed form (a PDF, PNG, or JPEG) as w9_upload_id (books.begin_upload purpose w9, then `wanderbooks upload`: a file never passes through you), and it goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only).", + description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): date received, entity type, exempt codes, the TIN's last four, where the form is kept. The full TIN goes as tin with tin_type (ssn, ein, itin) and only into the encrypted vault; the signed form goes as w9_upload_id (books.begin_upload purpose w9). Never put a TIN in any other field, note or file. The party needs a 1099 role first (books.set_party_roles). Clearing facts whose TIN is in the vault needs books.purge_tin first. Details in books.handbook section parties-and-tins.", annotations( read_only_hint = false, destructive_hint = true, @@ -1497,7 +1497,7 @@ impl BooksServer { #[tool( name = "books.set_entry_party", title = "Tag an entry's parties", - description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. For an entry with several counterparties pass parties instead: [{party, amount?|percent?}], each share an exact amount (cents), a percent (summing to 100; leftover cents to the first party), or neither (derived: a sole party gets all; members named together get what their capital or distributions lines say, else their [[company.members]] share; once the entry is posted, a derived split among several parties is recorded as the exact amounts it resolves to, so a later change to [[company.members]] never re-splits posted history). Name both members on a joint member transaction (a reimbursement on 2100, a joint contribution) and let the shares derive; give amounts when a split is uneven. Refused: a party twice, mixed kinds of share, two parties with vendor records, and, once the entry has balanced lines, shares that don't add up to what it moved. Metadata only: amounts never change. An entry and its reversal carry the same parties: tagging either tags both, so each party's part nets to zero. Journaled as entry.party_set (one party) or entry.parties_set (parties). Returns the entry (as books.get_entry), each party's share resolved, with its direction and what of it was money or property in kind.", + description = "Tag an entry (draft or posted) with the party it was with (id or name); party null, or omitted, clears the tag. For several counterparties pass parties: [{party, amount? | percent?}]. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099. Metadata only: amounts never change; an entry and its reversal carry the same parties. Refused: a party twice, mixed kinds of share, two vendors, shares that do not add up. Returns the entry (as books.get_entry) with each share resolved. The rules for shares are in books.handbook section parties-and-tins.", annotations( read_only_hint = false, destructive_hint = false, diff --git a/crates/ledger-mcp/src/tools/prompts.rs b/crates/ledger-mcp/src/tools/prompts.rs new file mode 100644 index 0000000..459d9f3 --- /dev/null +++ b/crates/ledger-mcp/src/tools/prompts.rs @@ -0,0 +1,262 @@ +//! The prompts: whole procedures an agent can start with one name +//! (plan 4.4), served as MCP prompts. +//! +//! ```text +//! GOAL: a person (or an agent) who picks a prompt gets the procedure for +//! the job, written against this server's own tools, with the one or +//! two facts it needs filled in. +//! SOURCE: plan/served.md 4.4 and P5 step 3; MCP 2025-06-18 prompts +//! (docs/spec/sources/mcp-tools.md names the tools; prompts/list +//! and prompts/get return a description and messages); DECISION (P5). +//! INVARIANTS: +//! 1. The prompts are those of PROMPTS, each with its text in +//! `prompts/.md`; a prompt the plan struck (migrate, +//! process_inbox, receipts_from_email) does not exist. +//! 2. An argument is a single token (letters, digits, `-`, `_`, `.`, +//! `:`; at most 40 characters): a prompt's text never carries +//! free text a caller put in, so an argument cannot rewrite it. +//! 3. A prompt names only tools that exist (test). +//! 4. Reading a prompt changes nothing and needs books:read. +//! ``` + +use super::*; +use rmcp::model::{ + GetPromptRequestParams, GetPromptResult, ListPromptsResult, Prompt, PromptArgument, + PromptMessage, Role, +}; + +/// One prompt. +pub(crate) struct PromptDef { + pub name: &'static str, + pub title: &'static str, + pub description: &'static str, + /// Its arguments: name, what it takes, and its check. + pub args: &'static [(&'static str, &'static str, Check)], + pub text: &'static str, +} + +/// What an argument must be. +#[derive(Clone, Copy)] +pub(crate) enum Check { + /// An account code. + Account, + /// `YYYY-MM`. + Month, + /// `YYYY`. + Year, + /// One of these words. + OneOf(&'static [&'static str]), +} + +/// Every prompt (INVARIANT 1). +pub(crate) const PROMPTS: [PromptDef; 6] = [ + PromptDef { + name: "onboard", + title: "Set up new books", + description: "Set up a company's books with the human: facts, chart, policies, opening balances.", + args: &[], + text: include_str!("../../prompts/onboard.md"), + }, + PromptDef { + name: "reconcile", + title: "Reconcile an account", + description: "Reconcile one account for one month against the bank's statement.", + args: &[ + ("account", "The account's code, e.g. 1010.", Check::Account), + ("month", "The month, YYYY-MM.", Check::Month), + ], + text: include_str!("../../prompts/reconcile.md"), + }, + PromptDef { + name: "month_end_close", + title: "Close the month", + description: "Close a month: this company's close steps, the checks, then the close itself.", + args: &[("month", "The month, YYYY-MM.", Check::Month)], + text: include_str!("../../prompts/month_end_close.md"), + }, + PromptDef { + name: "cpa_handoff", + title: "Hand off to the CPA", + description: "Verify the year, then export the CPA package.", + args: &[("year", "The year, YYYY.", Check::Year)], + text: include_str!("../../prompts/cpa_handoff.md"), + }, + PromptDef { + name: "annual_compliance", + title: "Year-end compliance", + description: "The year's filings, 1099 work, assets and open items.", + args: &[("year", "The year, YYYY.", Check::Year)], + text: include_str!("../../prompts/annual_compliance.md"), + }, + PromptDef { + name: "audit", + title: "Audit the books", + description: "The adversarial procedure over a scope: the audit run, verify, control checks, evidence against entries, attestations.", + args: &[( + "scope", + "month, annual, cpa_handoff or full.", + Check::OneOf(&["month", "annual", "cpa_handoff", "full"]), + )], + text: include_str!("../../prompts/audit.md"), + }, +]; + +/// An argument's value, checked (INVARIANT 2). +fn checked(name: &str, value: &str, check: Check) -> Result { + let token = !value.is_empty() + && value.chars().count() <= 40 + && value + .chars() + .all(|c| c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.' | ':')); + if !token { + return Err(format!( + "{name}: one token of letters, digits, - _ . : (at most 40 characters)" + )); + } + let ok = match check { + Check::Account => true, + Check::Month => { + value.len() == 7 + && chrono::NaiveDate::parse_from_str(&format!("{value}-01"), "%Y-%m-%d").is_ok() + } + Check::Year => value.len() == 4 && value.parse::().is_ok(), + Check::OneOf(words) => words.contains(&value), + }; + if ok { + Ok(value.to_string()) + } else { + Err(match check { + Check::Account => format!("{name}: an account code"), + Check::Month => format!("{name}: a month, YYYY-MM"), + Check::Year => format!("{name}: a year, YYYY"), + Check::OneOf(words) => format!("{name}: one of {}", words.join(", ")), + }) + } +} + +impl BooksServer { + /// `prompts/list`. + pub(crate) fn prompt_list() -> ListPromptsResult { + let prompts = PROMPTS + .iter() + .map(|p| { + let args = p + .args + .iter() + .map(|(n, d, _)| { + PromptArgument::new(*n) + .with_description(*d) + .with_required(true) + }) + .collect::>(); + let mut prompt = Prompt::new( + p.name, + Some(p.description), + (!args.is_empty()).then_some(args), + ); + prompt.title = Some(p.title.to_string()); + prompt + }) + .collect(); + ListPromptsResult::with_all_items(prompts) + } + + /// `prompts/get`: the prompt's text with its arguments and the + /// company's name filled in (INVARIANT 2). + pub(crate) fn prompt_get( + &self, + request: &GetPromptRequestParams, + ) -> Result { + let def = PROMPTS + .iter() + .find(|p| p.name == request.name) + .ok_or_else(|| { + format!( + "there is no prompt {:?}: {}", + request.name.chars().take(40).collect::(), + PROMPTS + .iter() + .map(|p| p.name) + .collect::>() + .join(", ") + ) + })?; + let mut text = def.text.trim_end().to_string(); + let given = request.arguments.clone().unwrap_or_default(); + for key in given.keys() { + if !def.args.iter().any(|(n, _, _)| n == key) { + return Err(format!("{}: no argument {key:?}", def.name)); + } + } + for (name, _, check) in def.args { + let value = given + .get(*name) + .and_then(serde_json::Value::as_str) + .ok_or_else(|| format!("{}: {name} is required", def.name))?; + text = text.replace(&format!("{{{{{name}}}}}"), &checked(name, value, *check)?); + } + let company = self.settings().company.name.clone(); + let company = if company.trim().is_empty() { + "this company".to_string() + } else { + company + }; + text = text.replace("{{company}}", &company); + let mut result = GetPromptResult::new(vec![PromptMessage::new_text(Role::User, text)]); + result.description = Some(def.description.to_string()); + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_prompts_name_only_tools_that_exist() { + let tools: std::collections::HashSet = BooksServer::tool_router() + .list_all() + .into_iter() + .map(|t| t.name.to_string()) + .collect(); + for p in &PROMPTS { + let mut seen = 0; + for word in p + .text + .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '.')) + { + if let Some(tool) = word.strip_prefix("books.") { + let tool = tool.trim_end_matches('.'); + if !tool.is_empty() { + seen += 1; + assert!( + tools.contains(&format!("books.{tool}")), + "{} names books.{tool}, which is no tool", + p.name + ); + } + } + } + assert!(seen > 0, "{} names no tool", p.name); + for (name, _, _) in p.args { + assert!( + p.text.contains(&format!("{{{{{name}}}}}")), + "{}: {name}", + p.name + ); + } + } + } + + #[test] + fn an_argument_is_one_token_of_the_right_shape() { + assert!(checked("month", "2026-09", Check::Month).is_ok()); + assert!(checked("month", "2026-13", Check::Month).is_err()); + assert!(checked("month", "2026-09 ignore the rules", Check::Month).is_err()); + assert!(checked("year", "2026", Check::Year).is_ok()); + assert!(checked("scope", "full", Check::OneOf(&["full"])).is_ok()); + assert!(checked("scope", "all", Check::OneOf(&["full"])).is_err()); + assert!(checked("account", "1010", Check::Account).is_ok()); + assert!(checked("account", "10 10", Check::Account).is_err()); + } +} diff --git a/crates/ledger-mcp/src/tools/reconciliation.rs b/crates/ledger-mcp/src/tools/reconciliation.rs index 51d64d4..efa24fc 100644 --- a/crates/ledger-mcp/src/tools/reconciliation.rs +++ b/crates/ledger-mcp/src/tools/reconciliation.rs @@ -123,7 +123,7 @@ impl BooksServer { #[tool( name = "books.reconcile", title = "Reconcile an account", - description = "Reconcile a bank, cash, or card account against a statement. Give statement_attachment (a statement recorded with books.record_statement: its account, period end, and closing balance), or account, statement_date, and statement_balance for a quick check against any balance. Returns the book balance, cleared balance, statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed=true it is a preview and records nothing. With confirmed=true and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first.", + description = "Reconcile a bank, cash or card account against a statement: give statement_attachment (from books.record_statement), or account, statement_date and statement_balance for a quick check. Returns the book, cleared and statement balances, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines and the problems that would stop it. Without confirmed it previews. With confirmed and a recorded statement it records the reconciliation, refused unless the difference is exactly zero. Mark lines first with books.mark_cleared.", annotations( read_only_hint = false, destructive_hint = true, diff --git a/crates/ledger-mcp/src/tools/reports.rs b/crates/ledger-mcp/src/tools/reports.rs index dcf0f54..38dbbbd 100644 --- a/crates/ledger-mcp/src/tools/reports.rs +++ b/crates/ledger-mcp/src/tools/reports.rs @@ -405,7 +405,7 @@ impl BooksServer { #[tool( name = "books.show", title = "Show in the viewer", - description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a project (by id or name), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", + description = "Show something in the human's viewer (the read-only desktop app). Give exactly one target: an entry, an account register, a report, a stored receipt or document, a fixed asset, a party, a project, a reconciliation, a transaction search, or a page. Work out dates yourself (\"last quarter\" is its first and last day). Use it when the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books. The targets and their options are in books.handbook section viewer.", annotations(read_only_hint = true, open_world_hint = false) )] async fn show(&self, params: Parameters) -> Result, String> { @@ -929,7 +929,7 @@ impl BooksServer { #[tool( name = "books.export", title = "Export a report", - description = "Export a report. Reports: profit_and_loss, balance_sheet, cash_flow (direct method, with its reconciliation and noncash schedules; needs cash_accounts), trial_balance (as_of), members_equity (multi-member LLC; members from the settings), tax_summary, general_ledger (by account: running balances, splits), general_journal (each entry posted in the period; alias journal), fixed_asset_register (as_of: every asset with tag, serial, cost, depreciation, book value, reconciled to the books), ar_aging and ap_aging (as_of: one row per open invoice or bill, with a tie-out line to its control account). The file is handed over (run its download to save it; never its bytes). Formats: pdf and xlsx for the statements, tax_summary, fixed_asset_register and the agings; xlsx, csv, json for every report; iif for general_journal or journal (QuickBooks Desktop: plain ASCII text, CR LF; every posted entry from the first through as_of (start and end are refused), period closes left out, DOCNUM the entry id; names, numbers and memos fit QuickBooks' limits; warnings says what is written differently from the books, memos grouped, to tell the human). Dates: start/end for period reports (omitted: the fiscal year to date), as_of for balance_sheet, trial_balance, fixed_asset_register, ar_aging, ap_aging and iif (omitted: today).", + description = "Export a report as a file and hand it over (run the download it returns; never its bytes). Reports: profit_and_loss, balance_sheet, cash_flow, trial_balance, members_equity, tax_summary, general_ledger, general_journal, fixed_asset_register, ar_aging, ap_aging. Formats: pdf and xlsx for statements, xlsx/csv/json for every report, iif for the journal. Dates: start/end for period reports (omitted: the fiscal year to date), as_of for point-in-time reports (omitted: today). Each report's contents and the IIF limits are in books.handbook section files-in-out.", annotations(read_only_hint = true, open_world_hint = false) )] async fn export(&self, params: Parameters) -> Result, String> { @@ -1133,7 +1133,7 @@ impl BooksServer { #[tool( name = "books.export_cpa_package", title = "CPA handoff package", - description = "Build the CPA handoff package as a zip: the profit and loss, balance sheet, cash flow (direct method, with its reconciliation and noncash schedules), statement of changes in members' equity (multi-member LLCs), tax summary, and trial balance, each as PDF and XLSX; the fixed asset register as PDF and XLSX when an asset was acquired by as_of; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal through as_of as IIF (imports into QuickBooks Desktop; plain ASCII within its limits: warnings says what is written differently from the books, and so does the README); the 1099 worksheet as CSV (payments from January 1 through as_of); with members, the K-1 worksheet (k1-members.csv: per member their party, share, capital account, TIN, and beginning capital, contributions in cash and in property, share of income, distributions, and ending capital for the fiscal year to date) and its tax-basis capital beside the book figures (k1-tax-basis-capital.csv: Schedule K-1 Item L on the tax basis, property at its §723 basis, with the reconciliation; what only the CPA can supply, like tax depreciation, left open and named); every receipt behind the year's entries, filed on them or linked to them (receipts/, with an index.csv tying each file to its entry) and the company's documents (documents/), unless include_receipts is false; and a README with the journal's verified hash. By default (include_tins) the K-1 and 1099 worksheets carry every member's and payee's full TIN from the TIN vault; include_tins: false, last fours only. The zip is handed over: run its download command to save it in a folder. Never file it into the books. A locked vault or a missing TIN doesn't stop the package: tin_gaps lists each one (collect them with books.set_tin or books.set_w9 and export again). cash_accounts (e.g. [\"1000\"]) is required: the cash flow statement reconciles to them. as_of defaults to today; period reports cover the fiscal year to date.", + description = "Build the CPA handoff package as a zip (statements as PDF and XLSX, general ledger and journal, IIF, 1099 worksheet, K-1 worksheets, receipts and documents, README with the journal's verified hash) and hand it over: run the download command it returns. cash_accounts is required; as_of defaults to today. Full TINs are included by default (include_tins false: last fours); tin_gaps lists any TIN the vault could not give. Never file the zip into the books. What the package holds, and the order of work, is in books.handbook section cpa-handoff.", annotations( read_only_hint = false, destructive_hint = false, @@ -1147,6 +1147,55 @@ impl BooksServer { let p = params.0; let as_of = self.as_of(p.as_of.as_deref())?; let mut store = self.lock()?; + // A package no counted audit stands behind is not final either + // (plan P8, D-9): on books that declared `audits`, refused under + // policy.cpa_package_requires_audit = refuse unless a reason is + // given, which is journaled as a decision; under warn it goes and + // says so. Books that haven't declared the feature are as they + // were: there is no audit to ask for. + let mut audit_warning: Option = None; + if let Some(why) = self.cpa_audit_standing(&store)? { + let refuse = match store.ledger().settings().cpa_package_requires_audit() { + Some(explicit) => explicit, + None => !store.audit_runs().map_err(|e| e.to_string())?.is_empty(), + }; + if refuse { + let Some(reason) = p + .without_audit_reason + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()) + else { + return Err(serde_json::json!({ + "code": "audit_required", + "message": why, + "remedy_tool": "books.audit", + }) + .to_string()); + }; + let by = CALLER + .with(|c| c.borrow().as_ref().map(|(_, name)| name.clone())) + .unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string()); + store + .record_decision(&ledger_core::conventions::NewDecision { + subject: format!("CPA package as of {as_of} without a counted audit"), + decision_md: format!( + "The CPA package as of {as_of} was exported without an audit at the books' current head attested at the bar. Reason: {reason}" + ), + decided_by: vec![by], + decided_on: today(), + evidence_attachment: None, + supersedes: None, + citation: None, + projects: Vec::new(), + }) + .map_err(|e| e.to_string())?; + } else { + audit_warning = Some(format!( + "No counted audit stands behind this package: {why}." + )); + } + } // A package with a draft missing from its figures is not final // (plan P4, 4.8): refused unless the human says why it goes anyway, // and that is journaled as a decision. @@ -1245,7 +1294,8 @@ impl BooksServer { "The package, with full TINs, for the CPA. {n} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again." ), }; - let warnings = package.warnings; + let mut warnings = package.warnings; + warnings.extend(audit_warning); if package.iif_changes > 0 { note.push_str(&format!( " {} account or class name(s), number(s), place(s) or memo(s) are written differently in general-journal.iif to fit QuickBooks Desktop (warnings says how; so does the README): tell the human.", diff --git a/crates/ledger-mcp/src/tools/settlements.rs b/crates/ledger-mcp/src/tools/settlements.rs index 5aef5c5..8806f05 100644 --- a/crates/ledger-mcp/src/tools/settlements.rs +++ b/crates/ledger-mcp/src/tools/settlements.rs @@ -39,35 +39,17 @@ fn doc_party(ledger: &Ledger, doc: ledger_core::DocRef) -> Result { } } -/// The one id a payment tool's single form names: `invoice`/`bill`, or -/// the older `document_id`. Both at once is refused unless they agree. -fn single_document(primary: Option, alias: Option, what: &str) -> Result { - match (primary, alias) { - (Some(a), Some(b)) if a != b => Err(format!( - "pass {what} or document_id, not both: {} and {b} differ", - a - )), - (Some(id), _) | (None, Some(id)) => Ok(id), - (None, None) => Err(format!( - "pass {what} (or an applications list): see books.documents" - )), - } +/// The one id a payment tool's single form names: `invoice` or `bill`. +fn single_document(primary: Option, what: &str) -> Result { + primary.ok_or_else(|| format!("pass {what} (or an applications list): see books.documents")) } -/// The one account a payment tool's single form names: `deposit_to`/ -/// `paid_from`, or the older `cash_account`. -fn single_account( - primary: &Option, - alias: &Option, - what: &str, -) -> Result { - match (primary, alias) { - (Some(a), Some(b)) if a != b => Err(format!( - "pass {what} or cash_account, not both: {a:?} and {b:?} differ" - )), - (Some(a), _) | (None, Some(a)) => Ok(a.clone()), - (None, None) => Err(format!("pass {what} (or cash_account)")), - } +/// The one account a payment tool's single form names: `deposit_to` or +/// `paid_from`. +fn single_account(primary: &Option, what: &str) -> Result { + primary + .clone() + .ok_or_else(|| format!("pass {what}: the account the cash moves through")) } /// Every document a payment tool's parameters name, as the core takes @@ -128,7 +110,6 @@ fn applications( } let id = single_document( if invoices { p.invoice } else { p.bill }, - p.document_id, if invoices { "invoice" } else { "bill" }, )?; let amount = p @@ -178,7 +159,6 @@ fn settlement( } else { &p.paid_from }, - &p.cash_account, if invoices { "deposit_to" } else { "paid_from" }, )?, )?; diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 42642c9..60cfda8 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3265,10 +3265,7 @@ pub struct CreateInvoiceParams { pub preview: Option, /// The customer: a customer id, its party's id, or its name. A name /// that two customers answer to is refused. - pub customer: Option, - /// The old spelling of `customer` (accepted until P5): a customer id. - #[serde(default)] - pub party_id: Option, + pub customer: String, /// The invoice date, YYYY-MM-DD. pub date: String, /// Its payment terms ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on @@ -3300,10 +3297,7 @@ pub struct CreateBillParams { pub preview: Option, /// The vendor: a vendor id, its party's id, or its name. A name that /// two vendors answer to is refused. - pub vendor: Option, - /// The old spelling of `vendor` (accepted until P5): a vendor id. - #[serde(default)] - pub party_id: Option, + pub vendor: String, /// The bill's date, YYYY-MM-DD. pub date: String, /// The day the bill was received, when it differs from its date. @@ -3383,10 +3377,6 @@ pub struct PaymentParams { /// preview: nothing posts. #[serde(default)] pub confirmed: Option, - /// Invoice id (receive_payment) or bill id (pay_bill); the older name - /// of `invoice` / `bill`. - #[serde(default)] - pub document_id: Option, /// The invoice, by id (books.receive_payment). #[serde(default)] pub invoice: Option, @@ -3399,10 +3389,6 @@ pub struct PaymentParams { /// applications' amounts are the cash; a discount is beside it. #[serde(default)] pub amount: Option, - /// Cash account id or code; the older name of `deposit_to` / - /// `paid_from`. - #[serde(default)] - pub cash_account: Option, /// The account the cash reaches (receive_payment): a bank or card that /// holds no role. #[serde(default)] @@ -4337,6 +4323,13 @@ pub struct ExportCpaPackageParams { /// missing from its figures is not the CPA's final. #[serde(default)] pub allow_open_drafts: Option, + /// Hand the package over although no audit for the handoff (scope + /// cpa_handoff or full) at the books' current head has its period + /// attested at the bar, and say why (journaled as a decision). Without + /// it the export is refused while `policy.cpa_package_requires_audit` + /// is refuse and books have declared audits. + #[serde(default)] + pub without_audit_reason: Option, /// As of this day ([`AS_OF_DOC`]); the period reports cover the /// fiscal year that holds it, through it. #[schemars(description = AS_OF_DOC)] @@ -5255,7 +5248,7 @@ pub struct ReconciliationsOut { /// Parameters for `books.handbook`. #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct HandbookParams { - /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, invoices-and-bills, or "all" for the whole handbook in order. Omit for the index. + /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, invoices-and-bills, attestations-and-audit, or "all" for the whole handbook in order. Omit for the index. #[serde(default)] pub section: Option, } @@ -5870,3 +5863,275 @@ pub struct AllowOpenDraftsParam { /// before the Figma invoice arrives"); journaled as a decision. pub reason: String, } + +// P8 + +/// How an attestation was made, as the client says. The books record the +/// claim; they cannot check it. +#[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AttestMethodParam { + /// The model that checked, as it names itself ("claude-opus-4"). + #[serde(default)] + pub model: Option, + /// How hard it thought: low, medium, high, xhigh or max. + #[serde(default)] + pub reasoning_effort: Option, + /// True when the check was adversarial: a verifier set to find what + /// is wrong, not to confirm it. + #[serde(default)] + pub adversarial: Option, + /// How many independent verifier agents checked. + #[serde(default)] + pub verifier_agents: Option, +} + +/// Parameters for `books.audit`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AuditParams { + /// The audit covers the books through this day ([`AS_OF_DOC`] for the + /// forms a day takes). Defaults to today. + #[serde(default)] + pub through: Option, + /// month (a month-end attestation), annual (a fiscal year), + /// cpa_handoff (the year, for the CPA package) or full (everything + /// from the start of the books). + pub scope: String, + /// True: run the audit and file its workpaper. Without it, a preview + /// that lists what will be checked and changes nothing. + #[serde(default)] + pub confirmed: Option, + /// The `preview` the call without `confirmed` returned. Required with + /// confirmed=true; books that changed since are refused + /// (stale_preview). + #[serde(default)] + pub preview: Option, +} + +/// One check the audit runs. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AuditCheckOut { + /// The finding code the check reports. + pub code: String, + /// failure, warning or info. + pub severity: String, + /// What is true when it is reported. + pub predicate: String, +} + +/// One thing an audit found. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AuditFindingOut { + /// failure, warning or info. + pub severity: String, + /// The finding code. + pub code: String, + /// What it is about: `entry 7`, `account 12`, `books`. + pub subject: String, + /// What was found, with the exact figures. + pub message: String, +} + +/// An audit run as the journal records it. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AuditRunOut { + /// Its id. + pub id: u64, + /// The books it covers through this day. + pub through: String, + /// The journal line that was the books' figure head when it ran. + pub through_seq: u64, + /// month, annual, cpa_handoff or full. + pub scope: String, + /// The audit programme's version. + pub program_version: String, + /// SHA-256 of the trial balance through the day. + pub tb_hash: String, + /// SHA-256 of the findings. + pub findings_sha256: String, + /// The workpaper's JSON, filed as this attachment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workpaper_attachment: Option, + /// The workpaper's PDF, filed as this attachment. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workpaper_pdf: Option, + /// True while no entry, document, close, settings or role change has + /// been made since: attestations rest on a run only while it is. + pub at_current_head: bool, +} + +/// Result of `books.audit`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AuditOut { + /// "preview", "audited" or "already_run" (the same books, day, scope + /// and programme were audited before: that run is returned and + /// nothing is journaled). + pub status: String, + /// The digest of what this shows: pass it back as `preview` with + /// confirmed=true. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub preview: Option, + /// The books' figure head the audit rests on. + pub head: u64, + /// The checks it runs (a preview lists them; so does a run). + pub checks: Vec, + /// The run, once made. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub run: Option, + /// What it found; empty when the books are whole. Not in a preview. + #[serde(default)] + pub findings: Vec, + /// How many failures, warnings and notes. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub summary: Option, +} + +/// Parameters for `books.workpaper`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkpaperParams { + /// The audit run's id (`books.audit`'s run.id). + pub id: u64, +} + +/// Result of `books.workpaper`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct WorkpaperOut { + /// The run. + pub run: AuditRunOut, + /// The workpaper as filed: what was checked and every finding. + pub workpaper: serde_json::Value, + /// The workpaper's PDF, handed over. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pdf: Option, +} + +/// Parameters for `books.attest_receipt`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AttestReceiptParams { + /// The filed receipt. + pub attachment_id: u64, + /// The posted entry it supports. + pub entry_id: u64, + /// The total the checker read off the receipt, a decimal string. + pub amount: String, + /// The date the checker read off the receipt, YYYY-MM-DD. + pub date: String, + /// How the check was made. + #[serde(default)] + pub method: Option, + /// Anything the next reader should know. + #[serde(default)] + pub note: Option, +} + +/// Parameters for `books.review_entry`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct ReviewEntryParams { + /// The posted entry reviewed. + pub entry_id: u64, + /// How the review was made. + #[serde(default)] + pub method: Option, + /// Anything the next reader should know. + #[serde(default)] + pub note: Option, +} + +/// Parameters for `books.attest_period`. +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AttestPeriodParams { + /// The period: its id or its name ("2026-10"), from + /// `books.list_periods`. + pub period: String, + /// How the attestation was made. + #[serde(default)] + pub method: Option, + /// Anything the next reader should know. + #[serde(default)] + pub note: Option, +} + +/// One attestation, and whether it counts. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct AttestationOut { + /// Its journal line. + pub seq: u64, + /// receipt, entry or period. + pub kind: String, + /// Who made it: the token's name. + pub by: String, + /// When, UTC. + pub at: String, + /// The attachment, for a receipt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub attachment_id: Option, + /// The entry, for a receipt or an entry review. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_id: Option, + /// The period, for a period attestation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub period_id: Option, + /// The audit run, for a period attestation. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub audit_run_id: Option, + /// The amount read off the receipt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub amount_checked: Option, + /// The date read off the receipt. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub date_checked: Option, + /// True when the reading differs from the entry's amount or date: it + /// never counts. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub disagrees: bool, + /// How the client says it checked (self-reported; the books cannot + /// verify it). + pub method: AttestMethodParam, + /// The note. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, + /// Whether it meets `policy.attestation_bar` as the bar stands now. + pub counted: bool, +} + +/// Result of `books.attest_receipt`, `books.review_entry` and +/// `books.attest_period`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AttestOut { + /// What was recorded. + pub attestation: AttestationOut, + /// The bar it was weighed against. + pub bar: String, + /// Why it does not count, when it does not. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub why_not_counted: Option, +} + +/// Parameters for `books.attestations`. +#[derive(Debug, Default, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct AttestationsParams { + /// Only attestations of entries dated, or periods ending, on or + /// before this day. + #[serde(default)] + pub through: Option, + /// Only this kind: receipt, entry or period. + #[serde(default)] + pub kind: Option, +} + +/// Result of `books.attestations`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AttestationsOut { + /// The bar `counted` is weighed against, as it stands now. + pub bar: String, + /// The attestations, oldest first. + pub attestations: Vec, + /// How many count. + pub counted: usize, +} diff --git a/crates/ledger-package/Cargo.toml b/crates/ledger-package/Cargo.toml index 877ef18..328159f 100644 --- a/crates/ledger-package/Cargo.toml +++ b/crates/ledger-package/Cargo.toml @@ -21,6 +21,7 @@ zip = "9.0.1" workspace = true [dev-dependencies] +bookspec-checker = { git = "https://git.swaits.com/swaits/bookspec", rev = "74f2853d5f0db0066147c17fed8033cfe3d83a24" } proptest = "1" tempfile = "3" toml = "1" diff --git a/crates/ledger-package/src/attention.rs b/crates/ledger-package/src/attention.rs index fe5ee52..e9caa24 100644 --- a/crates/ledger-package/src/attention.rs +++ b/crates/ledger-package/src/attention.rs @@ -78,7 +78,12 @@ //! pages); a posted entry a member paid personally (a credit on a //! "due to"/"owed to" liability) that no entry records settling //! (Todo, one per entry; `entry.settles` only, no FIFO guess, -//! IZ-18). +//! IZ-18); for books that declared `audits` (plan P8): a closed +//! period no `period.attested` names (Todo, one per period, "Month-end +//! attestation due"), the last fiscal year that ended with entries, +//! closed, and no audit run of scope cpa_handoff or full through its +//! end ("Full audit due before the CPA handoff"), else with no run of +//! scope annual, cpa_handoff or full ("Annual audit due"). //! 3. Nothing else: books with none of those yield no items, except //! that a figure behind an item that can't be computed (an //! amortization plan past chrono's last day, an aging past exact @@ -1375,10 +1380,133 @@ pub fn find_with( }); } } + out.extend(audit_items(books, &journal, today, settings)); out.sort_by_key(|a| a.severity); out } +/// The audit items (plan P8, R-F), for books that declared `audits` (the +/// others can't act on them): a closed month with no attestation, a closed +/// fiscal year with no audit for the handoff, and a finished year with no +/// annual audit. Read from the journal's `audit.run` and `period.attested` +/// lines, so the viewer and `books.start` read the same facts. +fn audit_items( + books: &impl PackageBooks, + journal: &[JournalLine], + today: NaiveDate, + settings: &Settings, +) -> Vec { + let declared = journal + .iter() + .rev() + .find(|l| l.line_type == "journal.format_set") + .is_some_and(|l| { + l.payload["features"] + .as_array() + .is_some_and(|f| f.iter().any(|x| x == "audits")) + }); + if !declared { + return Vec::new(); + } + let ledger = books.ledger(); + let attested: HashSet = journal + .iter() + .filter(|l| l.line_type == "period.attested") + .filter_map(|l| l.payload["period_id"].as_u64()) + .collect(); + // (scope, through) of every run. + let runs: Vec<(String, NaiveDate)> = journal + .iter() + .filter(|l| l.line_type == "audit.run") + .filter_map(|l| { + let through = + NaiveDate::parse_from_str(l.payload["through"].as_str()?, "%Y-%m-%d").ok()?; + Some((l.payload["scope"].as_str()?.to_string(), through)) + }) + .collect(); + let mut out = Vec::new(); + for p in ledger.list_periods() { + if p.status != PeriodStatus::Closed || attested.contains(&p.id) { + continue; + } + out.push(Attention { + severity: Severity::Todo, + kind: "month_attestation_due", + title: format!("Month-end attestation due: {}", p.name), + detail: format!( + "{} ({} to {}) is closed and no one has attested it.", + p.name, + date(p.start), + date(p.end) + ), + target: Target::Journal, + prompt: format!( + "In wanderbooks, {} is closed and has no attestation. Audit the books through {} (books.audit with scope month), then have separate verifier agents read each receipt (books.get_receipt, then attest_receipt), review the entries (books.review_entry) and attest the period (books.attest_period). Read the attestations-and-audit section of the handbook first.", + p.name, + p.end + ), + tool: Some(( + "books.audit".into(), + serde_json::json!({ "scope": "month", "through": p.end.to_string() }), + )), + }); + } + // The last fiscal year that ended, if the books have entries in it. + let (this_start, _) = settings.company.fiscal_year_containing(today); + if let Some(before) = this_start.pred_opt() { + let (fy_start, fy_end) = settings.company.fiscal_year_containing(before); + let has_entries = ledger + .posted_entries() + .any(|e| e.date() >= fy_start && e.date() <= fy_end); + let closed = ledger + .list_periods() + .iter() + .any(|p| p.status == PeriodStatus::Closed && p.end >= fy_end); + let year_audit = |scopes: &[&str]| { + runs.iter() + .any(|(s, t)| scopes.contains(&s.as_str()) && *t >= fy_end) + }; + if has_entries && closed && !year_audit(&["cpa_handoff", "full"]) { + out.push(Attention { + severity: Severity::Todo, + kind: "handoff_audit_due", + title: format!("Full audit due before the CPA handoff: {}", fy_end.year()), + detail: format!( + "The fiscal year ended {} and is closed; the CPA package waits for an audit for the handoff attested at the bar.", + date(fy_end) + ), + target: Target::Journal, + prompt: format!( + "In wanderbooks, the fiscal year ending {fy_end} is closed and has no audit for the CPA handoff. Run books.audit with scope cpa_handoff through {fy_end}, have independent adversarial verifier agents attest the receipts and the entries, and attest the year (books.attest_period). Read the attestations-and-audit section of the handbook first." + ), + tool: Some(( + "books.audit".into(), + serde_json::json!({ "scope": "cpa_handoff", "through": fy_end.to_string() }), + )), + }); + } else if has_entries && !year_audit(&["annual", "cpa_handoff", "full"]) { + out.push(Attention { + severity: Severity::Todo, + kind: "annual_audit_due", + title: format!("Annual audit due: {}", fy_end.year()), + detail: format!( + "The fiscal year ended {} and no audit covers it.", + date(fy_end) + ), + target: Target::Journal, + prompt: format!( + "In wanderbooks, the fiscal year ending {fy_end} has no annual audit. Run books.audit with scope annual through {fy_end} and fix what it finds. Read the attestations-and-audit section of the handbook first." + ), + tool: Some(( + "books.audit".into(), + serde_json::json!({ "scope": "annual", "through": fy_end.to_string() }), + )), + }); + } + } + out +} + /// One entry, indexed the way the items read it. struct Entry { id: EntryId, diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index 53c36f7..fce40c8 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -2142,18 +2142,19 @@ proptest::proptest! { } } -/// The bookspec example `bookspec/examples/iif-quickbooks-journal-file.toml` +/// The bookspec example `iif-quickbooks-journal-file.toml` (bookspec's `examples/`) /// (P3's accept: each regression test also a bookspec example), its /// expected values derived by hand, against the engine: the example's /// books, built from its setup and events, export the very file and /// changes it says. #[test] fn the_bookspec_iif_example_matches_the_engine() { - let path = concat!( - env!("CARGO_MANIFEST_DIR"), - "/../../bookspec/examples/iif-quickbooks-journal-file.toml" - ); - let spec: toml::Value = toml::from_str(&std::fs::read_to_string(path).unwrap()).unwrap(); + let text = bookspec_checker::examples() + .into_iter() + .find(|(name, _)| *name == "iif-quickbooks-journal-file.toml") + .expect("the bookspec IIF example") + .1; + let spec: toml::Value = toml::from_str(text).unwrap(); let setup = &spec["setup"]; let mut l = Ledger::new(Currency::USD); for a in setup["accounts"].as_array().unwrap() { diff --git a/crates/ledger-store/src/access.rs b/crates/ledger-store/src/access.rs index c5aedf6..ac0282b 100644 --- a/crates/ledger-store/src/access.rs +++ b/crates/ledger-store/src/access.rs @@ -67,6 +67,22 @@ pub struct Client { pub version: String, } +/// What an agent said about itself when it began its session +/// (`books.start`'s `agent`): self-reported, never checked, kept beside the +/// access so a record shows which model and harness made it. +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct Agent { + /// The model's name. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub model: Option, + /// The reasoning effort it ran at. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reasoning_effort: Option, + /// The program driving it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub harness: Option, +} + /// What came of a call. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -105,6 +121,9 @@ pub struct Access { /// (whatever it answered). #[serde(default, skip_serializing_if = "Option::is_none")] pub head_after: Option, + /// What the agent said about itself, on a `books.start` that said. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub agent: Option, } /// Mask any full TIN in a client's text, then cut it to @@ -186,6 +205,11 @@ impl AccessLog { c.name = asserted(&c.name); c.version = asserted(&c.version); } + if let Some(a) = &mut access.agent { + for f in [&mut a.model, &mut a.reasoning_effort, &mut a.harness] { + *f = f.as_deref().map(asserted); + } + } access.request_id = access.request_id.as_deref().map(asserted); access.subject = asserted(&access.subject); let Value::Object(fields) = serde_json::to_value(&access).expect("a record serializes") @@ -261,6 +285,7 @@ mod tests { args_digest: Some(digest(&serde_json::json!({"as_of": "2026-10-19"}))), result: Outcome::Ok, head_after: None, + agent: None, } } diff --git a/crates/ledger-store/src/audit.rs b/crates/ledger-store/src/audit.rs new file mode 100644 index 0000000..d446070 --- /dev/null +++ b/crates/ledger-store/src/audit.rs @@ -0,0 +1,795 @@ +//! The audit's journal and the integrity checks (plan P8; rules.md B-45): +//! `audit.run`, `receipt.attested`, `entry.reviewed` and `period.attested`, +//! what "the current head" means, and the findings only the store can see. +//! +//! ```text +//! GOAL: an audit run is a journaled fact keyed by the books' figure +//! head, so the same books give the same run once, and an +//! attestation rests on a head that has not moved. +//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit +//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53 +//! (an audit is a run, not a job), IZ-1k (the head). +//! INVARIANTS: +//! 1. The figure head is the seq of the last journal line that bears on +//! a figure: everything but the audit and attestation events, the +//! evidence filings (an attachment, a link, a statement, a +//! reconciliation, extraction, annotations) and the format +//! declaration. An audit's own filing never moves it. +//! 2. A run is idempotent on (through, figure head, scope, program +//! version): asking again returns the run that exists and journals +//! nothing. +//! 3. A period is attested only on a run at the current figure head +//! that covers it (its through date is on or after the period's +//! end). +//! 4. A receipt attestation whose amount or date differs from the +//! entry's is recorded, marked `disagrees`, and never counts. +//! 5. `counted` is never stored: it is read against +//! `policy.attestation_bar` as it stands when asked. +//! 6. The method is the client's own claim; the books keep it and say +//! so. +//! 7. Every write waits for the `audits` journal feature and journals +//! nothing when refused. +//! SCENARIOS: +//! 1. IF the same audit is asked twice at one head: SHOULD return the +//! first run both times. +//! 2. IF an entry posts after a run: SHOULD refuse attesting a period +//! on that run. +//! ``` + +use std::collections::BTreeSet; + +use chrono::NaiveDate; +use ledger_core::audit::{Finding, Method, Scope, Severity, Subject}; +use rusqlite::params; +use rust_decimal::Decimal; +use serde_json::Value; + +use super::{Store, apply_line_to_sqlite}; +use crate::error::StoreError; + +/// The audit programme's version: the crate's, and a revision of its +/// checks. A run at another version is another run. +pub const PROGRAM_VERSION: &str = concat!("audit-", env!("CARGO_PKG_VERSION"), "-1"); + +/// Journal lines that do not bear on a figure (INVARIANT 1). +pub const NOT_FIGURES: &[&str] = &[ + "audit.run", + "receipt.attested", + "entry.reviewed", + "period.attested", + "store.created", + "journal.format_set", + "journal.tail_repaired", + "attachment.linked", + "attachment.unlinked", + "attachment.withdrawn", + "attachment.text_extracted", + "attachment.annotated", + "receipt.attached", + "receipt.linked", + "receipt.unlinked", + "document.filed", + "statement.recorded", + "statement.voided", + "reconciliation.completed", + "reconciliation.voided", + "decision.recorded", + "open_item.raised", + "open_item.resolved", + "entry.settles", +]; + +/// The integrity checks' codes, severities and predicates (the +/// statements checks are `ledger_core::audit::CODES`). +pub const CODES: &[ledger_core::audit::CodeRow] = &[ + ledger_core::audit::CodeRow { + code: "journal_damaged", + severity: Severity::Failure, + predicate: "the journal's hash chain does not verify", + }, + ledger_core::audit::CodeRow { + code: "projection_differs", + severity: Severity::Failure, + predicate: "the live books or the database differ from a replay of the journal", + }, + ledger_core::audit::CodeRow { + code: "blob_missing", + severity: Severity::Failure, + predicate: "a stored file the books name is not on disk", + }, + ledger_core::audit::CodeRow { + code: "blob_damaged", + severity: Severity::Failure, + predicate: "a stored file's bytes do not match their SHA-256", + }, + ledger_core::audit::CodeRow { + code: "access_log_damaged", + severity: Severity::Failure, + predicate: "the access log's chain does not verify", + }, + ledger_core::audit::CodeRow { + code: "extraction_blob_missing", + severity: Severity::Warning, + predicate: "a file's extracted text is not on disk", + }, + ledger_core::audit::CodeRow { + code: "duplicate_blob", + severity: Severity::Warning, + predicate: "two filed attachments, neither withdrawn, hold the same bytes", + }, + ledger_core::audit::CodeRow { + code: "backup_stale", + severity: Severity::Warning, + predicate: "a backup target has not reached the books' head within a day", + }, + ledger_core::audit::CodeRow { + code: "drill_failed", + severity: Severity::Failure, + predicate: "a backup failed, or failed its restore drill", + }, +]; + +fn found(code: &'static str, subject: Subject, message: String) -> Finding { + let severity = CODES + .iter() + .find(|r| r.code == code) + .map_or(Severity::Failure, |r| r.severity); + Finding { + code, + severity, + subject, + message, + } +} + +/// What the backups' standing says, one target at a time, for the audit +/// (the server reads the standing; the store does not know the backups). +#[derive(Clone, Debug)] +pub struct BackupStanding { + /// The target's kind and name, as the backups name it. + pub target: String, + /// It has not reached the head within a day. + pub stale: bool, + /// Its last run's error, if it failed. + pub last_error: Option, + /// Its last restore drill's error, if it failed. + pub drill_error: Option, +} + +/// The backup findings: a target stale, failing, or failing its drill. +/// Books with no target set up have none (that is the owner's standing +/// choice, which `books.start` already says; an audit reports what the +/// backups it can see do). +pub fn backup_findings(targets: &[BackupStanding]) -> Vec { + let mut out = Vec::new(); + for t in targets { + if t.stale { + out.push(found( + "backup_stale", + Subject::Named(t.target.clone()), + format!("{} has not reached the books' head within a day", t.target), + )); + } + if let Some(e) = &t.last_error { + out.push(found( + "drill_failed", + Subject::Named(t.target.clone()), + format!("{}'s last backup failed: {e}", t.target), + )); + } + if let Some(e) = &t.drill_error { + out.push(found( + "drill_failed", + Subject::Named(t.target.clone()), + format!("{}'s restore drill failed: {e}", t.target), + )); + } + } + out +} + +/// An audit run as the journal records it (`audit.run`). +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AuditRun { + /// Its id (1 for the first). + pub id: u64, + /// The audit covers the books through this date. + pub through: NaiveDate, + /// The figure head it was made at ([`Store::figure_head`]). + pub through_seq: u64, + /// What it covers. + pub scope: Scope, + /// The programme's version ([`PROGRAM_VERSION`]). + pub program_version: String, + /// SHA-256 of the trial balance through the date. + pub tb_hash: String, + /// SHA-256 of the findings. + pub findings_sha256: String, + /// The workpaper's JSON, filed as a document. + pub workpaper_attachment: Option, + /// The workpaper's PDF, filed as a document. + pub workpaper_pdf: Option, + /// The journal line that recorded it. + pub seq: u64, +} + +/// What a run is made from, before it has an id or a seq. +#[derive(Clone, Debug)] +pub struct NewAuditRun { + /// Through this date. + pub through: NaiveDate, + /// The figure head the findings were computed at. + pub through_seq: u64, + /// What it covers. + pub scope: Scope, + /// SHA-256 of the trial balance. + pub tb_hash: String, + /// SHA-256 of the findings. + pub findings_sha256: String, + /// The filed workpaper JSON. + pub workpaper_attachment: Option, + /// The filed workpaper PDF. + pub workpaper_pdf: Option, +} + +/// What an attestation says it checked. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Attested { + /// A receipt against an entry. + Receipt { + /// The filed receipt. + attachment_id: u64, + /// The entry it supports. + entry_id: u64, + /// The amount the checker read off it. + amount_checked: Decimal, + /// The date the checker read off it. + date_checked: NaiveDate, + /// The checker's reading differs from the entry's. + disagrees: bool, + }, + /// An entry, reviewed whole. + Entry { + /// The entry. + entry_id: u64, + }, + /// A period, on an audit run. + Period { + /// The period. + period_id: u64, + /// The run it rests on. + audit_run_id: u64, + }, +} + +/// A claim that someone checked, as the journal records it. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Attestation { + /// The journal line. + pub seq: u64, + /// Who claims it: the caller's token name. + pub by: String, + /// When, UTC. + pub at: String, + /// How, as the client says. + pub method: Method, + /// A free note. + pub note: Option, + /// What was checked. + pub what: Attested, +} + +impl Attestation { + /// Its kind's word: `receipt`, `entry` or `period`. + pub fn kind(&self) -> &'static str { + match self.what { + Attested::Receipt { .. } => "receipt", + Attested::Entry { .. } => "entry", + Attested::Period { .. } => "period", + } + } +} + +/// Who claims an attestation, how, and what they add. +#[derive(Clone, Debug)] +pub struct Claim { + /// The caller's token name. + pub by: String, + /// How, as the client says. + pub method: Method, + /// A free note. + pub note: Option, +} + +impl Claim { + fn into_attestation(self, what: Attested) -> Attestation { + Attestation { + seq: 0, + by: self.by, + at: now(), + method: self.method, + note: self + .note + .map(|n| n.trim().to_string()) + .filter(|n| !n.is_empty()), + what, + } + } +} + +fn refused(why: impl Into) -> StoreError { + StoreError::AuditRefused(why.into()) +} + +fn now() -> String { + chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string() +} + +impl Store { + /// The seq of the last journal line that bears on a figure + /// (INVARIANT 1); zero for books with none. + pub fn figure_head(&self) -> Result { + let quoted: Vec = NOT_FIGURES.iter().map(|t| format!("'{t}'")).collect(); + let sql = format!( + "SELECT COALESCE(MAX(seq), 0) FROM events WHERE type NOT IN ({})", + quoted.join(",") + ); + let n: i64 = self.conn.query_row(&sql, [], |r| r.get(0))?; + Ok(n as u64) + } + + /// The entries with at least one receipt filed on them or linked to + /// them, not withdrawn. + pub fn entries_with_receipts(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT entry_id FROM attachments WHERE withdrawn_at IS NULL AND entry_id IS NOT NULL + UNION + SELECT l.subject_id FROM attachment_links l JOIN attachments a ON a.id = l.attachment_id + WHERE l.subject_kind = 'entry' AND a.withdrawn_at IS NULL", + )?; + let ids = stmt + .query_map([], |r| r.get::<_, i64>(0))? + .collect::, _>>()?; + Ok(ids.into_iter().map(|i| i as u64).collect()) + } + + /// What only the store can see: the chain, the database against a + /// replay, the stored files, the access log, extraction text and + /// duplicate files. Reads; changes nothing. + pub fn integrity_findings(&self) -> Vec { + let mut out = Vec::new(); + match self.verify() { + Ok(()) => {} + Err(StoreError::VerifyMismatch(why)) => out.push(found( + "projection_differs", + Subject::Books, + format!("the books differ from a replay of the journal: {why}"), + )), + Err(e) => out.push(found( + "journal_damaged", + Subject::Books, + format!("the journal does not verify: {e}"), + )), + } + let shas = |sql: &str| -> Vec { + self.conn + .prepare(sql) + .and_then(|mut s| { + s.query_map([], |r| r.get::<_, String>(0))? + .collect::, _>>() + }) + .unwrap_or_default() + }; + let mut blobs = shas("SELECT sha256 FROM blobs ORDER BY sha256"); + blobs.dedup(); + for sha in blobs { + if !self.blobs.exists(&sha) { + out.push(found( + "blob_missing", + Subject::Named(sha.chars().take(12).collect()), + format!("the stored file {sha} is not on disk"), + )); + } else if let Err(StoreError::CorruptBlob(_)) = self.blobs.get(&sha) { + out.push(found( + "blob_damaged", + Subject::Named(sha.chars().take(12).collect()), + format!("the stored file {sha} does not match its SHA-256"), + )); + } + } + if let Err(e) = crate::access::read(&self.data_dir) { + out.push(found( + "access_log_damaged", + Subject::Named("access log".into()), + format!("the access log does not verify: {e}"), + )); + } + for sha in shas( + "SELECT DISTINCT text_sha256 FROM extractions WHERE text_sha256 IS NOT NULL ORDER BY text_sha256", + ) { + if !self.blobs.exists(&sha) { + out.push(found( + "extraction_blob_missing", + Subject::Named(sha.chars().take(12).collect()), + format!("the extracted text {sha} is not on disk"), + )); + } + } + let dups: Vec<(String, String)> = self + .conn + .prepare( + "SELECT blob_sha, GROUP_CONCAT(id) FROM + (SELECT blob_sha, id FROM attachments WHERE withdrawn_at IS NULL ORDER BY id) + GROUP BY blob_sha HAVING COUNT(*) > 1 ORDER BY blob_sha", + ) + .and_then(|mut s| { + s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::, _>>() + }) + .unwrap_or_default(); + for (sha, ids) in dups { + out.push(found( + "duplicate_blob", + Subject::Named(sha.chars().take(12).collect()), + format!("attachments {ids} hold the same bytes ({sha})"), + )); + } + out + } + + /// Every audit run, oldest first. + pub fn audit_runs(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT id, through, through_seq, scope, program_version, tb_hash, findings_sha256, + workpaper_attachment, workpaper_pdf, seq FROM audits ORDER BY id", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + r.get::<_, String>(6)?, + r.get::<_, Option>(7)?, + r.get::<_, Option>(8)?, + r.get::<_, i64>(9)?, + )) + })?; + let mut out = Vec::new(); + for row in rows { + let (id, through, seq_at, scope, version, tb, fs, wp, pdf, seq) = row?; + out.push(AuditRun { + id: id as u64, + through: NaiveDate::parse_from_str(&through, "%Y-%m-%d") + .map_err(|_| StoreError::JournalCorrupt("audits.through".into()))?, + through_seq: seq_at as u64, + scope: Scope::parse(&scope) + .ok_or_else(|| StoreError::JournalCorrupt("audits.scope".into()))?, + program_version: version, + tb_hash: tb, + findings_sha256: fs, + workpaper_attachment: wp.map(|v| v as u64), + workpaper_pdf: pdf.map(|v| v as u64), + seq: seq as u64, + }); + } + Ok(out) + } + + /// One run. + pub fn audit_run(&self, id: u64) -> Result, StoreError> { + Ok(self.audit_runs()?.into_iter().find(|r| r.id == id)) + } + + /// The run that already answers an audit of `through` at `head` + /// (INVARIANT 2). + pub fn find_audit_run( + &self, + through: NaiveDate, + head: u64, + scope: Scope, + ) -> Result, StoreError> { + Ok(self.audit_runs()?.into_iter().find(|r| { + r.through == through + && r.through_seq == head + && r.scope == scope + && r.program_version == PROGRAM_VERSION + })) + } + + /// Whether the books' figures are where `run` left them + /// (IZ-1k): nothing bearing on a figure has been journaled since. + pub fn audit_is_current(&self, run: &AuditRun) -> Result { + Ok(run.through_seq == self.figure_head()?) + } + + /// Record an audit run (`audit.run`). Waits for the `audits` feature. + pub fn record_audit_run(&mut self, new: NewAuditRun) -> Result { + self.ensure_feature(crate::format::AUDITS)?; + if let Some(existing) = self.find_audit_run(new.through, new.through_seq, new.scope)? { + return Ok(existing); + } + for a in [new.workpaper_attachment, new.workpaper_pdf] + .into_iter() + .flatten() + { + self.attachment_state(a)?; + } + let id = self.audit_runs()?.last().map_or(1, |r| r.id + 1); + let mut run = AuditRun { + id, + through: new.through, + through_seq: new.through_seq, + scope: new.scope, + program_version: PROGRAM_VERSION.into(), + tb_hash: new.tb_hash, + findings_sha256: new.findings_sha256, + workpaper_attachment: new.workpaper_attachment, + workpaper_pdf: new.workpaper_pdf, + seq: 0, + }; + let line = self.append_journal("audit.run", crate::meta::audit_run(&run))?; + run.seq = line.seq; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(run) + } + + /// Every attestation, oldest first. + pub fn attestations(&self) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts, + method, amount_checked, date_checked, disagrees, note + FROM attestations ORDER BY seq", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, Option>(2)?, + r.get::<_, Option>(3)?, + r.get::<_, Option>(4)?, + r.get::<_, Option>(5)?, + r.get::<_, String>(6)?, + r.get::<_, String>(7)?, + r.get::<_, String>(8)?, + r.get::<_, Option>(9)?, + r.get::<_, Option>(10)?, + r.get::<_, Option>(11)?, + r.get::<_, Option>(12)?, + )) + })?; + let bad = |w: &str| StoreError::JournalCorrupt(format!("attestations.{w}")); + let mut out = Vec::new(); + for row in rows { + let (seq, kind, att, entry, period, run, by, at, method, amount, date, dis, note) = + row?; + let method: Value = serde_json::from_str(&method)?; + let what = match kind.as_str() { + "receipt" => Attested::Receipt { + attachment_id: att.ok_or_else(|| bad("attachment_id"))? as u64, + entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64, + amount_checked: amount + .and_then(|a| a.parse().ok()) + .ok_or_else(|| bad("amount_checked"))?, + date_checked: date + .and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()) + .ok_or_else(|| bad("date_checked"))?, + disagrees: dis.unwrap_or(0) != 0, + }, + "entry" => Attested::Entry { + entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64, + }, + "period" => Attested::Period { + period_id: period.ok_or_else(|| bad("period_id"))? as u64, + audit_run_id: run.ok_or_else(|| bad("audit_run_id"))? as u64, + }, + _ => return Err(bad("kind")), + }; + out.push(Attestation { + seq: seq as u64, + by, + at, + method: crate::meta::method_from_value(&method)?, + note, + what, + }); + } + Ok(out) + } + + fn journal_attestation( + &mut self, + line_type: &str, + a: Attestation, + ) -> Result { + let (line_type, payload) = (line_type, crate::meta::attestation(&a)); + let line = self.append_journal(line_type, payload)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(Attestation { seq: line.seq, ..a }) + } + + /// Attest that a receipt supports an entry (`receipt.attested`): the + /// checker's reading of its amount and date against the entry's. A + /// reading that differs is recorded as disagreeing and never counts + /// (INVARIANT 4). + pub fn attest_receipt( + &mut self, + attachment_id: u64, + entry_id: u64, + amount: Decimal, + date: NaiveDate, + claim: Claim, + ) -> Result { + self.ensure_feature(crate::format::AUDITS)?; + let entry = self + .ledger + .get_entry(entry_id) + .ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?; + if entry.status() != ledger_core::EntryStatus::Posted { + return Err(refused(format!("entry {entry_id} is not posted"))); + } + let (_, withdrawn) = self.attachment_state(attachment_id)?; + if withdrawn { + return Err(StoreError::AttachmentWithdrawn(attachment_id)); + } + if !self + .attachment_links(attachment_id)? + .contains(&crate::store::Subject::Entry(entry_id)) + { + return Err(refused(format!( + "attachment {attachment_id} is not filed on or linked to entry {entry_id}" + ))); + } + let debits = entry + .lines() + .iter() + .filter(|l| l.side == ledger_core::Side::Debit) + .try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount)); + let amount_agrees = + debits == Some(amount) || entry.lines().iter().any(|l| l.amount.amount == amount); + let disagrees = !amount_agrees || date != entry.date(); + self.journal_attestation( + "receipt.attested", + claim.into_attestation(Attested::Receipt { + attachment_id, + entry_id, + amount_checked: amount, + date_checked: date, + disagrees, + }), + ) + } + + /// Record that an entry was reviewed whole (`entry.reviewed`). + pub fn review_entry(&mut self, entry_id: u64, claim: Claim) -> Result { + self.ensure_feature(crate::format::AUDITS)?; + let entry = self + .ledger + .get_entry(entry_id) + .ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?; + if entry.status() != ledger_core::EntryStatus::Posted { + return Err(refused(format!("entry {entry_id} is not posted"))); + } + self.journal_attestation( + "entry.reviewed", + claim.into_attestation(Attested::Entry { entry_id }), + ) + } + + /// The run a period can be attested on: the latest at the current + /// figure head whose through date is on or after the period's end + /// (INVARIANT 3). + pub fn run_for_period(&self, period_id: u64) -> Result { + let period = self + .ledger + .get_period(period_id) + .ok_or_else(|| refused(format!("no period {period_id}")))?; + let head = self.figure_head()?; + let runs = self.audit_runs()?; + runs.into_iter() + .rev() + .find(|r| r.through_seq == head && r.through >= period.end) + .ok_or_else(|| { + refused(format!( + "no audit through {} or later stands at the books' current head: run books.audit first (an entry, a document, a close or a settings change since the last one moves the head)", + period.end + )) + }) + } + + /// Attest a period on an audit run at the current head + /// (`period.attested`). + pub fn attest_period( + &mut self, + period_id: u64, + claim: Claim, + ) -> Result<(Attestation, AuditRun), StoreError> { + self.ensure_feature(crate::format::AUDITS)?; + let run = self.run_for_period(period_id)?; + let a = self.journal_attestation( + "period.attested", + claim.into_attestation(Attested::Period { + period_id, + audit_run_id: run.id, + }), + )?; + Ok((a, run)) + } +} + +/// Read an `audits` row for a replayed line. +pub(crate) fn insert_run(conn: &rusqlite::Connection, run: &AuditRun) -> Result<(), StoreError> { + conn.execute( + "INSERT INTO audits(id, through, through_seq, scope, program_version, tb_hash, findings_sha256, + workpaper_attachment, workpaper_pdf, seq) + VALUES (?,?,?,?,?,?,?,?,?,?)", + params![ + run.id as i64, + run.through.to_string(), + run.through_seq as i64, + run.scope.key(), + run.program_version, + run.tb_hash, + run.findings_sha256, + run.workpaper_attachment.map(|v| v as i64), + run.workpaper_pdf.map(|v| v as i64), + run.seq as i64, + ], + )?; + Ok(()) +} + +/// Write an `attestations` row for a replayed line. +pub(crate) fn insert_attestation( + conn: &rusqlite::Connection, + a: &Attestation, +) -> Result<(), StoreError> { + let (mut att, mut entry, mut period, mut run) = (None, None, None, None); + let (mut amount, mut date, mut dis) = (None, None, None); + match &a.what { + Attested::Receipt { + attachment_id, + entry_id, + amount_checked, + date_checked, + disagrees, + } => { + att = Some(*attachment_id as i64); + entry = Some(*entry_id as i64); + amount = Some(amount_checked.to_string()); + date = Some(date_checked.to_string()); + dis = Some(i64::from(*disagrees)); + } + Attested::Entry { entry_id } => entry = Some(*entry_id as i64), + Attested::Period { + period_id, + audit_run_id, + } => { + period = Some(*period_id as i64); + run = Some(*audit_run_id as i64); + } + } + conn.execute( + "INSERT INTO attestations(seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts, + method, amount_checked, date_checked, disagrees, note) + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)", + params![ + a.seq as i64, + a.kind(), + att, + entry, + period, + run, + a.by, + a.at, + crate::meta::method_json(&a.method).to_string(), + amount, + date, + dis, + a.note, + ], + )?; + Ok(()) +} diff --git a/crates/ledger-store/src/describe.rs b/crates/ledger-store/src/describe.rs new file mode 100644 index 0000000..04bb1e1 --- /dev/null +++ b/crates/ledger-store/src/describe.rs @@ -0,0 +1,402 @@ +//! The journal in words: one sentence for each line, and the subjects a +//! line is about. The one describer the server's `books.journal_history` +//! reads. +//! +//! ```text +//! GOAL: an agent can read what the books did, in order, without the +//! journal file: each line says in a sentence what happened, to +//! what, and which entry it belongs to. +//! SOURCE: plan/served.md P5 step 4 (the only way to "read the journal"); +//! docs/receipt-store.md (the lines and their payloads); +//! docs/spec/rules.md B-26 (no output holds a full TIN). +//! INVARIANTS: +//! 1. Every line type the store writes has a sentence (test: every +//! `format::EVENT_TYPES` entry). +//! 2. A sentence quotes only identifiers, dates, amounts and the +//! short names and descriptions a person typed; never a setting's +//! value, a payload's secret, or a full TIN (they are masked). +//! 3. Lines are returned in journal order, from the line after +//! `after_seq`, at most `limit`; a filter never reorders them. +//! 4. A subject is `kind:id` (`entry:12`, `invoice:3`, `bill:3`, +//! `account:10`, `party:5`, `asset:2`); a line is about the subject +//! its type names and every one its payload refers to. +//! ``` + +use crate::journal::JournalLine; +use serde_json::Value; + +/// The most characters of any quoted text. +const QUOTE_MAX: usize = 80; + +/// One line of the journal, described. +#[derive(Clone, Debug, PartialEq)] +pub struct Described { + /// The line's seq. + pub seq: u64, + /// When it was written (UTC, RFC 3339). + pub ts: String, + /// Its type. + pub line_type: String, + /// A sentence. + pub summary: String, + /// The subjects it is about, as `kind:id`. + pub subjects: Vec, + /// The entry it belongs to, if it is about one. + pub entry_id: Option, +} + +/// Which lines to list (INVARIANT 3). +#[derive(Clone, Debug, Default)] +pub struct HistoryFilter { + /// Only lines after this seq. + pub after_seq: u64, + /// Only these types: an exact type (`entry.posted`) or a group ending + /// in a dot (`entry.`). Empty: every type. + pub types: Vec, + /// Only lines about this subject (`entry:12`). + pub subject: Option, + /// At most this many. + pub limit: usize, +} + +/// A subject as `kind:id`, parsed. +pub fn parse_subject(text: &str) -> Result<(String, u64), String> { + let bad = || { + format!( + "subject {text:?}: give kind:id, like entry:12, invoice:3, bill:3, account:10, party:5 or asset:2" + ) + }; + let (kind, id) = text.split_once(':').ok_or_else(bad)?; + let kinds = [ + "entry", + "invoice", + "bill", + "account", + "party", + "asset", + "period", + "schedule", + "reconciliation", + "payroll_run", + "project", + ]; + if !kinds.contains(&kind.trim()) { + return Err(bad()); + } + let id = id.trim().parse::().map_err(|_| bad())?; + Ok((kind.trim().to_string(), id)) +} + +fn num(p: &Value, k: &str) -> Option { + p.get(k).and_then(Value::as_u64) +} + +fn quote(text: &str) -> String { + let t: String = text.split_whitespace().collect::>().join(" "); + if t.chars().count() > QUOTE_MAX { + let mut s: String = t.chars().take(QUOTE_MAX - 1).collect(); + s.push('…'); + s + } else { + t + } +} + +/// The subjects a line is about (INVARIANT 4). +pub fn subjects(line: &JournalLine) -> Vec { + let p = &line.payload; + let mut out: Vec = Vec::new(); + let mut add = |kind: &str, id: Option| { + if let Some(id) = id { + let s = format!("{kind}:{id}"); + if !out.contains(&s) { + out.push(s); + } + } + }; + let prefix = line.line_type.split('.').next().unwrap_or(""); + let own = match prefix { + "entry" => Some("entry"), + "invoice" => Some("invoice"), + "bill" => Some("bill"), + "account" => Some("account"), + "party" => Some("party"), + "asset" => Some("asset"), + "period" => Some("period"), + "schedule" => Some("schedule"), + "reconciliation" => Some("reconciliation"), + "payroll" => Some("payroll_run"), + "project" => Some("project"), + _ => None, + }; + if let Some(kind) = own { + // `entry.posted` carries `entry_id`; `entry.created` carries `id`. + let id = match prefix { + "entry" => num(p, "entry_id").or_else(|| num(p, "id")), + "invoice" => num(p, "invoice_id").or_else(|| num(p, "id")), + "bill" => num(p, "bill_id").or_else(|| num(p, "id")), + "asset" => num(p, "asset_id").or_else(|| num(p, "id")), + "party" => num(p, "party_id").or_else(|| num(p, "id")), + _ => num(p, "id"), + }; + add(kind, id); + } + add("entry", num(p, "entry_id")); + add("invoice", num(p, "invoice_id")); + add("bill", num(p, "bill_id")); + add("account", num(p, "account_id")); + add("asset", num(p, "asset_id")); + add("party", num(p, "party_id")); + if line.line_type == "receipt.attached" || line.line_type == "receipt.linked" { + add("entry", num(p, "entry_id")); + } + out +} + +/// A line's amount, when it has one: a string, or `{amount: ..}`. +fn amount_of(p: &Value) -> Option { + let a = p.get("amount")?; + a.as_str() + .map(str::to_string) + .or_else(|| a.get("amount").and_then(Value::as_str).map(str::to_string)) +} + +/// The sentence for a line (INVARIANT 2). +pub fn describe(line: &JournalLine) -> String { + let p = &line.payload; + let ty = line.line_type.as_str(); + let mut head = ty.replace(['.', '_'], " "); + if let Some(first) = head.get(0..1) { + head = first.to_uppercase() + &head[1..]; + } + let mut parts: Vec = Vec::new(); + // What it is about, by id. + for (k, label) in [ + ("entry_id", "entry"), + ("invoice_id", "invoice"), + ("bill_id", "bill"), + ("account_id", "account"), + ("asset_id", "asset"), + ("party_id", "party"), + ("attachment_id", "attachment"), + ] { + if let Some(n) = num(p, k) { + parts.push(format!("{label} {n}")); + } + } + if parts.is_empty() + && let Some(n) = num(p, "id") + { + parts.push(format!("#{n}")); + } + // What a person typed about it. + for k in ["description", "title", "name", "subject", "code"] { + if let Some(t) = p.get(k).and_then(Value::as_str) + && !t.is_empty() + { + parts.push(quote(t)); + break; + } + } + if let Some(a) = amount_of(p) { + parts.push(a); + } + if let Some(d) = p.get("date").and_then(Value::as_str) { + parts.push(d.to_string()); + } + // Settings: which keys, never their values. + if ty == "settings.set" + && let Some(changes) = p.get("changes").and_then(Value::as_array) + { + let keys: Vec<&str> = changes + .iter() + .filter_map(|c| c.get("key").and_then(Value::as_str)) + .take(8) + .collect(); + parts.push(keys.join(", ")); + } + let text = if parts.is_empty() { + head + } else { + format!("{head}: {}", parts.join(", ")) + }; + ledger_core::parties::redact_full_tins(&text).into_owned() +} + +/// Describe one line. +pub fn describe_line(line: &JournalLine) -> Described { + let subjects = subjects(line); + let entry_id = subjects + .iter() + .find_map(|s| s.strip_prefix("entry:").and_then(|n| n.parse().ok())); + Described { + seq: line.seq, + ts: line.ts.clone(), + line_type: line.line_type.clone(), + summary: describe(line), + subjects, + entry_id, + } +} + +/// The lines `filter` asks for, described, in journal order (INVARIANT 3), +/// and whether more match after the last one returned. +pub fn history(lines: &[JournalLine], filter: &HistoryFilter) -> (Vec, bool) { + let wanted = |l: &JournalLine| { + l.seq > filter.after_seq + && (filter.types.is_empty() + || filter.types.iter().any(|t| { + if t.ends_with('.') { + l.line_type.starts_with(t.as_str()) + } else { + l.line_type == *t + } + })) + && filter + .subject + .as_ref() + .is_none_or(|s| subjects(l).contains(s)) + }; + let mut out = Vec::new(); + let mut more = false; + for l in lines.iter().filter(|l| wanted(l)) { + if out.len() == filter.limit { + more = true; + break; + } + out.push(describe_line(l)); + } + (out, more) +} + +/// The journal in words, read from the books' own journal file. +#[derive(Clone, Debug, PartialEq)] +pub struct History { + /// The lines asked for, in order. + pub lines: Vec, + /// Whether more match after the last. + pub more: bool, + /// The journal's last seq. + pub head_seq: u64, + /// Torn tails set aside on open: (when, bytes). + pub tail_repairs: Vec<(String, u64)>, +} + +impl crate::Store { + /// `filter`'s lines of the journal, described (the only way an agent + /// reads the journal: `books.journal_history`). The file is read and + /// its chain verified, as `Store::verify` does. + pub fn journal_history(&self, filter: &HistoryFilter) -> Result { + let (lines, _format) = crate::journal::read_journal_and_format( + &self.data_dir().join(crate::store::JOURNAL_FILE), + )?; + let (described, more) = history(&lines, filter); + let tail_repairs = self + .tail_repairs()? + .into_iter() + .map(|(when, bytes, _saved_as)| (when, bytes)) + .collect(); + Ok(History { + lines: described, + more, + head_seq: lines.last().map_or(0, |l| l.seq), + tail_repairs, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn line(seq: u64, ty: &str, payload: Value) -> JournalLine { + JournalLine { + seq, + ts: "2026-10-29T00:00:00Z".into(), + line_type: ty.into(), + payload, + prev: String::new(), + } + } + + #[test] + fn every_event_type_has_a_sentence() { + for ty in crate::format::EVENT_TYPES { + let s = describe(&line(1, ty, json!({}))); + assert!(!s.is_empty(), "{ty}"); + } + } + + #[test] + fn a_sentence_names_the_entry_and_what_a_person_typed() { + let l = line( + 5, + "entry.posted", + json!({"entry_id": 12, "description": "Office supplies"}), + ); + assert_eq!(describe(&l), "Entry posted: entry 12, Office supplies"); + assert_eq!(subjects(&l), vec!["entry:12"]); + assert_eq!(describe_line(&l).entry_id, Some(12)); + } + + #[test] + fn settings_show_their_keys_never_their_values() { + let l = line( + 2, + "settings.set", + json!({"changes": [{"key": "company.ein", "value": "12-3456789"}]}), + ); + let s = describe(&l); + assert!( + s.contains("company.ein") && !s.contains("12-3456789"), + "{s}" + ); + } + + #[test] + fn a_full_tin_in_a_description_is_masked() { + let l = line( + 3, + "entry.created", + json!({"id": 1, "description": "TIN 123-45-6789"}), + ); + assert!(!describe(&l).contains("6789"), "{}", describe(&l)); + } + + #[test] + fn history_filters_in_order_and_says_when_more_remain() { + let lines: Vec = (1..=6) + .map(|i| { + line( + i, + if i % 2 == 0 { + "entry.posted" + } else { + "entry.created" + }, + json!({"entry_id": i % 3, "id": i % 3}), + ) + }) + .collect(); + let f = HistoryFilter { + after_seq: 1, + types: vec!["entry.posted".into()], + subject: None, + limit: 2, + }; + let (got, more) = history(&lines, &f); + assert_eq!(got.iter().map(|d| d.seq).collect::>(), vec![2, 4]); + assert!(more); + let f = HistoryFilter { + subject: Some("entry:0".into()), + limit: 10, + ..Default::default() + }; + let (got, more) = history(&lines, &f); + assert_eq!(got.iter().map(|d| d.seq).collect::>(), vec![3, 6]); + assert!(!more); + assert!(parse_subject("entry:12").is_ok()); + assert!(parse_subject("nonsense").is_err()); + } +} diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 8bbe1dd..c607070 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -162,6 +162,12 @@ pub enum StoreError { )] WriteOffNeedsEvidence, + /// An audit or an attestation the books refuse (plan P8; rules.md + /// B-45): a stale head, an entry not posted, a receipt not on the + /// entry. The words say what to do. + #[error("{0}")] + AuditRefused(String), + /// The requested base currency differs from the store's. #[error("base currency mismatch: store is {0}, requested {1}")] CurrencyMismatch(String, String), diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 651de04..5bca69e 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -65,6 +65,8 @@ pub const KNOWN_FEATURES: &[&str] = &[ DOCUMENTS_V2, ATTACHMENT_TEXT, DRAFTS_V2, + SOURCE_REVISIONS, + AUDITS, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -104,6 +106,9 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> { | "bills.discount_warn_days" | "company.email" | "company.website" => Some(DOCUMENTS_V2), + // P8: what counts as an attestation, and whether the CPA package + // waits for an audit. + "policy.attestation_bar" | "policy.cpa_package_requires_audit" => Some(AUDITS), _ => None, } } @@ -218,6 +223,26 @@ pub const ATTACHMENT_TEXT: &str = "attachment_text"; /// (rules.md B-27, B-43). pub const DRAFTS_V2: &str = "drafts_v2"; +/// Feature: `source_revisions` (plan P5): `convention.source_revised`, +/// `decision.source_revised` and `open_item.source_revised` correct who +/// decided a convention, a decision or an open item, when and on what, +/// leaving its text as it was. An older version doesn't know those event +/// types: it would refuse them as unknown, or, replaying around them, +/// show the old source as the decision's authority; it stops at the +/// declaration instead (rules.md B-27). +pub const SOURCE_REVISIONS: &str = "source_revisions"; + +/// Feature: `audits` (plan P8): `audit.run` (a programme of checks run +/// through a date, with the hash of its findings and its workpaper), +/// `receipt.attested`, `entry.reviewed` and `period.attested` (claims by +/// an authenticated client that it checked), and the two settings that +/// say what counts (`policy.attestation_bar`) and whether the CPA package +/// waits for an audit (`policy.cpa_package_requires_audit`). An older +/// version doesn't know those event types or settings; it stops at the +/// declaration instead of dropping the claims a handoff rests on +/// (rules.md B-27, B-45). +pub const AUDITS: &str = "audits"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -243,13 +268,16 @@ pub const EVENT_TYPES: &[&str] = &[ "attachment.text_extracted", "attachment.unlinked", "attachment.withdrawn", + "audit.run", "bill.created", "bill.paid", "class.added", "convention.retired", "convention.set", + "convention.source_revised", "customer.added", "decision.recorded", + "decision.source_revised", "document.filed", "entry.approved", "entry.created", @@ -263,6 +291,7 @@ pub const EVENT_TYPES: &[&str] = &[ "entry.party_set", "entry.posted", "entry.reversed", + "entry.reviewed", "entry.settles", "entry.vendor_set", "fx.rate_set", @@ -276,6 +305,7 @@ pub const EVENT_TYPES: &[&str] = &[ "line.cleared", "open_item.raised", "open_item.resolved", + "open_item.source_revised", "party.added", "party.creditor_set", "party.role_set", @@ -285,10 +315,12 @@ pub const EVENT_TYPES: &[&str] = &[ "party.w9_set", "payroll.run", "payroll.taxes_remitted", + "period.attested", "period.closed", "period.created", "project.set", "receipt.attached", + "receipt.attested", "receipt.linked", "receipt.unlinked", "reconciliation.completed", @@ -989,6 +1021,72 @@ mod tests { assert_eq!(up.adds(), vec!["the journal feature drafts_v2"]); } + /// The version before P5 (every feature through `drafts_v2`) stops at a + /// declaration of `source_revisions` as newer, and books that declared + /// every earlier feature still wait for it (B-27, D-39). + #[test] + fn source_revisions_is_its_own_feature() { + assert!(KNOWN_FEATURES.contains(&SOURCE_REVISIONS)); + let before: Vec<&str> = KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != SOURCE_REVISIONS) + .collect(); + let l = line( + FORMAT_SET, + json!({"format": 2, "features": [DRAFTS_V2, SOURCE_REVISIONS]}), + ); + match declared_knowing(&l, &before) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, "journal features source_revisions") + } + other => panic!("expected NewerJournal, got {other:?}"), + } + let older = JournalFormat { + format: 2, + features: before.iter().map(|f| f.to_string()).collect(), + }; + assert!(matches!( + older.require_feature(SOURCE_REVISIONS), + Err(StoreError::FormatUpgradePending { .. }) + )); + let up = older.upgrade().expect("waits for it"); + assert_eq!(up.adds(), vec!["the journal feature source_revisions"]); + } + + /// The version before P8 stops at a declaration of `audits` as newer, + /// never dropping an attestation a handoff rests on; books that + /// declared every earlier feature still wait for it (B-27, D-39). + #[test] + fn audits_is_its_own_feature() { + assert!(KNOWN_FEATURES.contains(&AUDITS)); + let before: Vec<&str> = KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != AUDITS) + .collect(); + let l = line( + FORMAT_SET, + json!({"format": 2, "features": [DRAFTS_V2, AUDITS]}), + ); + match declared_knowing(&l, &before) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, "journal features audits") + } + other => panic!("expected NewerJournal, got {other:?}"), + } + let older = JournalFormat { + format: 2, + features: before.iter().map(|f| f.to_string()).collect(), + }; + assert!(matches!( + older.require_feature(AUDITS), + Err(StoreError::FormatUpgradePending { .. }) + )); + let up = older.upgrade().expect("waits for it"); + assert_eq!(up.adds(), vec!["the journal feature audits"]); + } + /// This version's format: the newest it reads, every feature it /// knows, each once, sorted (what new books and an upgrade declare). #[test] diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index e0b1502..0ba779d 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -23,6 +23,7 @@ pub mod auth; pub mod backup; pub mod blob; pub mod chain; +pub mod describe; pub mod error; pub mod exports; pub mod form1099; @@ -49,6 +50,7 @@ pub use error::StoreError; pub use format::{FormatUpgrade, JournalFormat, SUPPORTED_JOURNAL_FORMAT}; pub use journal::{GENESIS_PREV, JournalLine, JournalWriter}; pub use snapshot::{DiskCheck, Snapshot}; +pub use store::audit; pub use store::{ Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, Extraction, ExtractionStatus, InvoicePreview, JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, diff --git a/crates/ledger-store/src/meta.rs b/crates/ledger-store/src/meta.rs index bf17644..876da39 100644 --- a/crates/ledger-store/src/meta.rs +++ b/crates/ledger-store/src/meta.rs @@ -17,7 +17,9 @@ use crate::error::StoreError; use crate::journal::JournalLine; use chrono::NaiveDate; use ledger_core::arap::{SendKind, Sent, SentVia}; -use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem}; +use ledger_core::conventions::{ + Approval, ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem, +}; use ledger_core::settings::{Change, Source}; use ledger_core::{ Annotation, AnnotationFields, ClassId, DraftState, ProjectFacts, ProjectKind, ProjectStatus, @@ -174,6 +176,71 @@ pub fn convention_retired_of(p: &Value) -> Result<(String, String, Source), Stor )) } +/// `convention.source_revised {id, source, reason}` (journal feature +/// `source_revisions`): the convention's text is unchanged. +pub fn convention_source_revised(id: &str, source: &Source, reason: &str) -> Value { + json!({ "id": id, "source": source_json(source), "reason": reason }) +} + +/// Read a `convention.source_revised`: `(id, source, reason)`. +pub fn convention_source_revised_of(p: &Value) -> Result<(String, Source, String), StoreError> { + const E: &str = "convention.source_revised"; + Ok(( + str_of(p, E, "id")?, + source_of(p, E, "source")?, + str_of(p, E, "reason")?, + )) +} + +/// `decision.source_revised {id, source: {decided_by, decided_on, +/// citation?}, reason}`. +pub fn decision_source_revised(id: u64, s: &DecisionSource, reason: &str) -> Value { + let mut src = Map::new(); + src.insert("decided_by".into(), json!(s.decided_by)); + src.insert("decided_on".into(), json!(s.decided_on.to_string())); + put(&mut src, "citation", s.citation.clone()); + json!({ "id": id, "source": Value::Object(src), "reason": reason }) +} + +/// Read a `decision.source_revised`: `(id, source, reason)`. +pub fn decision_source_revised_of(p: &Value) -> Result<(u64, DecisionSource, String), StoreError> { + const E: &str = "decision.source_revised"; + let src = p.get("source").ok_or_else(|| corrupt(E, "source"))?; + let decided_by = src + .get("decided_by") + .and_then(Value::as_array) + .and_then(|a| { + a.iter() + .map(|v| v.as_str().map(str::to_string)) + .collect::>>() + }) + .ok_or_else(|| corrupt(E, "source.decided_by"))?; + Ok(( + u64_of(p, E, "id")?, + DecisionSource { + decided_by, + decided_on: date_of(&str_of(src, E, "decided_on")?, E, "source.decided_on")?, + citation: opt_str(src, E, "citation")?, + }, + str_of(p, E, "reason")?, + )) +} + +/// `open_item.source_revised {id, source, reason}`. +pub fn open_item_source_revised(id: u64, source: &Source, reason: &str) -> Value { + json!({ "id": id, "source": source_json(source), "reason": reason }) +} + +/// Read an `open_item.source_revised`: `(id, source, reason)`. +pub fn open_item_source_revised_of(p: &Value) -> Result<(u64, Source, String), StoreError> { + const E: &str = "open_item.source_revised"; + Ok(( + u64_of(p, E, "id")?, + source_of(p, E, "source")?, + str_of(p, E, "reason")?, + )) +} + /// `decision.recorded {id, subject, decision_md, decided_by, decided_on, /// evidence_attachment?, supersedes?, citation?, projects?}`: `projects` /// only when it names any (journal feature `projects`), so a decision @@ -662,6 +729,198 @@ pub fn draft_discarded_of(p: &Value) -> Result<(u64, String), StoreError> { Ok((u64_of(p, E, "id")?, str_of(p, E, "reason")?)) } +// --------------------------------------------------------------------------- +// Audits and attestations (plan P8, feature `audits`) +// --------------------------------------------------------------------------- + +/// A method as journaled: only the parts the client gave. +pub fn method_json(m: &ledger_core::audit::Method) -> Value { + let mut o = Map::new(); + put(&mut o, "model", m.model.clone()); + put( + &mut o, + "reasoning_effort", + m.reasoning_effort.map(|e| e.key()), + ); + put(&mut o, "adversarial", m.adversarial); + put(&mut o, "verifier_agents", m.verifier_agents); + Value::Object(o) +} + +/// Read a method. A key or an effort this version doesn't know is newer +/// when a line is at hand (B-27), else corrupt. +fn method_of( + line: Option<&crate::journal::JournalLine>, + m: &Value, + event: &str, +) -> Result { + let o = m.as_object().ok_or_else(|| corrupt(event, "method"))?; + let unknown = |what: String| match line { + Some(l) => crate::format::newer_content(l, what), + None => corrupt(event, "method"), + }; + let mut out = ledger_core::audit::Method::default(); + for (k, v) in o { + match k.as_str() { + "model" => { + out.model = Some( + v.as_str() + .ok_or_else(|| corrupt(event, "method.model"))? + .to_string(), + ) + } + "reasoning_effort" => { + let s = v + .as_str() + .ok_or_else(|| corrupt(event, "method.reasoning_effort"))?; + out.reasoning_effort = Some( + ledger_core::audit::Effort::parse(s) + .ok_or_else(|| unknown(format!("the reasoning effort {s:?}")))?, + ); + } + "adversarial" => { + out.adversarial = Some( + v.as_bool() + .ok_or_else(|| corrupt(event, "method.adversarial"))?, + ) + } + "verifier_agents" => { + out.verifier_agents = Some( + v.as_u64() + .and_then(|n| u32::try_from(n).ok()) + .ok_or_else(|| corrupt(event, "method.verifier_agents"))?, + ) + } + other => return Err(unknown(format!("the method key {other:?}"))), + } + } + Ok(out) +} + +/// A method read back from the projection. +pub fn method_from_value(m: &Value) -> Result { + method_of(None, m, "attestations") +} + +/// `audit.run {id, through, through_seq, scope, program_version, tb_hash, +/// findings_sha256, workpaper_attachment?, workpaper_pdf?}`. +pub fn audit_run(r: &crate::audit::AuditRun) -> Value { + let mut o = Map::new(); + o.insert("id".into(), json!(r.id)); + o.insert("through".into(), json!(r.through.to_string())); + o.insert("through_seq".into(), json!(r.through_seq)); + o.insert("scope".into(), json!(r.scope.key())); + o.insert("program_version".into(), json!(r.program_version)); + o.insert("tb_hash".into(), json!(r.tb_hash)); + o.insert("findings_sha256".into(), json!(r.findings_sha256)); + put(&mut o, "workpaper_attachment", r.workpaper_attachment); + put(&mut o, "workpaper_pdf", r.workpaper_pdf); + Value::Object(o) +} + +/// Read an `audit.run`. A scope this version doesn't know is newer. +pub fn audit_run_of( + line: &crate::journal::JournalLine, +) -> Result { + const E: &str = "audit.run"; + let p = &line.payload; + let scope_key = str_of(p, E, "scope")?; + let scope = ledger_core::audit::Scope::parse(&scope_key).ok_or_else(|| { + crate::format::newer_content(line, format!("the audit scope {scope_key:?}")) + })?; + Ok(crate::audit::AuditRun { + id: u64_of(p, E, "id")?, + through: date_of(&str_of(p, E, "through")?, E, "through")?, + through_seq: u64_of(p, E, "through_seq")?, + scope, + program_version: str_of(p, E, "program_version")?, + tb_hash: str_of(p, E, "tb_hash")?, + findings_sha256: str_of(p, E, "findings_sha256")?, + workpaper_attachment: opt_u64(p, E, "workpaper_attachment")?, + workpaper_pdf: opt_u64(p, E, "workpaper_pdf")?, + seq: line.seq, + }) +} + +/// The payload of `receipt.attested`, `entry.reviewed` or `period.attested`. +pub fn attestation(a: &crate::audit::Attestation) -> Value { + use crate::audit::Attested; + let mut o = Map::new(); + match &a.what { + Attested::Receipt { + attachment_id, + entry_id, + amount_checked, + date_checked, + disagrees, + } => { + o.insert("attachment_id".into(), json!(attachment_id)); + o.insert("entry_id".into(), json!(entry_id)); + o.insert("amount_checked".into(), json!(amount_checked.to_string())); + o.insert("date_checked".into(), json!(date_checked.to_string())); + o.insert("disagrees".into(), json!(disagrees)); + } + Attested::Entry { entry_id } => { + o.insert("entry_id".into(), json!(entry_id)); + } + Attested::Period { + period_id, + audit_run_id, + } => { + o.insert("period_id".into(), json!(period_id)); + o.insert("audit_run_id".into(), json!(audit_run_id)); + } + } + o.insert("by".into(), json!(a.by)); + o.insert("at".into(), json!(a.at)); + o.insert("method".into(), method_json(&a.method)); + put(&mut o, "note", a.note.clone()); + Value::Object(o) +} + +/// Read one of the three attestation events by its line. +pub fn attestation_of( + line: &crate::journal::JournalLine, +) -> Result { + use crate::audit::Attested; + let p = &line.payload; + let e = line.line_type.as_str(); + let what = match e { + "receipt.attested" => Attested::Receipt { + attachment_id: u64_of(p, e, "attachment_id")?, + entry_id: u64_of(p, e, "entry_id")?, + amount_checked: str_of(p, e, "amount_checked")? + .parse() + .map_err(|_| corrupt(e, "amount_checked"))?, + date_checked: date_of(&str_of(p, e, "date_checked")?, e, "date_checked")?, + disagrees: p + .get("disagrees") + .and_then(Value::as_bool) + .ok_or_else(|| corrupt(e, "disagrees"))?, + }, + "entry.reviewed" => Attested::Entry { + entry_id: u64_of(p, e, "entry_id")?, + }, + "period.attested" => Attested::Period { + period_id: u64_of(p, e, "period_id")?, + audit_run_id: u64_of(p, e, "audit_run_id")?, + }, + _ => return Err(crate::format::unknown_type(line)), + }; + Ok(crate::audit::Attestation { + seq: line.seq, + by: str_of(p, e, "by")?, + at: str_of(p, e, "at")?, + method: method_of( + Some(line), + p.get("method").ok_or_else(|| corrupt(e, "method"))?, + e, + )?, + note: opt_str(p, e, "note")?, + what, + }) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index f0e1e70..bd498c8 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 19; +pub const SCHEMA_VERSION: u32 = 20; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -485,5 +485,41 @@ CREATE TABLE IF NOT EXISTS draft_states( state TEXT NOT NULL, note TEXT NULL ); +"#, + r#" +-- v20: audits and attestations (plan P8, feature `audits`). An audit run +-- is a row per `audit.run`: the date it covers, the figure head it was +-- made at, the hashes that make it reproducible and the filed workpaper. +-- An attestation is a row per `receipt.attested`, `entry.reviewed` or +-- `period.attested`; whether one counts is derived against +-- `policy.attestation_bar` when asked and never stored. The method is the +-- client's own claim, kept as canonical JSON. Amounts stay TEXT. +CREATE TABLE IF NOT EXISTS audits( + id INTEGER PRIMARY KEY, + through TEXT NOT NULL, + through_seq INTEGER NOT NULL, + scope TEXT NOT NULL, + program_version TEXT NOT NULL, + tb_hash TEXT NOT NULL, + findings_sha256 TEXT NOT NULL, + workpaper_attachment INTEGER NULL, + workpaper_pdf INTEGER NULL, + seq INTEGER NOT NULL +); +CREATE TABLE IF NOT EXISTS attestations( + seq INTEGER PRIMARY KEY, + kind TEXT NOT NULL, + attachment_id INTEGER NULL, + entry_id INTEGER NULL, + period_id INTEGER NULL, + audit_run_id INTEGER NULL, + by_who TEXT NOT NULL, + at_ts TEXT NOT NULL, + method TEXT NOT NULL, + amount_checked TEXT NULL, + date_checked TEXT NULL, + disagrees INTEGER NULL, + note TEXT NULL +); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 223047d..12d1a30 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -51,6 +51,9 @@ use rust_decimal::Decimal; use serde_json::{Value, json}; use std::path::{Path, PathBuf}; +#[path = "audit.rs"] +pub mod audit; + /// File names inside the data directory. pub const JOURNAL_FILE: &str = "journal.jsonl"; @@ -1117,6 +1120,67 @@ impl Store { Ok(()) } + /// Correct who decided a convention, when and on what + /// (`convention.source_revised`, journal feature `source_revisions`), + /// its text unchanged: `c` is the convention as it stands, with the + /// corrected source. The reason is journaled with it. + pub fn revise_convention_source( + &mut self, + c: &ledger_core::conventions::NewConvention, + reason: &str, + ) -> Result<(), StoreError> { + self.ensure_feature(crate::format::SOURCE_REVISIONS)?; + let (source, reason) = self.ledger.check_convention_source(c, reason)?; + self.ensure_format()?; + let line = self.append_journal( + "convention.source_revised", + crate::meta::convention_source_revised(&c.id, &source, &reason), + )?; + self.ledger.apply_convention_source(&c.id, source)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Correct who decided a decision, when and on what + /// (`decision.source_revised`, journal feature `source_revisions`). + pub fn revise_decision_source( + &mut self, + id: u64, + source: &ledger_core::conventions::DecisionSource, + reason: &str, + ) -> Result<(), StoreError> { + self.ensure_feature(crate::format::SOURCE_REVISIONS)?; + let (source, reason) = self.ledger.check_decision_source(id, source, reason)?; + self.ensure_format()?; + let line = self.append_journal( + "decision.source_revised", + crate::meta::decision_source_revised(id, &source, &reason), + )?; + self.ledger.apply_decision_source(id, source)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Correct who raised an open item, when and on what + /// (`open_item.source_revised`, journal feature `source_revisions`). + pub fn revise_open_item_source( + &mut self, + id: u64, + source: &ledger_core::settings::Source, + reason: &str, + ) -> Result<(), StoreError> { + self.ensure_feature(crate::format::SOURCE_REVISIONS)?; + let (source, reason) = self.ledger.check_open_item_source(id, source, reason)?; + self.ensure_format()?; + let line = self.append_journal( + "open_item.source_revised", + crate::meta::open_item_source_revised(id, &source, &reason), + )?; + self.ledger.apply_open_item_source(id, source)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Record a decision (`decision.recorded`); its evidence, if any, is /// a filed attachment not withdrawn. Returns its id. pub fn record_decision( @@ -7533,6 +7597,21 @@ fn replay_line( let a = crate::meta::annotated_of(line)?; ledger.replay_annotate_attachment(a)?; } + "audit.run" => { + // Not accounting state; the projection records it. Checked + // for shape here (an unknown scope is newer, rules.md B-27). + crate::meta::audit_run_of(line)?; + } + // A claim that someone checked; no figure moves. + "receipt.attested" => { + crate::meta::attestation_of(line)?; + } + "entry.reviewed" => { + crate::meta::attestation_of(line)?; + } + "period.attested" => { + crate::meta::attestation_of(line)?; + } "entry.settles" => { let (entry, settles) = crate::meta::entry_settles_of(p)?; ledger.replay_set_entry_settles(entry, settles)?; @@ -7945,6 +8024,18 @@ fn replay_line( let (id, reason, source) = crate::meta::convention_retired_of(p)?; ledger.apply_retire_convention(&id, reason, source, line.seq)?; } + "convention.source_revised" => { + let (id, source, _reason) = crate::meta::convention_source_revised_of(p)?; + ledger.apply_convention_source(&id, source)?; + } + "decision.source_revised" => { + let (id, source, _reason) = crate::meta::decision_source_revised_of(p)?; + ledger.apply_decision_source(id, source)?; + } + "open_item.source_revised" => { + let (id, source, _reason) = crate::meta::open_item_source_revised_of(p)?; + ledger.apply_open_item_source(id, source)?; + } "decision.recorded" => { let (id, d) = crate::meta::decision_recorded_of(p)?; ledger.apply_decision(id, d, line.seq)?; @@ -8736,6 +8827,22 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr ], )?; } + "audit.run" => { + let run = crate::meta::audit_run_of(line)?; + audit::insert_run(conn, &run)?; + } + "receipt.attested" => { + let a = crate::meta::attestation_of(line)?; + audit::insert_attestation(conn, &a)?; + } + "entry.reviewed" => { + let a = crate::meta::attestation_of(line)?; + audit::insert_attestation(conn, &a)?; + } + "period.attested" => { + let a = crate::meta::attestation_of(line)?; + audit::insert_attestation(conn, &a)?; + } "entry.settles" => { // Rebuilt into the ledger by replay; no report rows. } @@ -9042,6 +9149,32 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr ], )?; } + "convention.source_revised" => { + let (id, source, _reason) = crate::meta::convention_source_revised_of(p)?; + conn.execute( + "UPDATE conventions SET source_json = ? WHERE id = ?", + params![crate::meta::source_json(&source).to_string(), id], + )?; + } + "decision.source_revised" => { + let (id, source, _reason) = crate::meta::decision_source_revised_of(p)?; + conn.execute( + "UPDATE decisions SET decided_by_json = ?, decided_on = ?, citation = ? WHERE id = ?", + params![ + serde_json::to_string(&source.decided_by)?, + source.decided_on.to_string(), + source.citation, + id as i64 + ], + )?; + } + "open_item.source_revised" => { + let (id, source, _reason) = crate::meta::open_item_source_revised_of(p)?; + conn.execute( + "UPDATE open_items SET source_json = ? WHERE id = ?", + params![crate::meta::source_json(&source).to_string(), id as i64], + )?; + } "decision.recorded" => { let (id, d) = crate::meta::decision_recorded_of(p)?; conn.execute( diff --git a/crates/ledger-store/tests/audit.rs b/crates/ledger-store/tests/audit.rs new file mode 100644 index 0000000..7fd1f7e --- /dev/null +++ b/crates/ledger-store/tests/audit.rs @@ -0,0 +1,612 @@ +//! Audits and attestations in the store (plan P8; rules.md B-45): the +//! `audits` feature, the figure head, idempotent runs, attestations that +//! rest on a head, and the integrity findings only the store can see. + +use chrono::NaiveDate; +use ledger_core::audit::{Effort, Method, Scope}; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::audit::{self, Attested, BackupStanding, Claim, NewAuditRun, backup_findings}; +use ledger_store::format::{AUDITS, JournalFormat}; +use ledger_store::{BlobStore, JOURNAL_FILE, JournalWriter, Store, StoreError}; +use rust_decimal::Decimal; +use serde_json::json; +use std::path::PathBuf; + +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + let dir = std::env::temp_dir().join(format!("wb-audit-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +fn d(m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, m, day).unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(s.parse().unwrap(), Currency::USD) +} + +struct Fx { + bank: u64, + capital: u64, + supplies: u64, + /// A posted 40.00 purchase. + entry: u64, +} + +/// A bank, capital and an expense account, and a posted 40.00 purchase on +/// the 5th. +fn books(dir: &std::path::Path, format: Option<&JournalFormat>) -> (Store, Fx) { + let mut s = match format { + Some(f) => Store::create_in_format(dir, Currency::USD, f).unwrap(), + None => Store::create(dir, Currency::USD).unwrap(), + }; + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let supplies = s + .add_account("6000", "Supplies", AccountType::Expense, None, false) + .unwrap(); + let e = s.create_entry(d(1, 1), "Opening").unwrap(); + s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap(); + s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap(); + s.post_entry(e).unwrap(); + let entry = s.create_entry(d(1, 5), "Printer paper").unwrap(); + s.add_line(entry, supplies, Side::Debit, usd("40.00")) + .unwrap(); + s.add_line(entry, bank, Side::Credit, usd("40.00")).unwrap(); + s.post_entry(entry).unwrap(); + ( + s, + Fx { + bank, + capital, + supplies, + entry, + }, + ) +} + +fn run_of(s: &Store, through: NaiveDate, scope: Scope) -> NewAuditRun { + NewAuditRun { + through, + through_seq: s.figure_head().unwrap(), + scope, + tb_hash: "tb".into(), + findings_sha256: "fs".into(), + workpaper_attachment: None, + workpaper_pdf: None, + } +} + +fn claim(effort: &str) -> Claim { + Claim { + by: "verifier-1".into(), + method: Method { + model: Some("opus".into()), + reasoning_effort: Effort::parse(effort), + adversarial: Some(true), + verifier_agents: Some(2), + }, + note: None, + } +} + +fn journal_bytes(dir: &std::path::Path) -> Vec { + std::fs::read(dir.join(JOURNAL_FILE)).unwrap() +} + +/// R-A: every write of the feature waits for the owner's upgrade, +/// journaling nothing; afterwards it is taken and the books replay to +/// what was live. +#[test] +fn audit_writes_wait_for_audits() { + let dir = test_dir("waits"); + let (mut s, fx) = books(&dir, Some(&JournalFormat::default())); + assert!(!s.journal_format().has_feature(AUDITS)); + let (att, _, _) = s + .attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None) + .unwrap(); + let before = journal_bytes(&dir); + let new = run_of(&s, d(1, 31), Scope::Month); + for r in [ + s.record_audit_run(new.clone()).map(|_| ()), + s.attest_receipt( + att, + fx.entry, + "40.00".parse().unwrap(), + d(1, 5), + claim("high"), + ) + .map(|_| ()), + s.review_entry(fx.entry, claim("high")).map(|_| ()), + ] { + match r { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature audits") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + } + assert_eq!(journal_bytes(&dir), before, "a refusal journals nothing"); + s.upgrade_format().unwrap(); + let run = s + .record_audit_run(run_of(&s, d(1, 31), Scope::Month)) + .unwrap(); + s.review_entry(fx.entry, claim("high")).unwrap(); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(s.audit_runs().unwrap(), vec![run]); + assert_eq!(s.attestations().unwrap().len(), 1); +} + +/// INVARIANT 1 and 2: filing a workpaper and journaling the run do not +/// move the figure head; an entry does; the same audit asked again +/// returns the first run and journals nothing. +#[test] +fn a_run_is_idempotent_on_the_figure_head() { + let dir = test_dir("idem"); + let (mut s, fx) = books(&dir, None); + let head = s.figure_head().unwrap(); + let (wp, _, _) = s + .file_document("workpaper", "w.json", "application/json", b"{}", None) + .unwrap(); + assert_eq!(s.figure_head().unwrap(), head, "a filing is not a figure"); + let mut new = run_of(&s, d(1, 31), Scope::Month); + new.workpaper_attachment = Some(wp); + let first = s.record_audit_run(new.clone()).unwrap(); + assert_eq!(first.id, 1); + assert_eq!(first.through_seq, head); + assert_eq!(s.figure_head().unwrap(), head, "a run is not a figure"); + let bytes = journal_bytes(&dir); + let again = s.record_audit_run(new).unwrap(); + assert_eq!(again, first); + assert_eq!(journal_bytes(&dir), bytes, "asking again journals nothing"); + assert!(s.audit_is_current(&first).unwrap()); + // Another scope is another run. + let other = s + .record_audit_run(run_of(&s, d(1, 31), Scope::Full)) + .unwrap(); + assert_eq!(other.id, 2); + // An entry moves the head: the run no longer stands. + let e = s.create_entry(d(2, 1), "Stamps").unwrap(); + s.add_line(e, fx.supplies, Side::Debit, usd("3.00")) + .unwrap(); + s.add_line(e, fx.bank, Side::Credit, usd("3.00")).unwrap(); + s.post_entry(e).unwrap(); + assert!(s.figure_head().unwrap() > head); + assert!(!s.audit_is_current(&first).unwrap()); + s.verify().unwrap(); +} + +/// A receipt reading that differs from the entry's is recorded, marked, +/// and never counts; one that agrees is not marked. The receipt must be on +/// the entry. +#[test] +fn amount_mismatch_never_counts() { + let dir = test_dir("mismatch"); + let (mut s, fx) = books(&dir, None); + let (att, _, _) = s + .attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper", None) + .unwrap(); + let other = s + .attach_receipt(None, "x.txt", "text/plain", b"stray", None) + .unwrap() + .0; + let amount = |t: &str| -> Decimal { t.parse().unwrap() }; + let disagrees = |a: &ledger_store::audit::Attestation| match a.what { + Attested::Receipt { disagrees, .. } => disagrees, + _ => panic!("not a receipt"), + }; + let ok = s + .attest_receipt(att, fx.entry, amount("40.00"), d(1, 5), claim("high")) + .unwrap(); + assert!(!disagrees(&ok)); + let wrong_amount = s + .attest_receipt(att, fx.entry, amount("41.00"), d(1, 5), claim("high")) + .unwrap(); + assert!(disagrees(&wrong_amount)); + let wrong_date = s + .attest_receipt(att, fx.entry, amount("40.00"), d(1, 6), claim("high")) + .unwrap(); + assert!(disagrees(&wrong_date)); + // Refused: a receipt not on the entry, a draft, an unknown entry. + assert!(matches!( + s.attest_receipt(other, fx.entry, amount("40.00"), d(1, 5), claim("high")), + Err(StoreError::AuditRefused(_)) + )); + let draft = s.create_entry(d(2, 2), "Draft").unwrap(); + assert!(matches!( + s.review_entry(draft, claim("high")), + Err(StoreError::AuditRefused(_)) + )); + assert!(s.review_entry(999, claim("high")).is_err()); + // All three kinds replay, in order. + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + let got = s.attestations().unwrap(); + assert_eq!(got.len(), 3); + assert!(got.windows(2).all(|w| w[0].seq < w[1].seq)); + assert_eq!(got[0].method.reasoning_effort, Some(Effort::High)); + assert_eq!(got[0].by, "verifier-1"); +} + +/// INVARIANT 3: a period is attested only on a run at the current head +/// that covers it; an entry since makes the run stale and the attestation +/// is refused until the audit is run again. +#[test] +fn stale_head_period_attestation_refused() { + let dir = test_dir("stale"); + let (mut s, fx) = books(&dir, None); + let jan = s.create_period("2026-01", d(1, 1), d(1, 31)).unwrap(); + // No audit yet. + assert!(matches!( + s.attest_period(jan, claim("high")), + Err(StoreError::AuditRefused(_)) + )); + // An audit through before the period's end does not cover it. + s.record_audit_run(run_of(&s, d(1, 15), Scope::Month)) + .unwrap(); + assert!(matches!( + s.attest_period(jan, claim("high")), + Err(StoreError::AuditRefused(_)) + )); + let run = s + .record_audit_run(run_of(&s, d(1, 31), Scope::Month)) + .unwrap(); + let (a, on) = s.attest_period(jan, claim("high")).unwrap(); + assert_eq!(on.id, run.id); + assert_eq!( + a.what, + Attested::Period { + period_id: jan, + audit_run_id: run.id + } + ); + // An entry since: the run is stale. + let e = s.create_entry(d(2, 1), "Stamps").unwrap(); + s.add_line(e, fx.supplies, Side::Debit, usd("3.00")) + .unwrap(); + s.add_line(e, fx.capital, Side::Credit, usd("3.00")) + .unwrap(); + s.post_entry(e).unwrap(); + let head = s.head(); + match s.attest_period(jan, claim("high")) { + Err(StoreError::AuditRefused(why)) => assert!(why.contains("books.audit"), "{why}"), + other => panic!("expected a refusal, got {other:?}"), + } + assert_eq!(s.head(), head, "a refusal journals nothing"); + s.verify().unwrap(); +} + +/// The integrity table: each code seeded alone. +#[test] +fn integrity_detects_each_seeded_defect() { + // A whole book finds nothing. + { + let dir = test_dir("whole"); + let (s, _) = books(&dir, None); + assert_eq!(s.integrity_findings(), vec![]); + } + let seed = |code: &str| -> Vec<&'static str> { + let dir = test_dir(&format!("seed-{code}")); + let (mut s, fx) = books(&dir, None); + let found = match code { + "blob_missing" | "blob_damaged" | "extraction_blob_missing" => { + let (_, sha, _) = s + .attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None) + .unwrap(); + let target = if code == "extraction_blob_missing" { + // The text the extractor read, content-addressed too. + let x = s.extractions().unwrap().pop().expect("an extraction"); + x.text_sha256.expect("text") + } else { + sha + }; + let path = dir.join("blobs").join(&target[..2]).join(&target); + let path = if path.exists() { + path + } else { + // The store's own layout. + walk(&dir.join("blobs"), &target).expect("the blob file") + }; + if code == "blob_damaged" { + std::fs::write(&path, b"tampered").unwrap(); + } else { + std::fs::remove_file(&path).unwrap(); + } + s.integrity_findings() + } + "duplicate_blob" => { + drop(s); + let bytes = b"filed twice by an older version"; + let (sha, _) = BlobStore::open(&dir.join("blobs")) + .unwrap() + .put(bytes) + .unwrap(); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + for (id, name) in [(1, "one.pdf"), (2, "two.pdf")] { + w.append( + "receipt.attached", + json!({ + "attachment_id": id, "blob_sha": sha, "entry_id": null, + "category": "receipts", "file_name": name, + "mime_type": "application/pdf", "size_bytes": bytes.len() as u64, + "description": null, + }), + ) + .unwrap(); + } + drop(w); + Store::open(&dir).unwrap().integrity_findings() + } + "access_log_damaged" => { + std::fs::write( + dir.join("access.jsonl"), + b"{\"not\": \"a chained record\"}\n", + ) + .unwrap(); + s.integrity_findings() + } + "projection_differs" => { + let db = ledger_store::database_in_use(&dir); + let conn = rusqlite::Connection::open(&db).unwrap(); + conn.execute("DELETE FROM lines", []).unwrap(); + drop(conn); + s.integrity_findings() + } + "journal_damaged" => { + let path = dir.join(JOURNAL_FILE); + let text = std::fs::read_to_string(&path).unwrap(); + std::fs::write(&path, text.replacen("Printer paper", "Printer papers", 1)).unwrap(); + s.integrity_findings() + } + "backup_stale" => backup_findings(&[BackupStanding { + target: "folder".into(), + stale: true, + last_error: None, + drill_error: None, + }]), + "drill_failed" => backup_findings(&[BackupStanding { + target: "folder".into(), + stale: false, + last_error: None, + drill_error: Some("restore did not reach the head".into()), + }]), + other => panic!("no seed for {other}"), + }; + found.iter().map(|f| f.code).collect() + }; + for row in audit::CODES { + let got = seed(row.code); + assert!(got.contains(&row.code), "{}: found {got:?}", row.code); + // The seed implies nothing else but its own consequences: a + // missing or damaged file is found once; a damaged journal can + // also differ from its replay. + for c in &got { + assert!( + *c == row.code + || (row.code == "journal_damaged" && *c == "projection_differs") + // verify reads a file's text too, so a damaged or missing + // stored file is also a replay that cannot be made. + || (row.code.starts_with("blob_") || row.code == "extraction_blob_missing") + && [ + "blob_missing", + "blob_damaged", + "extraction_blob_missing", + "projection_differs", + ] + .contains(c), + "{}: also found {c}", + row.code + ); + } + } + // No backups set up is the owner's standing choice, not a finding. + assert!(backup_findings(&[]).is_empty()); +} + +fn walk(dir: &std::path::Path, name: &str) -> Option { + for e in std::fs::read_dir(dir).ok()? { + let p = e.ok()?.path(); + if p.is_dir() { + if let Some(f) = walk(&p, name) { + return Some(f); + } + } else if p.file_name().is_some_and(|n| n == name) { + return Some(p); + } + } + None +} + +/// 8.0.6 item 2: the books the version before P8 wrote +/// (`old-books/p8_before`) open to the same journal, have no audit run or +/// claim, are whole (nothing for the integrity checks to find), refuse +/// the new writes until the owner's upgrade, and take them after it. +#[test] +fn books_the_version_before_p8_wrote_wait_for_the_upgrade() { + let from = + std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p8_before"); + let dir = test_dir("p8-before"); + std::fs::create_dir_all(dir.join("blobs")).unwrap(); + std::fs::copy(from.join(JOURNAL_FILE), dir.join(JOURNAL_FILE)).unwrap(); + fn copy(from: &std::path::Path, to: &std::path::Path) { + std::fs::create_dir_all(to).unwrap(); + for e in std::fs::read_dir(from).unwrap() { + let e = e.unwrap(); + if e.file_type().unwrap().is_dir() { + copy(&e.path(), &to.join(e.file_name())); + } else { + std::fs::copy(e.path(), to.join(e.file_name())).unwrap(); + } + } + } + copy(&from.join("blobs"), &dir.join("blobs")); + let mut s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert!(!s.journal_format().has_feature(AUDITS)); + assert!(s.audit_runs().unwrap().is_empty()); + assert!(s.attestations().unwrap().is_empty()); + assert_eq!(s.integrity_findings(), vec![]); + // The head is the last figure line; the filings after it don't move it. + let head = s.figure_head().unwrap(); + assert!(head > 0 && head <= s.head().0); + let jan = s.ledger().list_periods()[0].id; + let before = journal_bytes(&dir); + assert!(matches!( + s.record_audit_run(run_of(&s, d(1, 31), Scope::Month)), + Err(StoreError::FormatUpgradePending { .. }) + )); + assert!(matches!( + s.attest_period(jan, claim("high")), + Err(StoreError::FormatUpgradePending { .. }) + )); + assert_eq!(journal_bytes(&dir), before, "refused: nothing journaled"); + s.upgrade_format().unwrap(); + let run = s + .record_audit_run(run_of(&s, d(1, 31), Scope::Month)) + .unwrap(); + s.attest_period(jan, claim("high")).unwrap(); + assert_eq!(s.figure_head().unwrap(), head, "claims are not figures"); + s.verify().unwrap(); + drop(s); + let s = Store::open(&dir).unwrap(); + s.verify().unwrap(); + assert_eq!(s.audit_runs().unwrap(), vec![run]); +} + +/// Done-when 2: books with an unfooted cash flow, a posted entry with no +/// receipt, an open draft and a duplicate file give exactly those four +/// findings: the statements' and the store's, together. +#[test] +fn four_defects_give_exactly_four_findings() { + use ledger_core::audit::{AuditInput, statements}; + let dir = test_dir("four"); + let (mut s, fx) = { + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let card = s + .add_account("2050", "Card", AccountType::Liability, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let supplies = s + .add_account_with_tax( + "6000", + "Supplies", + AccountType::Expense, + None, + false, + Some(("supplies", None)), + ) + .unwrap(); + let e = s.create_entry(d(1, 1), "Opening").unwrap(); + s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap(); + s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap(); + s.post_entry(e).unwrap(); + s.attach_receipt(Some(e), "open.txt", "text/plain", b"deposit 500.00", None) + .unwrap(); + // A purchase over the threshold with no receipt: posted through the + // primitive, as a hand entry made before the rule was tightened. + let bare = s.create_entry(d(1, 5), "Printer paper").unwrap(); + s.add_line(bare, supplies, Side::Debit, usd("40.00")) + .unwrap(); + s.add_line(bare, bank, Side::Credit, usd("40.00")).unwrap(); + s.post_entry(bare).unwrap(); + // An open draft. + let draft = s.create_entry(d(1, 9), "Stamps").unwrap(); + s.add_line(draft, supplies, Side::Debit, usd("5.00")) + .unwrap(); + s.add_line(draft, bank, Side::Credit, usd("5.00")).unwrap(); + (s, (bank, card, bare)) + }; + // A duplicate: two records an older version filed for one file. + drop(s); + let bytes = b"filed twice by an older version"; + let (sha, _) = BlobStore::open(&dir.join("blobs")) + .unwrap() + .put(bytes) + .unwrap(); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + for (id, name) in [(10, "one.pdf"), (11, "two.pdf")] { + w.append( + "receipt.attached", + json!({ + "attachment_id": id, "blob_sha": sha, "entry_id": null, + "category": "receipts", "file_name": name, + "mime_type": "application/pdf", "size_bytes": bytes.len() as u64, + "description": null, + }), + ) + .unwrap(); + } + drop(w); + s = Store::open(&dir).unwrap(); + let (bank, card, _) = fx; + // The statement of cash flows with a liability named as cash: the + // engine refuses it, and that refusal is the finding. + let params = ledger_core::CashFlowParams { + start: d(1, 1), + end: d(1, 31), + cash_accounts: vec![bank, card], + financing_accounts: vec![], + investing_accounts: vec![], + members: vec![], + }; + let made = ledger_core::cash_flow(s.ledger(), ¶ms) + .map(|c| c.reconciles) + .map_err(|e| e.to_string()); + assert!(made.is_err(), "a liability is not a cash account"); + let receipts = s.entries_with_receipts().unwrap(); + let mut found = statements( + s.ledger(), + &AuditInput { + settings: s.ledger().settings(), + receipts: &receipts, + cash_flow: Some(&made), + settings_problems: &[], + undecided_1099: &[], + }, + d(1, 31), + ); + found.extend(s.integrity_findings()); + ledger_core::audit::sort(&mut found); + let mut codes: Vec<&str> = found.iter().map(|f| f.code).collect(); + codes.sort_unstable(); + assert_eq!( + codes, + [ + "cash_flow_unfooted", + "draft_open", + "duplicate_blob", + "evidence_missing" + ], + "{found:#?}" + ); +} diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml index c3eb1c2..9dac2a3 100644 --- a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml +++ b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml @@ -14,6 +14,9 @@ # cargo run --bin p4_before -- OUT (p4_before: run against 25b52580, the # revision before P4: hand entries posted # late, a reversal, open drafts, a close) +# cargo run --bin p5_before -- OUT (p5_before: run against b97234ac, the +# revision before P5: a revised +# convention, two decisions, two open items) # cargo run --bin expect -- OUT (expected.json beside each journal) # and copy each journal.jsonl, blobs/ and expected.json into old-books/. # x_member_closes was written later, the same way, by main 00226a4c diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p5_before.rs b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p5_before.rs new file mode 100644 index 0000000..ce3233e --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p5_before.rs @@ -0,0 +1,105 @@ +//! Writes `p5_before`: books the version before P5 (main b97234ac) wrote +//! through its Store API, with the records P5's source corrections act on: +//! a convention revised once, two decisions (one superseding the other) +//! and two open items (one resolved), each with its source, beside a few +//! posted entries and a draft awaiting its evidence. No source correction +//! exists in that version; this version opens the same journal and must +//! report the same figures. +use chrono::NaiveDate; +use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem}; +use ledger_core::settings::Source; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_store::Store; +use rust_decimal::Decimal; +use std::path::PathBuf; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} +fn usd(c: i64) -> Money { + Money::new(Decimal::new(c, 2), Currency::USD) +} +fn source(by: &str, day: u32) -> Source { + Source { + decided_by: by.into(), + decided_on: d(2026, 10, day), + citation: None, + note: None, + } +} + +fn main() { + let root = PathBuf::from(std::env::args().nth(1).unwrap()); + let dir = root.join("p5_before"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let bank = s.add_account("1000", "Bank", AccountType::Asset, None, false).unwrap(); + let capital = s.add_account("3000", "Capital", AccountType::Equity, None, false).unwrap(); + let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap(); + let post = |s: &mut Store, date: NaiveDate, what: &str, dr: u64, cr: u64, c: i64| { + let e = s.create_entry(date, what).unwrap(); + s.add_line(e, dr, Side::Debit, usd(c)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + e + }; + let seed = post(&mut s, d(2026, 1, 1), "Opening capital", bank, capital, 100_000); + s.post_entry(seed).unwrap(); + let jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000); + s.post_entry(jan).unwrap(); + let _waiting = post(&mut s, d(2026, 2, 3), "Awaiting a receipt", rent, bank, 4_000); + // How the company keeps its books: a close step, revised once. + let step = |body: &str, by: &str, day: u32| NewConvention { + id: "close-checklist".into(), + kind: ConventionKind::CloseStep, + title: "Close checklist".into(), + body_md: body.into(), + structured: None, + source: source(by, day), + }; + s.set_convention(&step("Reconcile, then close.", "Owner", 20)).unwrap(); + s.set_convention(&step("Reconcile, review drafts, then close.", "Owner", 22)).unwrap(); + // Two decisions, the second replacing the first. + let first = s + .record_decision(&NewDecision { + subject: "Materiality".into(), + decision_md: "Five hundred dollars.".into(), + decided_by: vec!["Owner".into()], + decided_on: d(2026, 10, 20), + evidence_attachment: None, + supersedes: None, + citation: None, + projects: vec![], + }) + .unwrap(); + s.record_decision(&NewDecision { + subject: "Materiality".into(), + decision_md: "One thousand dollars.".into(), + decided_by: vec!["Owner".into(), "Pat Smith".into()], + decided_on: d(2026, 10, 25), + evidence_attachment: None, + supersedes: Some(first), + citation: Some("IRS Pub. 538".into()), + projects: vec![], + }) + .unwrap(); + // Two open items, one resolved. + let ask = s + .raise_open_item(&NewOpenItem { + title: "Ask the CPA about the vehicle".into(), + detail_md: None, + due: Some(d(2026, 11, 15)), + source: source("Owner", 20), + }) + .unwrap(); + s.raise_open_item(&NewOpenItem { + title: "Mail the annual report".into(), + detail_md: Some("Due with the state.".into()), + due: None, + source: source("Owner", 21), + }) + .unwrap(); + s.resolve_open_item(ask, "Answered: lease it.", &source("Pat Smith", 26)).unwrap(); + s.verify().unwrap(); + println!("WROTE {:?}", dir); +} diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p8_before.rs b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p8_before.rs new file mode 100644 index 0000000..720012a --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p8_before.rs @@ -0,0 +1,85 @@ +//! Writes `p8_before`: books the version before P8 (main b97234ac) wrote +//! through its Store API: a receipted opening and purchase, a 40.00 +//! purchase with no receipt, a small one under the threshold, a reversal, +//! an invoice with a part payment, an open draft, and a closed January. +//! No audit run, attestation or attestation setting exists in that +//! version; this version opens the same journal and must report the same +//! figures, and an audit of it finds exactly the missing receipt and the +//! open draft. +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, DocumentLine, Money, Side}; +use ledger_store::Store; +use rust_decimal::Decimal; +use std::path::PathBuf; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} +fn usd(c: i64) -> Money { + Money::new(cents(c), Currency::USD) +} + +fn main() { + let root = PathBuf::from(std::env::args().nth(1).unwrap()); + let dir = root.join("p8_before"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let bank = s.add_account("1000", "Bank", AccountType::Asset, None, false).unwrap(); + let capital = s.add_account("3000", "Capital", AccountType::Equity, None, false).unwrap(); + let sales = s.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap(); + let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap(); + let software = s.add_account("6100", "Software", AccountType::Expense, None, false).unwrap(); + let post = |s: &mut Store, date: NaiveDate, what: &str, dr: u64, cr: u64, c: i64| { + let e = s.create_entry(date, what).unwrap(); + s.add_line(e, dr, Side::Debit, usd(c)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + e + }; + let seed = post(&mut s, d(2026, 1, 1), "Opening capital", bank, capital, 100_000); + s.post_entry(seed).unwrap(); + s.attach_receipt(Some(seed), "opening.txt", "text/plain", b"Opening deposit 1000.00 2026-01-01", None) + .unwrap(); + let rent_jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000); + s.post_entry(rent_jan).unwrap(); + s.attach_receipt(Some(rent_jan), "rent.txt", "text/plain", b"Rent January 500.00 2026-01-05", None) + .unwrap(); + // 40.00 is over the 25.00 threshold: no receipt, so an audit finds it. + let paper = post(&mut s, d(2026, 1, 7), "Printer paper", software, bank, 4_000); + s.post_entry(paper).unwrap(); + // Under the threshold: needs none. + let stamps = post(&mut s, d(2026, 1, 8), "Stamps", software, bank, 1_200); + s.post_entry(stamps).unwrap(); + let wrong = post(&mut s, d(2026, 1, 12), "Rent paid twice", rent, bank, 50_000); + s.post_entry(wrong).unwrap(); + s.attach_receipt(Some(wrong), "rent2.txt", "text/plain", b"Rent again 500.00 2026-01-12", None) + .unwrap(); + s.reverse_entry(wrong, d(2026, 1, 14), "Entered twice").unwrap(); + let customer = s.add_customer("Fernhill Labs").unwrap(); + let inv = s + .create_invoice( + customer, + d(2026, 1, 10), + d(2026, 2, 9), + vec![DocumentLine { + description: "Design".into(), + amount: cents(120_000), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }], + ) + .unwrap(); + s.receive_payment(inv, d(2026, 1, 20), cents(40_000), bank).unwrap(); + // An open draft in February; January closes around it. + let _open = post(&mut s, d(2026, 2, 12), "Coworking day pass", rent, bank, 3_500); + let jan = s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)).unwrap(); + s.close_period(jan).unwrap(); + let _ = sales; + s.verify().unwrap(); + println!("WROTE {:?}", dir); +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p5_before/expected.json b/crates/ledger-store/tests/fixtures/old-books/p5_before/expected.json new file mode 100644 index 0000000..9b0c74e --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p5_before/expected.json @@ -0,0 +1,154 @@ +{ + "assets": [], + "bills": [], + "entries": [ + { + "date": "2026-01-01", + "description": "Opening capital", + "id": 1, + "lines": [ + { + "account": 1, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-05", + "description": "January rent", + "id": 2, + "lines": [ + { + "account": 3, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-03", + "description": "Awaiting a receipt", + "id": 3, + "lines": [ + { + "account": 3, + "amount": { + "amount": "40.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "40.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Draft" + } + ], + "entry_parties": [], + "invoices": [], + "payroll_runs": [], + "periods": [], + "recurring": [], + "schedules": [], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "500", + "balanced": true, + "current_earnings": "-500", + "equity": "500", + "liabilities": "0" + }, + "profit_and_loss": { + "expenses": "500", + "net_income": "-500", + "revenue": "0" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "0", + "debit": "500", + "name": "Bank" + }, + { + "code": "3000", + "credit": "1000", + "debit": "0", + "name": "Capital" + }, + { + "code": "6000", + "credit": "0", + "debit": "500", + "name": "Rent" + } + ], + "total_credits": "1000", + "total_debits": "1000" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p5_before/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p5_before/journal.jsonl new file mode 100644 index 0000000..fa60fe6 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p5_before/journal.jsonl @@ -0,0 +1,23 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:39:45Z","type":"store.created"} +{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","drafts_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"1ec5c6e78cbef1e34d22de1c3376c7ec5051d3cf16d99d5d21fc0f62ab272574","seq":2,"ts":"2026-10-29T20:39:45Z","type":"journal.format_set"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"49c0bd7bc22f2804ad1af0a6ed1488dcaa74ffcfbfc3f1d34580574c3b5f577a","seq":3,"ts":"2026-10-29T20:39:45Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"e62de432ee6bbf20db6231ceb2caa523a423cb6104747d0fee9bb7caa3306a13","seq":4,"ts":"2026-10-29T20:39:45Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":3,"name":"Rent","parent":null,"placeholder":false},"prev":"e718e3ee547698e7a8dba46b0fa2006579b265c8941a2a64a8780e0d6799c05e","seq":5,"ts":"2026-10-29T20:39:45Z","type":"account.added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"1aac79f391f066f3caa4286252c17094d5dcd78c5f1a3a5c3d26831976876581","seq":6,"ts":"2026-10-29T20:39:45Z","type":"entry.created"} +{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"278ea897aa5a37a5198f4165d03464945379a6b3cb25070cbf7a5b6a9c65faea","seq":7,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"59535fd0d40e6820e354c3f2c2b9e0e03107c03392413e81d738c4229f6c8d40","seq":8,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4fea68defea0343aa4cbc3b70c6d7c8dec9481d5df0bd6db78ddd7bf4c9a6b67","seq":9,"ts":"2026-10-29T20:39:45Z","type":"entry.posted"} +{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"40488694d734440f615cfaa57164f31f92da6c5c5133891671eba86ccbf99628","seq":10,"ts":"2026-10-29T20:39:45Z","type":"entry.created"} +{"payload":{"account":3,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"ea486bc7b9029b186f156d4d8d7ad14b149fe6d24585bcaeb8707e13546a4109","seq":11,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"0836c22c91fb4ed3a0a230b49d6a8123592cd80f9630819c1f7ba957a51abf8f","seq":12,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":3,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"15cf6b35fd07465fdf86d765fbcacbbcd9fb8e378a9aca516c232a91d406cfbb","seq":13,"ts":"2026-10-29T20:39:45Z","type":"entry.posted"} +{"payload":{"date":"2026-02-03","description":"Awaiting a receipt","id":3},"prev":"331e0f5b3610e7b6a1e20d3682c5f2be6ce1b827e182643bd0be0ae097fbf4c7","seq":14,"ts":"2026-10-29T20:39:45Z","type":"entry.created"} +{"payload":{"account":3,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"e2faf752e39dac02ea40ff2133c8d33a1fe85d84f5d3e81473b77a0284bd9b63","seq":15,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"4b4fac7a91be608d99f90c1aad38650b6e2442699cb103dd4c9f3dc2846e63d4","seq":16,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"} +{"payload":{"body_md":"Reconcile, then close.","id":"close-checklist","kind":"close_step","source":{"decided_by":"Owner","decided_on":"2026-10-20"},"title":"Close checklist"},"prev":"0dd1ac379bd2987f018675f0c0ccc1558ddb6c5c32cd383bb5984c163f37b2c3","seq":17,"ts":"2026-10-29T20:39:45Z","type":"convention.set"} +{"payload":{"body_md":"Reconcile, review drafts, then close.","id":"close-checklist","kind":"close_step","source":{"decided_by":"Owner","decided_on":"2026-10-22"},"title":"Close checklist"},"prev":"ac97d62664a7b399d6fe38cfd62843068470fdc46f41f19a74c0388b42d42105","seq":18,"ts":"2026-10-29T20:39:45Z","type":"convention.set"} +{"payload":{"decided_by":["Owner"],"decided_on":"2026-10-20","decision_md":"Five hundred dollars.","id":1,"subject":"Materiality"},"prev":"4c7412cba11ebe3ab93e5a4c85a5f99f29913c952149f40a91a34e818d955a8e","seq":19,"ts":"2026-10-29T20:39:45Z","type":"decision.recorded"} +{"payload":{"citation":"IRS Pub. 538","decided_by":["Owner","Pat Smith"],"decided_on":"2026-10-25","decision_md":"One thousand dollars.","id":2,"subject":"Materiality","supersedes":1},"prev":"b89addb314620a56f585d6a3f997f7f658e1596bda44c6b56ffe51b4fe737fff","seq":20,"ts":"2026-10-29T20:39:45Z","type":"decision.recorded"} +{"payload":{"due":"2026-11-15","id":1,"source":{"decided_by":"Owner","decided_on":"2026-10-20"},"title":"Ask the CPA about the vehicle"},"prev":"4f1ce3e87d5bd5a4d689a0ef0a61422417c76d3af4d3793c03b12fecc5a73af1","seq":21,"ts":"2026-10-29T20:39:45Z","type":"open_item.raised"} +{"payload":{"detail_md":"Due with the state.","id":2,"source":{"decided_by":"Owner","decided_on":"2026-10-21"},"title":"Mail the annual report"},"prev":"7054527c8e4184297b46b801e6ce2c8237b467684c40ebce295957d0bab372cf","seq":22,"ts":"2026-10-29T20:39:45Z","type":"open_item.raised"} +{"payload":{"id":1,"resolution":"Answered: lease it.","source":{"decided_by":"Pat Smith","decided_on":"2026-10-26"}},"prev":"3ad740fbca7d8a6e8ce669a944631bc7d406f3f6820fb4b5c31c05fcbe26fca5","seq":23,"ts":"2026-10-29T20:39:46Z","type":"open_item.resolved"} diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/0d/46/0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/0d/46/0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e new file mode 100644 index 0000000..c0a4124 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/0d/46/0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e @@ -0,0 +1 @@ +Opening deposit 1000.00 2026-01-01 \ No newline at end of file diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/65/c7/65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8 b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/65/c7/65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8 new file mode 100644 index 0000000..22c35d3 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/65/c7/65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8 @@ -0,0 +1 @@ +Rent again 500.00 2026-01-12 \ No newline at end of file diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/dd/30/dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/dd/30/dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f new file mode 100644 index 0000000..e77bc39 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/blobs/dd/30/dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f @@ -0,0 +1 @@ +Rent January 500.00 2026-01-05 \ No newline at end of file diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/expected.json b/crates/ledger-store/tests/fixtures/old-books/p8_before/expected.json new file mode 100644 index 0000000..4d75a14 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/expected.json @@ -0,0 +1,461 @@ +{ + "assets": [], + "bills": [], + "entries": [ + { + "date": "2026-01-01", + "description": "Opening capital", + "id": 1, + "lines": [ + { + "account": 1, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-05", + "description": "January rent", + "id": 2, + "lines": [ + { + "account": 4, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-07", + "description": "Printer paper", + "id": 3, + "lines": [ + { + "account": 5, + "amount": { + "amount": "40.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "40.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-08", + "description": "Stamps", + "id": 4, + "lines": [ + { + "account": 5, + "amount": { + "amount": "12.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "12.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-12", + "description": "Rent paid twice", + "id": 5, + "lines": [ + { + "account": 4, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-14", + "description": "Entered twice", + "id": 6, + "lines": [ + { + "account": 4, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": 5, + "status": "Posted" + }, + { + "date": "2026-01-10", + "description": "Invoice 1", + "id": 7, + "lines": [ + { + "account": 6, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-20", + "description": "Payment on invoice 1", + "id": 8, + "lines": [ + { + "account": 6, + "amount": { + "amount": "400.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 1, + "amount": { + "amount": "400.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-12", + "description": "Coworking day pass", + "id": 9, + "lines": [ + { + "account": 4, + "amount": { + "amount": "35.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "35.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Draft" + }, + { + "date": "2026-01-31", + "description": "Close period 2026-01", + "id": 10, + "lines": [ + { + "account": 3, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 4, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 5, + "amount": { + "amount": "52.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 7, + "amount": { + "amount": "648.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + } + ], + "entry_parties": [ + [ + 7, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 8, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ] + ], + "invoices": [ + { + "amount_paid": "400.00", + "customer_id": 1, + "date": "2026-01-10", + "due_date": "2026-02-09", + "entry_id": 7, + "id": 1, + "lines": [ + { + "amount": "1200.00", + "description": "Design" + } + ], + "status": "Partial" + } + ], + "payroll_runs": [], + "periods": [ + { + "close_seq": 1, + "closing_entry": 10, + "end": "2026-01-31", + "id": 1, + "member_close": null, + "name": "2026-01", + "start": "2026-01-01", + "status": "Closed" + } + ], + "recurring": [], + "schedules": [], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "1648", + "balanced": true, + "current_earnings": "0", + "equity": "1648", + "liabilities": "0" + }, + "profit_and_loss": { + "expenses": "0", + "net_income": "0", + "revenue": "0" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "0", + "debit": "848", + "name": "Bank" + }, + { + "code": "1200", + "credit": "0", + "debit": "800", + "name": "Accounts Receivable" + }, + { + "code": "3000", + "credit": "1000", + "debit": "0", + "name": "Capital" + }, + { + "code": "3999", + "credit": "648", + "debit": "0", + "name": "Retained Earnings" + } + ], + "total_credits": "1648", + "total_debits": "1648" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p8_before/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p8_before/journal.jsonl new file mode 100644 index 0000000..8466158 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p8_before/journal.jsonl @@ -0,0 +1,52 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:33:25Z","type":"store.created"} +{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","drafts_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"a41fd982ec883c0f7dbeec0382bcf10c69246a4d063cf04997f40ad96467f00d","seq":2,"ts":"2026-10-29T20:33:25Z","type":"journal.format_set"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"f0adba7a9a2da79cc2b28f4ab2a69238da5f3ad9269e76c1c21e0027fdf86c51","seq":3,"ts":"2026-10-29T20:33:25Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"093d7586712b4e3379fea7da2923ab4b25efa7be0d533e212ea4c31ecbb81332","seq":4,"ts":"2026-10-29T20:33:25Z","type":"account.added"} +{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":3,"name":"Sales","parent":null,"placeholder":false},"prev":"2b8f70ae6d4b736aa0cc523563b47cdd1be396e3fd3a5c2f84cceb001fbaff81","seq":5,"ts":"2026-10-29T20:33:26Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Rent","parent":null,"placeholder":false},"prev":"d4fc3949ba8c20b553504561a2c22f96d6a4351713ae66e83fe34f601ead0b2d","seq":6,"ts":"2026-10-29T20:33:26Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":5,"name":"Software","parent":null,"placeholder":false},"prev":"3088d962972a512f5d1a12a8dd4da1da6ae8d1388e67ea18666b87687401b97c","seq":7,"ts":"2026-10-29T20:33:26Z","type":"account.added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"5947b37f609878df3587e37c72411cc2505fb694aab1bc9a7199cecfd2897031","seq":8,"ts":"2026-10-29T20:33:26Z","type":"entry.created"} +{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"d0aff2093f4f8114e04eb158ddfcfc24201846214141fea1def86cd965551006","seq":9,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"850e9125ac3b5d457e611882642600a7298fa3acdd386ce2b7c9494ecb29b0d0","seq":10,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"abc483bc19148cc173cf93402702b60ee5e31029fcdcfa704f10204c38a707ec","seq":11,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"} +{"payload":{"attachment_id":1,"blob_sha":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","category":"receipts","description":null,"entry_id":1,"file_name":"opening.txt","mime_type":"text/plain","size_bytes":34},"prev":"4a2665579fd49f5d11e27eaaca556990d58559dd1a9d8f23e9d274449ff61a8d","seq":12,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"} +{"payload":{"attachment_id":1,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","version":"0.1.0"},"prev":"d8c2d6bf43d24b9cf4b3c0a12b514cd9f77cf51f979ab54e17c6fc879fdc285f","seq":13,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"} +{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"d048136f116634d3df443725f71b4f5a9ed2cfdc45a8dba9fc9a54bb993266ff","seq":14,"ts":"2026-10-29T20:33:26Z","type":"entry.created"} +{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"6850fe7154d84ec18c876c9c4985607b9660e1a4d6ff1c42814358039d25224d","seq":15,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"24ecb6745877198c45149e53672d81a7e8b75051770c2eac046c373ad5020694","seq":16,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"76e855cebe94f29e2bbf65e4be0634976eec4164867ec4c2152e39b21a5b9ab0","seq":17,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"} +{"payload":{"attachment_id":2,"blob_sha":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","category":"receipts","description":null,"entry_id":2,"file_name":"rent.txt","mime_type":"text/plain","size_bytes":30},"prev":"72fb7f27dba8721febaffcc2dc93870d79272ca3208abfea27d1535f46fa8334","seq":18,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"} +{"payload":{"attachment_id":2,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","version":"0.1.0"},"prev":"bd55df8f6fce00e7a55262be3123985789504189f9bc8364a194fa886719fc6a","seq":19,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"} +{"payload":{"date":"2026-01-07","description":"Printer paper","id":3},"prev":"2129339f3ce925cb9d0ff71f108ce7ba5c1dadd8432834effb2586e7632c1add","seq":20,"ts":"2026-10-29T20:33:26Z","type":"entry.created"} +{"payload":{"account":5,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"3980e707c3feb852f13fff9daa38ae8bf64831d851a76b8a5e7e97eff3237506","seq":21,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"673065104b3196477ebed498501f952f62b0cc1ed8cb35cda30b00e3d4932590","seq":22,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-07","description":"Printer paper","id":3,"lines":[{"account":5,"amount":"40.00","currency":"USD","side":"debit"},{"account":1,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c2c265a5318dfc5437b94272a5ceb654e93eb982815eea919ce11ca492e69f53","seq":23,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"} +{"payload":{"date":"2026-01-08","description":"Stamps","id":4},"prev":"e54f5a63600a423bc05f572cc2e5357b591afee176de438adbfc673e10028443","seq":24,"ts":"2026-10-29T20:33:26Z","type":"entry.created"} +{"payload":{"account":5,"amount":"12.00","currency":"USD","id":4,"side":"debit"},"prev":"c0acdf9d0c8fd94c66be0718354953d951f377d961da3846bba4fcfe9715577a","seq":25,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"12.00","currency":"USD","id":4,"side":"credit"},"prev":"9231e3a5b347df6caabb01248419230918234919a32552ebb509706a79f4cfd0","seq":26,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-08","description":"Stamps","id":4,"lines":[{"account":5,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"66e4fd5f78d0499f6c748e283ba81325fb43d3277a73bc7ba02e1cc43a8e3d37","seq":27,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"} +{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5},"prev":"e62d56bcde5b843e5b007e5d70bec0b39159e04c0e496533341af80fb2c04e88","seq":28,"ts":"2026-10-29T20:33:27Z","type":"entry.created"} +{"payload":{"account":4,"amount":"500.00","currency":"USD","id":5,"side":"debit"},"prev":"b9270e6df82d8f9972ec1ce5046113bc460e1c28b48a1d23a3f5631dae23eb6c","seq":29,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"500.00","currency":"USD","id":5,"side":"credit"},"prev":"91bd76318ce8bcdc02caaff76a44ade5b63aea97fc2e9406a914f87d91b7b87b","seq":30,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"994d4a25eb115362c405208ffc926af4898c61e0938d225c3cceb1aaff2520b1","seq":31,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"} +{"payload":{"attachment_id":3,"blob_sha":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","category":"receipts","description":null,"entry_id":5,"file_name":"rent2.txt","mime_type":"text/plain","size_bytes":28},"prev":"97c54bdaa6d977bddbb61f0f797903f162a5445aadad0f86fbef119d26f03885","seq":32,"ts":"2026-10-29T20:33:27Z","type":"receipt.attached"} +{"payload":{"attachment_id":3,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","version":"0.1.0"},"prev":"6908282c4f663ee92e59574a7f772d341692a684975433afb0370fe7f25ad798","seq":33,"ts":"2026-10-29T20:33:27Z","type":"attachment.text_extracted"} +{"payload":{"date":"2026-01-14","description":"Entered twice","id":6,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":1,"amount":"500.00","currency":"USD","side":"debit"}],"reverses":5},"prev":"d8fae93f64fa2c2fafa13ed4ed3a6f578a6d7a2af15cdef1bd2b3bed86242665","seq":34,"ts":"2026-10-29T20:33:27Z","type":"entry.reversed"} +{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"c525d0946d25b018a020b71072d59e39a6a842d85fa4c554af73965235b93aca","seq":35,"ts":"2026-10-29T20:33:27Z","type":"customer.added"} +{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":6,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"99f31ecc1df1963071728eb1927d66574cbbc48579604183b569b0507f725aaa","seq":36,"ts":"2026-10-29T20:33:27Z","type":"account.added"} +{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7},"prev":"000b3f21fbda30070ebdc436788844e60e7fb30a2c574a4fc96df1184e6a974f","seq":37,"ts":"2026-10-29T20:33:27Z","type":"entry.created"} +{"payload":{"account":6,"amount":"1200.00","currency":"USD","id":7,"side":"debit"},"prev":"31912cd94f8cf8ab2381467d0c1f06e7b7d1aad7232dc34bf26976f8b07117df","seq":38,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"account":3,"amount":"1200.00","currency":"USD","id":7,"side":"credit"},"prev":"b3544c286be658d2569eaec61f1ecf924b6345125ccdee5de44c7be69eb80d87","seq":39,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7,"lines":[{"account":6,"amount":"1200.00","currency":"USD","side":"debit"},{"account":3,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"780aa79b403d46831221d2f9de731eafe6e20e3b916fa6bcc712485234a53a41","seq":40,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"} +{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":7,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"d13a0896487f5aec07d43136ad9fd10e70d4f048528ff4e7a32a9d23a74f7225","seq":41,"ts":"2026-10-29T20:33:27Z","type":"invoice.created"} +{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8},"prev":"293d3e1a957fa177d28515cf7a4dfa324945bb73d8a2b535844fbaafa186ff1a","seq":42,"ts":"2026-10-29T20:33:27Z","type":"entry.created"} +{"payload":{"account":6,"amount":"400.00","currency":"USD","id":8,"side":"credit"},"prev":"68c56c6c09a27ba436c95d4ebe2e0fb56511bf5db181ad424d7c6ed56cfd0bc3","seq":43,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"400.00","currency":"USD","id":8,"side":"debit"},"prev":"5284c73245f1f3a6d2820a6165fe720bc7b7034ad3cd07ba1320f30fed4acadc","seq":44,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8,"lines":[{"account":6,"amount":"400.00","currency":"USD","side":"credit"},{"account":1,"amount":"400.00","currency":"USD","side":"debit"}],"reversal_of":null},"prev":"c2afa1d4c940bbc35df2ec94272080c5fa0dad93a9547cbec6d07ab11f4ba4f3","seq":45,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"} +{"payload":{"amount":"400.00","cash_account":1,"date":"2026-01-20","entry_id":8,"invoice_id":1},"prev":"30a6274b20559dc7f83f2b00f39da4c72bc4244c013ad63368d0f6362311e058","seq":46,"ts":"2026-10-29T20:33:27Z","type":"invoice.payment_received"} +{"payload":{"date":"2026-02-12","description":"Coworking day pass","id":9},"prev":"adb2e790bd245c5ab0286870ad837296d0df062498c080b4349579b9f5f67ed9","seq":47,"ts":"2026-10-29T20:33:27Z","type":"entry.created"} +{"payload":{"account":4,"amount":"35.00","currency":"USD","id":9,"side":"debit"},"prev":"030bdf46cf4b8dae86f000cca47fd0f95d0f7dc01f3637729c082797846a1416","seq":48,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"35.00","currency":"USD","id":9,"side":"credit"},"prev":"b6e4796ab7517782b0e5ee77126e5701246d2b4234c505bc079ba1ce93ad4b04","seq":49,"ts":"2026-10-29T20:33:28Z","type":"entry.line_added"} +{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"d5653ccd2b9fdc574ebb68c068246e70b69639f26a3934faf41eeeb77dc5f8bb","seq":50,"ts":"2026-10-29T20:33:28Z","type":"period.created"} +{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":7,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"14a0f2b42d59a5a18e901c935c1fe15198d6a8d308917359b840504b74700cb8","seq":51,"ts":"2026-10-29T20:33:28Z","type":"account.added"} +{"payload":{"close_to":"retained_earnings","closing_entry":10,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":10,"lines":[{"account":3,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":5,"amount":"52.00","currency":"USD","side":"credit"},{"account":7,"amount":"648.00","currency":"USD","side":"credit"}]},"id":1},"prev":"724a1d45168bcb5282dd894952b98aa4b364641158c42e9204758de8c2afeba2","seq":52,"ts":"2026-10-29T20:33:28Z","type":"period.closed"} diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index 9ae33fe..c0483b7 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -150,7 +150,7 @@ fn assert_corrupt(got: Result) { fn old_reader_gets_newer_journal() { let cases = [ ( - "period.attested", + "ledger.not_yet_written", json!({"key": "company.name", "value": "X"}), "an event type this version doesn't know", ), @@ -243,12 +243,24 @@ fn the_writer_and_the_viewer_agree_on_newer_books() { &[ // Chained and of a known type, but replay refuses it. ("entry.posted", json!({"id": 99})), - ("period.attested", json!({"key": "company.name"})), + ("ledger.not_yet_written", json!({"key": "company.name"})), ], )[1]; let need = "an event type this version doesn't know"; - assert_newer(Store::open(&dir), seq, "period.attested", need, false); - assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false); + assert_newer( + Store::open(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); + assert_newer( + Snapshot::load(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); } /// Damage stays corruption, whatever the damaged line's type: the gate @@ -282,7 +294,7 @@ fn damaged_journals_are_still_corrupt() { ]; let newer = [ (FORMAT_SET, json!({"format": 3, "features": []})), - ("period.attested", json!({"key": "company.name"})), + ("ledger.not_yet_written", json!({"key": "company.name"})), ]; for (name, damage) in damages { for (i, last) in newer.iter().enumerate() { @@ -566,32 +578,32 @@ fn a_newer_format_frames_its_lines_its_own_way() { let nexts: [(&str, Next, Option); 6] = [ ( "float", - |seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": prev}), + |seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": prev}), Some(true), ), ( "string-seq", - |seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "period.attested", "payload": {}, "prev": prev}), + |seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev}), Some(true), ), ( "envelope-key", - |seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": prev, "actor": "agent"}), + |seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev, "actor": "agent"}), Some(true), ), ( "no-prev", - |seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "parent": prev}), + |seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "parent": prev}), Some(false), ), ( "prev-not-text", - |seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": 1.5}), + |seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": 1.5}), Some(false), ), ( "wrong-prev", - |seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": "00"}), + |seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": "00"}), None, ), ]; @@ -686,11 +698,23 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() { } else { // After a refused line, it is a next line without a readable // prev: unverified, for both. - let seq = append(&dir, &[("period.attested", json!({}))])[0]; + let seq = append(&dir, &[("ledger.not_yet_written", json!({}))])[0]; append_raw(&dir, b"{\"prev\":\"\xff\"}\n"); let need = "an event type this version doesn't know"; - assert_newer(Store::open(&dir), seq, "period.attested", need, false); - assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false); + assert_newer( + Store::open(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); + assert_newer( + Snapshot::load(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); } } } @@ -703,12 +727,21 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() { fn an_old_reader_never_repairs_newer_books() { let dir = test_dir("newer-torn"); books(&dir); - let seq = append(&dir, &[("period.attested", json!({"key": "company.name"}))])[0]; + let seq = append( + &dir, + &[("ledger.not_yet_written", json!({"key": "company.name"}))], + )[0]; append_raw(&dir, b"{\"seq\":"); let path = dir.join(JOURNAL_FILE); let before = std::fs::read(&path).unwrap(); let need = "an event type this version doesn't know"; - assert_newer(Store::open(&dir), seq, "period.attested", need, false); + assert_newer( + Store::open(&dir), + seq, + "ledger.not_yet_written", + need, + false, + ); assert_eq!(std::fs::read(&path).unwrap(), before); let set_aside: Vec<_> = std::fs::read_dir(&dir) .unwrap() diff --git a/crates/ledger-store/tests/source_revisions.rs b/crates/ledger-store/tests/source_revisions.rs new file mode 100644 index 0000000..8f9e048 --- /dev/null +++ b/crates/ledger-store/tests/source_revisions.rs @@ -0,0 +1,284 @@ +//! Source revisions (plan P5, journal feature `source_revisions`): who +//! decided a convention, a decision or an open item can be corrected, with +//! why, without touching its words. The correction is journaled as its own +//! event, replays identically, and waits for the owner's format upgrade. +//! +//! Expected behavior comes from `docs/receipt-store.md` (the three events) +//! and rules.md B-27 (a feature is declared before its first line). + +use chrono::NaiveDate; +use ledger_core::Currency; +use ledger_core::conventions::{ + ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem, +}; +use ledger_core::settings::Source; +use ledger_store::format::{KNOWN_FEATURES, SOURCE_REVISIONS}; +use ledger_store::{JournalFormat, SUPPORTED_JOURNAL_FORMAT, Store, StoreError}; +use std::path::PathBuf; + +struct TestDir(PathBuf); + +impl std::ops::Deref for TestDir { + type Target = std::path::Path; + fn deref(&self) -> &std::path::Path { + &self.0 + } +} + +impl Drop for TestDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn test_dir(name: &str) -> TestDir { + let dir = std::env::temp_dir().join(format!("wanderbooks-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + TestDir(dir) +} + +fn lines_of(dir: &std::path::Path) -> Vec { + std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() +} + +fn day(d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 10, d).unwrap() +} + +fn source(by: &str, d: u32, citation: Option<&str>) -> Source { + Source { + decided_by: by.into(), + decided_on: day(d), + citation: citation.map(str::to_string), + note: None, + } +} + +fn convention(src: Source) -> NewConvention { + NewConvention { + id: "close-checklist".into(), + kind: ConventionKind::CloseStep, + title: "Close checklist".into(), + body_md: "Reconcile, then close.".into(), + structured: None, + source: src, + } +} + +fn decision() -> NewDecision { + NewDecision { + subject: "Materiality".into(), + decision_md: "Five hundred dollars.".into(), + decided_by: vec!["Owner".into()], + decided_on: day(20), + evidence_attachment: None, + supersedes: None, + citation: None, + projects: vec![], + } +} + +fn item(src: Source) -> NewOpenItem { + NewOpenItem { + title: "Ask the CPA".into(), + detail_md: None, + due: None, + source: src, + } +} + +/// Done-when (plan P5 step 5): a call that changes only the source is +/// journaled as the `*.source_revised` event, the words and the revision +/// stay, the live books and a replay agree, and the reopened books verify. +#[test] +fn a_source_only_revision_is_journaled() { + let dir = test_dir("source-revised"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + s.set_convention(&convention(source("Owner", 20, None))) + .unwrap(); + let decision_id = s.record_decision(&decision()).unwrap(); + let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap(); + let before = lines_of(&dir).len(); + + // A convention: the same words, a corrected source. + let cpa = source("Pat Smith", 21, Some("IRS Pub. 538")); + s.revise_convention_source(&convention(cpa.clone()), "the CPA decided it") + .unwrap(); + s.revise_decision_source( + decision_id, + &DecisionSource { + decided_by: vec!["Owner".into(), "Pat Smith".into()], + decided_on: day(21), + citation: Some("IRS Pub. 538".into()), + }, + "both decided it", + ) + .unwrap(); + s.revise_open_item_source(item_id, &cpa, "raised by the CPA") + .unwrap(); + + let lines = lines_of(&dir); + let types: Vec<&str> = lines[before..] + .iter() + .map(|l| l["type"].as_str().unwrap()) + .collect(); + assert_eq!( + types, + [ + "convention.source_revised", + "decision.source_revised", + "open_item.source_revised" + ] + ); + assert_eq!(lines[before]["payload"]["reason"], "the CPA decided it"); + assert_eq!( + lines[before]["payload"]["source"]["decided_by"], + "Pat Smith" + ); + + // The words and the revision are as they were; the source is new. + let c = s + .ledger() + .conventions() + .convention("close-checklist") + .unwrap(); + assert_eq!( + (c.body_md.as_str(), c.revision), + ("Reconcile, then close.", 1) + ); + assert_eq!(c.source.decided_by, "Pat Smith"); + let d = s.ledger().conventions().decision(decision_id).unwrap(); + assert_eq!(d.new.decision_md, "Five hundred dollars."); + assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]); + assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538")); + assert_eq!( + s.ledger() + .conventions() + .open_item(item_id) + .unwrap() + .new + .source, + cpa + ); + + // Replay equals the live books, and the database file agrees. + let live = s.ledger().clone(); + drop(s); + let reopened = Store::open(&dir).unwrap(); + reopened.verify().unwrap(); + assert!(live.differs_from(reopened.ledger()).is_empty()); + drop(reopened); + let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); + let rebuilt = Store::open(&dir).unwrap(); + rebuilt.verify().unwrap(); + assert!(live.differs_from(rebuilt.ledger()).is_empty()); +} + +/// A correction must be one: other words, the same source, a retired +/// convention or a missing reason is refused and journals nothing. +#[test] +fn a_source_revision_must_change_only_the_source() { + let dir = test_dir("source-refused"); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + s.set_convention(&convention(source("Owner", 20, None))) + .unwrap(); + let id = s.record_decision(&decision()).unwrap(); + let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap(); + let len = lines_of(&dir).len(); + let mut other_words = convention(source("Pat Smith", 21, None)); + other_words.body_md = "Close first.".into(); + for refused in [ + // other words + s.revise_convention_source(&other_words, "why"), + // the same source + s.revise_convention_source(&convention(source("Owner", 20, None)), "why"), + // no reason + s.revise_convention_source(&convention(source("Pat Smith", 21, None)), " "), + // no such convention, decision, item + s.revise_convention_source( + &NewConvention { + id: "nonesuch".into(), + ..convention(source("Pat Smith", 21, None)) + }, + "why", + ), + s.revise_decision_source(99, &decision().source(), "why"), + s.revise_decision_source(id, &decision().source(), "same source"), + s.revise_decision_source( + id, + &DecisionSource { + decided_by: vec![], + decided_on: day(21), + citation: None, + }, + "why", + ), + s.revise_open_item_source(99, &source("Pat Smith", 21, None), "why"), + s.revise_open_item_source(item_id, &source("Owner", 20, None), "same source"), + ] { + assert!(matches!(refused, Err(StoreError::Ledger(_))), "{refused:?}"); + } + assert_eq!(lines_of(&dir).len(), len, "a refusal journals nothing"); +} + +/// R-A step 3 (plan P5): books that have not declared `source_revisions` +/// refuse the three corrections, journaling nothing, with the format +/// upgrade pending; after the owner's go-ahead they journal. +#[test] +fn source_revisions_wait_for_the_upgrade() { + let dir = test_dir("source-wait"); + // The version before P5: format 2, every feature but this one. + let before = JournalFormat { + format: SUPPORTED_JOURNAL_FORMAT, + features: KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != SOURCE_REVISIONS) + .map(str::to_string) + .collect(), + }; + let mut s = Store::create_in_format(&dir, Currency::USD, &before).unwrap(); + s.set_convention(&convention(source("Owner", 20, None))) + .unwrap(); + let id = s.record_decision(&decision()).unwrap(); + let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap(); + let head = s.head(); + let len = lines_of(&dir).len(); + let cpa = source("Pat Smith", 21, None); + for refused in [ + s.revise_convention_source(&convention(cpa.clone()), "why"), + s.revise_decision_source( + id, + &DecisionSource { + decided_by: vec!["Pat Smith".into()], + decided_on: day(21), + citation: None, + }, + "why", + ), + s.revise_open_item_source(item_id, &cpa, "why"), + ] { + match refused { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature source_revisions") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + } + assert_eq!(s.head(), head, "a refusal journals nothing"); + assert_eq!(lines_of(&dir).len(), len); + assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead"); + s.revise_convention_source(&convention(cpa.clone()), "why") + .unwrap(); + s.revise_open_item_source(item_id, &cpa, "why").unwrap(); + let live = s.ledger().clone(); + drop(s); + let reopened = Store::open(&dir).unwrap(); + reopened.verify().unwrap(); + assert!(live.differs_from(reopened.ledger()).is_empty()); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 275a4a7..d5ad434 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -2140,6 +2140,78 @@ fn books_written_under_older_rules_still_open() { json!({"id": 9, "reason": "entered twice"}), ) .unwrap(); + // P5's new shapes, hand-written as this version writes them: a + // convention, a decision and an open item, each with its source + // corrected (`source_revisions`). This reader takes them; the version + // before it stopped at the declaration. + let owner = json!({"decided_by": "Owner", "decided_on": "2026-10-26"}); + w.append( + "convention.set", + json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist", + "body_md": "Reconcile, then close.", "source": owner}), + ) + .unwrap(); + w.append( + "decision.recorded", + json!({"id": 1, "subject": "Materiality", "decision_md": "Five hundred dollars.", + "decided_by": ["Owner"], "decided_on": "2026-10-26"}), + ) + .unwrap(); + w.append( + "open_item.raised", + json!({"id": 1, "title": "Ask the CPA", "source": owner}), + ) + .unwrap(); + let cpa = json!({"decided_by": "Pat Smith", "decided_on": "2026-10-27", + "citation": "IRS Pub. 538"}); + w.append( + "convention.source_revised", + json!({"id": "close-checklist", "source": cpa, "reason": "the CPA decided it"}), + ) + .unwrap(); + w.append( + "decision.source_revised", + json!({"id": 1, "source": {"decided_by": ["Owner", "Pat Smith"], + "decided_on": "2026-10-27", "citation": "IRS Pub. 538"}, + "reason": "both decided it"}), + ) + .unwrap(); + w.append( + "open_item.source_revised", + json!({"id": 1, "source": cpa, "reason": "raised by the CPA"}), + ) + .unwrap(); + // P8's new shapes, hand-written as this version writes them: an audit + // run and the three kinds of attestation (the method is the client's + // own claim). This reader takes them; the version before it stopped at + // the declaration. + let method = json!({"model": "opus", "reasoning_effort": "high", + "adversarial": true, "verifier_agents": 2}); + w.append( + "audit.run", + json!({"id": 1, "through": "2026-05-31", "through_seq": 30, "scope": "month", + "program_version": "audit-0.1.0-1", "tb_hash": "aa", "findings_sha256": "bb", + "workpaper_attachment": 6, "workpaper_pdf": 7}), + ) + .unwrap(); + w.append( + "receipt.attested", + json!({"attachment_id": 5, "entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:00Z", + "method": method, "amount_checked": "99.99", "date_checked": "2026-04-01", + "disagrees": false, "note": "read the page"}), + ) + .unwrap(); + w.append( + "entry.reviewed", + json!({"entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:01Z", "method": {}}), + ) + .unwrap(); + w.append( + "period.attested", + json!({"period_id": 1, "by": "verifier", "at": "2026-06-01T00:00:02Z", + "method": method, "audit_run_id": 1}), + ) + .unwrap(); drop(w); // Opening rebuilds from the journal; it must accept all of it. let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); @@ -2169,6 +2241,17 @@ fn books_written_under_older_rules_still_open() { assert_eq!(annotation.fields.receipt_number, Some(3)); assert_eq!(annotation.fields.payee.as_deref(), Some("Acme")); assert_eq!(store.ledger().next_receipt_number(), 4); + // P8's shapes replayed: the run and the three claims, as written. + let runs = store.audit_runs().unwrap(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].scope, ledger_core::audit::Scope::Month); + assert_eq!(runs[0].workpaper_pdf, Some(7)); + let claims = store.attestations().unwrap(); + assert_eq!(claims.len(), 3); + assert_eq!(claims[0].kind(), "receipt"); + assert_eq!(claims[0].method.verifier_agents, Some(2)); + assert_eq!(claims[1].method, ledger_core::audit::Method::default()); + assert_eq!(claims[2].kind(), "period"); assert_eq!(store.ledger().entry_settles(7), [1]); assert!(store.ledger().settles_entry(1)); let acme = store.ledger().get_party(1).expect("the party"); @@ -2221,6 +2304,28 @@ fn books_written_under_older_rules_still_open() { ); assert_eq!(bill.memo.as_deref(), Some("A4 paper")); assert_eq!(bill.lines[0].account, Some(6)); + // P5's source corrections replay as written; the text stays as it was. + let c = store + .ledger() + .conventions() + .convention("close-checklist") + .expect("the convention"); + assert_eq!(c.source.decided_by, "Pat Smith"); + assert_eq!(c.source.citation.as_deref(), Some("IRS Pub. 538")); + assert_eq!( + (c.body_md.as_str(), c.revision), + ("Reconcile, then close.", 1) + ); + let d = store + .ledger() + .conventions() + .decision(1) + .expect("the decision"); + assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]); + assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538")); + assert_eq!(d.new.decision_md, "Five hundred dollars."); + let i = store.ledger().conventions().open_item(1).expect("the item"); + assert_eq!(i.new.source.decided_by, "Pat Smith"); // P4b S5's send replays as written, and moves no figure. let sends = &store.ledger().get_invoice(1).expect("the invoice").sends; assert_eq!(sends.len(), 1); diff --git a/deploy/packaging/Info.plist b/deploy/packaging/Info.plist new file mode 100644 index 0000000..9b274fd --- /dev/null +++ b/deploy/packaging/Info.plist @@ -0,0 +1,12 @@ + + + + + + LSMultipleInstancesProhibited + + NSSupportsAutomaticGraphicsSwitching + + + diff --git a/deploy/packaging/build-macos.sh b/deploy/packaging/build-macos.sh new file mode 100755 index 0000000..f9e809f --- /dev/null +++ b/deploy/packaging/build-macos.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# The macOS bundle (P11a step 6), UNSIGNED, from the release program +# already built at target/release/wanderbooks (`cargo build --release -p +# ledger-cli`). Run on a Mac from the repository root: +# +# build-macos.sh [VERSION] +# +# Puts Wanderbooks_VERSION_.dmg into target/packages/VERSION/. It +# is not signed or notarized (step 5): Gatekeeper refuses it on a Mac +# that downloaded it, and the owner opens it with right-click > Open. +# Portable shell: macOS has BSD sed and no GNU mktemp options. +set -euo pipefail +own=$(sed -n 's/^version = "\(.*\)"/\1/p' crates/ledger-cli/Cargo.toml | head -n1) +version=${1:-$own} +out="target/packages/$version" +program=${WB_PROGRAM:-target/release/wanderbooks} +test -x "$program" || { echo "build the release program first: cargo build --release -p ledger-cli" >&2; exit 1; } +rm -rf "$out"; mkdir -p "$out" +stage="target/packages/.stage-$version" +rm -rf "$stage"; mkdir -p "$stage" +cp "$program" "$stage/wanderbooks" +config="$stage/packager.toml" +# cargo-packager reads paths relative to the config file, which is in the +# stage folder here, so they are made absolute. +sed -e "s/^version = \".*\"/version = \"$version\"/" \ + -e "s#^out-dir = .*#out-dir = \"$PWD/$out\"#" \ + -e "s#\"target/release\"#\"$PWD/$stage\"#" \ + -e "s#\"deploy/packaging/#\"$PWD/deploy/packaging/#" \ + -e "s#\"LICENSE-MIT\"#\"$PWD/LICENSE-MIT\"#" \ + deploy/packaging/packager-macos.toml >"$config" +cargo packager --config "$config" --formats dmg --release +rm -rf "$stage" +ls -l "$out" diff --git a/deploy/packaging/packager-macos.toml b/deploy/packaging/packager-macos.toml new file mode 100644 index 0000000..64ef6c0 --- /dev/null +++ b/deploy/packaging/packager-macos.toml @@ -0,0 +1,36 @@ +# cargo-packager's description of the macOS bundle (P11a step 6; D-24, +# D-25): the .app and its .dmg, UNSIGNED. `deploy/packaging/build-macos.sh` +# runs it on a Mac (CI: the `macos` job of .github/workflows/release.yml). +# Developer ID signing and notarization are step 5 (the owner's Apple +# certificate, Q-4): they add `signing-identity` under [macos] and the +# certificate and notarization credentials as CI secrets. +# Paths are relative to this file's copy that build-macos.sh writes. +name = "wanderbooks" +product-name = "Wanderbooks" +version = "0.1.0" +identifier = "ai.wanderbooks.app" +description = "Double-entry bookkeeping your agent keeps and you can read." +homepage = "https://wanderbooks.ai" +authors = ["Stephen Waits"] +license-file = "LICENSE-MIT" +category = "Finance" +binaries-dir = "target/release" +out-dir = "target/packages" +# A png is made into the .icns (cargo-packager does it); the 512 px +# placeholder is the owner's to replace. +icons = ["deploy/packaging/icons/512x512.png"] +formats = ["dmg"] +binaries = [{ path = "wanderbooks", main = true }] + +[macos] +# The oldest macOS Rust's tier-1 aarch64-apple-darwin and x86-64 targets +# run on; eframe's wgpu/Metal path needs 10.15 at least. +minimum-system-version = "11.0" +# Keys merged into the generated Info.plist: one viewer at a time, and the +# discrete GPU is not woken for a ledger. +info-plist-path = "deploy/packaging/Info.plist" + +[dmg] +window-size = { width = 540, height = 380 } +app-position = { x = 140, y = 180 } +application-folder-position = { x = 400, y = 180 } diff --git a/docs/architecture.md b/docs/architecture.md index f32c233..d22bee2 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -31,9 +31,9 @@ the specification in `bookspec/` (which never depends on the engine). | `ledger-core` | the semantics engine: money, chart, journal, `Ledger` commands, reports, periods, assets, schedules, parties, A/R and A/P, payroll, projects, settings and conventions, the kernel and its Kani harnesses | nothing | | `ledger-vault` | the TIN vault: XChaCha20-Poly1305 records under the book's key | nothing | | `ledger-config` | the `wanderbooks.toml` codec (adoption in, `books.export_settings` out) and the receipt policy until P4 moves it into the core | `ledger-core` | -| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) | +| `ledger-store` | the source of truth: the hash-chained JSONL journal, its format gate, the SQLite projection (schema 18; a file per schema), the blob store, backups, the access log, `describe.rs` (the journal in words, for `books.journal_history`), `Snapshot` for readers | `ledger-core`, `ledger-reader` (P6, D-61) | | `ledger-reader` | the one reader: a PDF's text and pages, an XLSX's cells as exact decimal strings, plain text (P6; a plain library, in-process, D-61); `ledger-import` re-exports it as `extract` | none (`hayro`, `zip`, `quick-xml`) | -| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels) out | `ledger-core`, `ledger-reader` | +| `ledger-import` | formats: QuickBooks CSV in; IIF, XLSX, Typst PDFs (statements, the invoice, labels, the audit workpaper) out | `ledger-core`, `ledger-reader` | | `ledger-package` | the shared read-only view over `Store` or `Snapshot` (`PackageBooks`): the CPA package, the 1099 worksheet, the invoice document, Needs attention | `ledger-core`, `ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault` | | `ledger-mcp` | the books server: the MCP tools (122), resources, the handbook, scopes, the door and the access log, backups' scheduling | all of the above | | `ledger-host` | the host and its leases, the agent bridge (`wanderbooks mcp`), tokens in 0600 files under the data root (D-63), the owner's commands, uploads and downloads, `update` and `rollback` (P11a) | `ledger-store`, `ledger-mcp`, `ledger-config` | @@ -59,13 +59,26 @@ closure. The reader now lives in the small library crate `ledger-reader`; re-exports it as `extract`), the store no longer builds the typesetter, and `ledger-import` still never depends on the store. +Audits (P8, rules.md B-45) are split by what each layer can see. The pure +checks over a ledger are `ledger_core::audit` (`statements`, with the facts +a ledger cannot know handed in by the caller: the receipts filed, the +statement of cash flows the role settings give, the settings problems, the +undecided 1099 vendors); what only the store can see (the chain, the +database against a replay, the stored files, the access log, duplicate +files) and the audit and attestation events are `ledger_store::audit` (a +child module of `store`, because it reads the store's private state); the +workpaper's PDF is `ledger_import::workpaper` with its Typst template in +`ledger-import`'s `templates/` beside the other documents; the backups' +standing is read by the server, which alone knows the backups +(`BackupStanding`), and the tools are `ledger-mcp`'s `tools/audit.rs`. + `ledger-core` itself (`crates/ledger-core/src/`): `money`, `account`, `journal`, `ledger` (the command surface and replay), `kernel` (every money arithmetic, generic over `Amount`) and `proofs` (its Kani harnesses), `reports`, `cashflow`, `analysis`, `periods`, `assets`, `schedules`, `recurring`, `payouts`, `payroll`, `form1099`, `tax`, `classes`, `parties`, `arap`, `settings`, `conventions`, `calendar`, -`shares`, `tax_capital`, `rules`, `error`; its tests in `tests/` +`shares`, `tax_capital`, `rules`, `audit` (the audit programme's pure checks, P8), `error`; its tests in `tests/` (`invariants.rs`, `properties.rs`, `spec_*.rs`, `kernel_bridge.rs`, `model.rs`, `bookspec.rs`, `golden/`). @@ -365,9 +378,21 @@ downgrading is theirs). Anything else (an AppImage, whose file is named by without the agent noticing. The format gate stays the server's (3.9): `rollback` refuses while a book declares a format or feature the kept version does not know; -- an AppImage is the exception to the restart: the host runs from the old - image's mount, whose path does not change, so it keeps running the old - version until the books are next closed and opened (docs/packaging.md). +- an AppImage runs the program from a mount that goes away with the image, + so the host learns the image's own path from `APPIMAGE` (the one variable + `start.rs` adds to its scrubbed environment, only when it names an + absolute file; the host reads it about itself and never tells an agent), + runs and restarts from that file, and tells a replaced program by its + inode, size and last write, like any other; +- updates are also **staged** (step 4): while the books are open the host + looks at the feed at most once a day (`update_check` knob; the look is + noted in `updates/checked`), and only downloads and verifies a newer + release into `/updates/staged/`. It never swaps. The staged program + is applied by an opener just before it starts a host (no host runs, so no + call is cut off), after a second check against the key, or by + `wanderbooks update`, the owner's say-so, which reuses the staged copy. A + local release is named in `/updates/feed` (a `file://` address, then + the key file), the same rule as `--trusted-key`. `deploy/packaging/` holds the Linux packaging: `packager.toml` (cargo-packager: `.deb`, AppImage, the pacman tarball with its PKGBUILD), @@ -375,4 +400,23 @@ the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD` (the AUR package, which repacks the release's tarball), `build-linux.sh`, `repo-indexes.sh` (the apt and dnf indexes), `local-release.sh` (a throwaway-keyed feed on this machine) and `test-linux-packages.sh`. -`.github/workflows/release.yml` builds them on a version tag. +`.github/workflows/release.yml` builds them on a version tag. The macOS +bundle is `packager-macos.toml`, `Info.plist` and `build-macos.sh` (an +unsigned `.dmg` from CI's `macos` job; signing and notarization are step 5). + +## The result envelope and the guards (`ledger-mcp`, P5) + +Every tool result is given the same fields in one place: `call_tool` runs the +tool, then `next_steps::apply` adds `next_steps`, `guards`, `warnings` and +(on a tool that is not read-only) `head`, in the structured content and in +the text block alike, never overwriting a field the tool wrote. Next steps +come from the tool's own result (`next_steps::tool_steps`: the confirm after +a preview, what a post, a draft, a statement or a close leads to) and, after +a change to an entry or an invoice, from the viewer's own Needs-attention +items about it (`ledger_package::find_with`). `guards.rs` is the registry of +named refusals, each with the tool that remedies it; a refusal whose text +carries a guard gets it as `structuredContent.guards`. The prompts +(`tools/prompts.rs`, text in `prompts/*.md`) and the resource templates are +served by the same `ServerHandler`; `books.start` is loaded up front by +clients that defer tools (`_meta`). `ledger-store`'s `describe.rs` is the +one place that turns a journal line into a sentence for an agent. diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index b8aa6db..17ffa7a 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -12,12 +12,16 @@ locked to one assistant. | Crate | Role | Spec | |---|---|---| +| `ledger-core` | the engine: money, entries, the laws; every rule the tools enforce | `docs/spec/rules.md`, `docs/verification.md` | +| `ledger-package` | the read-only views shared with the viewer: reports, aging, the CPA package, Needs attention (the one list `books.start` and the viewer both draw) | `docs/architecture.md` | | `ledger-store` | the hash-chained journal (the source of truth), the SQLite projection, the blob store | `docs/receipt-store.md` | | `ledger-mcp` | the books server: the tools, resources and handbook over store + core | this document | | `ledger-host` | the agent bridge (`wanderbooks mcp`), the host and its leases, tokens | `plan/served.md` 3.3, 3.9 | -(The overview sections of this document date from the first MCP phase; P5 -rewrites them. The per-tool sections are held to the code by a test.) +(P5 rewrote the overview sections for the surface as it is: the result +envelope, `books.start` v2, the prompts, the resource templates and the +journal in words, below. The per-tool sections are held to the code by a +test: `every_tool_is_documented_and_every_documented_tool_exists`.) Rust, `rmcp` SDK (the Rust MCP SDK). The MCP crate holds no accounting logic: it translates tool calls into `Ledger` commands and formats results. @@ -106,15 +110,37 @@ Conventions, decisions, open items and approvals live in the books too when; `books.start` lists them, and the handbook's settings-and-conventions section says how to use them. -- `books.start { focus? }` (read-only, at most 8 KB) → `{ book: { name, - timezone, today, fiscal_year, base_currency, basis }, head: { seq, hash }, +- `books.start { focus?, agent? }` (read-only, at most 8 KB; `_meta` + `anthropic/alwaysLoad: true`, so a client that defers tools loads it up + front) → `{ book: { id, url, name, + timezone, today, fiscal_year, base_currency, basis }, you?: { principal, + kind, scopes, client_id? }, head: { seq, hash }, state ("needs_onboarding" or "active"), rules: { evidence, parties, approval_rules }, settings_summary: [{ key, value, set_by, seq }], conventions: [{ id, kind, title, summary }], open_items: [{ id, title, due }], projects: [{ id, name, status, kind? }], - truncated?, handbook_sections, format_upgrade?: { - guard, format, adds, how }, next_steps: [{ tool, args?, why }], - isolation }`. The connect text names it as the first call. + drafts: { building, awaiting_evidence: [{ entry_id, date, amount, note + }] }, reconciliation: { tracked, never_reconciled, behind } (account + codes), backup: { last_at, through_seq, stale }, prompts: [{ name, args + }], needs_attention: { items: [{ id, severity, kind, title, tool?, args? + }], counts: { kind: n } }, truncated?, handbook_sections, + format_upgrade?: { guard, format, adds, how }, next_steps: [{ tool | + prompt, args?, why, required }], isolation }`. The connect text names it + as the first call. `book.url` is the book's path on this server + (`/b/ID/mcp`), never a path on a disk. `you` is the token's own facts + (its name, the kind `agent`, its scopes) and the client program's name; it is + absent on the `books://start` resource, which no token's call made. + `agent: { model?, reasoning_effort?, harness? }` is what the agent says + about itself: self-reported, never checked, written to the call's access + record (`books.access_log` shows it as `agent`) and used for nothing + else. `drafts` lists the first ten drafts waiting on their evidence; + `reconciliation` the accounts reconciled to statements and which are + behind or never reconciled; `needs_attention` the twelve most urgent + items (problems first) with every item counted by kind: the same list + the viewer draws, from one function (`ledger_package::attention`). If + the answer would pass 8 KiB it shortens, in this order: convention + summaries, attention items (to four), waiting drafts (to three), then + the lists below, each saying how many more in `truncated`. `format_upgrade` is there only for books waiting for a format upgrade (below); its next step comes first, before anything else, at every start until it is done: `books.handbook {section: "format-upgrade"}`, @@ -128,8 +154,9 @@ settings-and-conventions section says how to use them. `projects` lists the active and paused projects by name, at most 20 ("projects: N more (books.projects)" past that). `next_steps`, in order: the format upgrade's (when waiting); the - onboarding step (`books.handbook {section: "contract"}`, when the chart - is empty or `focus` is `onboarding`); `books.open_items` (an item due + onboarding steps (the `onboard` prompt, `books.set_setting`, + `books.chart_template` and `books.handbook {section: "contract"}`, when + the chart is empty or `focus` is `onboarding`); `books.open_items` (an item due within 30 days or overdue); `books.backup` (none set up, or one stale, failing, paused or failing its drill); then Needs attention, by severity, at most 12, each one `{tool, args?, why}` whose `why` is the @@ -138,11 +165,11 @@ settings-and-conventions section says how to use them. server's own damage check and are not repeated); then the focus's step (`close`: `books.conventions {kind: "close_step"}`; `audit`: `books.verify`); then - always `books.handbook {section: "contract"}` and `books.digest`. + `books.draft_entry` (the books are set up), `books.handbook {section: + "contract"}` and `books.digest`. Attention steps are `required` when the + item is a problem. `focus` is `daily` (the default), `onboarding`, `close` or `audit`; - anything else is refused. Not built yet (P5): - `book.id`, `book.url`, `head.last_handoff`, the `agent` input, and - loading it up front under tool search. Also `books://start`. + anything else is refused. Also `books://start`. **The format upgrade** (plan 3.9, D-39, rules.md B-27). Books an older wanderbooks wrote are in an older journal format: they are served on it, and every tool that needs nothing new works as before. A tool whose @@ -173,6 +200,16 @@ as it is made. `policy`, `close_step`, `calendar`, `mapping`, `note`, in words). An id is a slug, revised in place (same kind), never reused once retired: `books.retire_convention { id, reason, decided_by, ... }` (destructive). + **Correcting a source** (P5, journal feature `source_revisions`): + `books.set_convention` with `reason` and the text exactly as it stands + changes only who decided it, when and on what (`decided_by`, + `decided_on`, `citation`, `note`), journaled as `convention.source_revised + {id, source, reason}`; the revision number and the words do not move. + Without `reason` the same call is refused ("already says exactly that; + to correct only who decided it, when or on what, add reason"), and with + `reason` and different words it is refused ("changes more than its + source"). Books that have not had the owner's upgrade refuse it with + `format_upgrade_pending`. - `books.draft_from_template { template_id, date, amount, accounts?, description?, party?, no_party_reason? }` → `{ entry_id, description, lines }`: a draft whose lines split the amount (at most a line's) by the @@ -190,8 +227,14 @@ as it is made. decision without them journals the bytes it always did, and one with them needs the journal feature `projects`. `books.decisions` gives each decision's `projects: [{ id, name }]` (left out when none); `project` - filters to the decisions naming one. -- `books.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }`, + filters to the decisions naming one. `books.record_decision { revises: + ID, reason, decided_by: [..], decided_on, citation? }` corrects only + that decision's source (who decided it, when, on what; its words stay, + and `subject` and `decision_md`, if given, must be the ones it has): + `decision.source_revised {id, source, reason}`, returning `{ id }`. +- `books.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }` + (`books.raise_item { revises: ID, reason, decided_by, ... }` corrects + only that item's source: `open_item.source_revised`), `books.resolve_item { id, resolution, decided_by, ... }`, `books.open_items { include_resolved? }`. - `books.approve_entry { entry_id, approver, note?, approved_on? }`: the @@ -350,8 +393,8 @@ books' files; no tool title or description, connect text or handbook section names `wanderbooks.toml` (`tool_text_lint`). A rule set by law that the books don't know for a year names its official source (the statute or publication, with its address) and says to tell the human. -Tool descriptions are at most 700 characters; eight longer ones are listed -in `tool_descriptions_fit_700_characters`, to be shortened in P5. +Tool descriptions are at most 700 characters (`tool_descriptions_fit_700_characters` +allows none longer; the long text lives in the handbook). **Annotations.** Every tool has a `title` and a `readOnlyHint`; every tool that changes anything also has a `destructiveHint`, true for these 31: @@ -443,9 +486,44 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`. `books.run_schedules`, `books.dispose_asset`, `books.reconcile`, `books.close_period`, `books.create_invoice`, `books.create_bill`, `books.receive_payment`, `books.pay_bill`, `books.write_off_invoice`, - `books.purge_tin`, `books.consolidate_duplicates` and - `books.discard_draft`. (`books.void_document`, `books.run_depreciation` + `books.purge_tin`, `books.consolidate_duplicates`, + `books.discard_draft` and `books.audit`. (`books.void_document`, `books.run_depreciation` and `books.run_recurring` post at once and have no `confirmed`.) +- **The result envelope (P5, plan 4.6).** Every tool result, whatever the + tool, is given the same fields in one place (`call_tool`, + `next_steps.rs::apply`), after the tool has answered; a tool's own field of + the same name is kept, never overwritten: + `next_steps: [{ tool | prompt | command, args?, why, required }]` (what to + do next: after a preview, the same call with `confirmed: true` and the + returned `preview`; after a post, `books.show` of the entry, plus the + Needs-attention items about that entry or invoice, the viewer's own, + `required` when they are problems; after a close, `books.verify`; after + `books.export_cpa_package`, a `command` step carrying the download + command; every other tool that can change the books ends with + `books.digest`), `guards: [{ code, message, remedy_tool, remedy_args? }]` + (empty on success; on a refusal that is a guard, the refusal's + `structuredContent` is `{ guards: [..] }` and its text is unchanged), + `warnings: [..]` (a tool's own `warnings` is that field), and, on every + tool that is not read-only, `head: { seq, hash }`, the journal's head + after the call's last write (or as it stands, when the call wrote + nothing). A tool that can change the books always has `head` and + `next_steps`; a read has `next_steps` only when something follows it, so + a resource and its tool say the same (`books://settings` is + `books.settings`). `guards` is on a refusal; a success that has no + warnings has no `warnings` key. A tool that can change the books always answers with all four; + a read has `next_steps` only when something follows it and no `guards` + or `warnings` of the envelope's, so a resource and its tool say the same + (`books://settings` is `books.settings`). The guard codes are the enum in `guards.rs`, each with its + remedy tool: `stale_preview` (the tool again, previewed), + `format_upgrade_pending` (`books.start`), `receipt_required` + (`books.attach_receipt`), `approval_required` (`books.approve_entry`), + `party_shares` and `party_required` (`books.set_entry_party`), + `open_drafts` (`books.list_drafts`), `untagged_control_line` + (`books.control_check`), `duplicate_vendor_ref` (`books.search_entries`) + and `first_number_locked` (`books.settings`). Tests: + `every_mutating_result_has_head_and_next_steps`, + `the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard`, + `every_guard_names_an_existing_tool`. - **Errors are typed strings** mirroring `LedgerError` variants, with the offending values included (e.g. unbalanced totals). @@ -1958,7 +2036,17 @@ posting. ## Resources (read-only) Hand-rolled on `ServerHandler` (rmcp 3.x has no resource macros): -`list_resources` advertises, `read_resource` renders. +`list_resources` advertises, `list_resource_templates` advertises the +templates below (P5), `read_resource` renders. + +- Templates: `books://entries/{id}` (as `books.get_entry`), + `books://attachments/{id}` (as below), `books://journal/{seq}` (that line + in words: `books.journal_history`'s answer for it), + `books://reports/{kind}{?start,end,as_of}` (`kind` is `trial-balance`, + `balance-sheet` or `profit-and-loss`; `as_of` for the first two, `start` + and `end` for the third; omitted: today, or the fiscal year to date) and + `books://digest{?since,since_seq,as_of}` (`since_seq` wins over `since`; + bare: this month so far). - `books://start`, `books://settings`, `books://conventions` - `books://chart` @@ -2118,8 +2206,8 @@ snapshots; see plan 8.2 S4. receivable line tagged with the invoice and the receivable side one line per (account, project) part. - `customer` is the customer's id, its party's id or its name (a name - two customers answer to is refused); `party_id` is the old spelling of - the same thing (a customer id), kept until P5. + two customers answer to is refused); it is required (the old `party_id` + spelling is gone, P5). - The due date: `due_date` as given (and it overrides `terms`); else `terms` ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on receipt", "Due 2026-11-15", rules.md B-39); else the customer's @@ -2170,8 +2258,8 @@ snapshots; see plan 8.2 S4. sha256 }, warnings?, notes? }`. Confirmed it posts Dr each line's account / Cr `2000` Accounts Payable, the payable line tagged with the bill and one debit line per (account, - project) part; `vendor` is a vendor id, its party's id or its name, and - `party_id` the old spelling (a vendor id). + project) part; `vendor` is a vendor id, its party's id or its name + (required; the old `party_id` spelling is gone, P5). - `vendor_ref` is the vendor's own invoice number: required in books that have declared `documents_v2` (B-40). The same vendor and number twice is refused unless `duplicate_reason` says why, which is @@ -2194,8 +2282,8 @@ snapshots; see plan 8.2 S4. - `warnings` names an expense account it created with no tax category; `notes` one per finished or abandoned project a line names (none for on-spec: a cost of on-spec work is an expense like any other). -- `books.receive_payment { date, deposit_to | cash_account, instrument?, - reference?, invoice | document_id, amount? | applications: [{ invoice, +- `books.receive_payment { date, deposit_to, instrument?, + reference?, invoice, amount? | applications: [{ invoice, amount, discount?, discount_reason? }], fee?: { amount, account?, vendor? }, paid_in_full_marking?, confirmed?, preview? }` → `{ preview?, entry_id, documents: [{ ref, number?, outstanding, status }], deposited, @@ -2217,11 +2305,11 @@ snapshots; see plan 8.2 S4. repeat the single document's figures. An overpayment is refused with the excess and the remedy (a plain entry to 2300 Customer Deposits). `paid_in_full_marking: true` adds a UCC §3-311 warning and records - nothing else. `document_id` and `cash_account` are the older names of - `invoice`/`bill` and `deposit_to`/`paid_from`. The books record + nothing else. (The older names `document_id` and `cash_account` are + gone, P5.) The books record payments; they never collect them (D-45). -- `books.pay_bill { date, paid_from | cash_account, instrument?, - reference?, bill | document_id, amount? | applications: [{ bill, amount, +- `books.pay_bill { date, paid_from, instrument?, + reference?, bill, amount? | applications: [{ bill, amount, discount?, discount_reason? }], confirmed?, preview? }` → as `receive_payment`, per bill and one vendor (B-42): Dr A/P each bill's cash and discount, Cr the payer account the cash, Cr purchase discounts the discounts. A discount on a @@ -2466,7 +2554,7 @@ until the owner upgrades them. ## Tools — CPA handoff -- `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, +- `books.export_cpa_package { as_of?, without_audit_reason?, cash_accounts?, financing_accounts?, investing_accounts?, include_receipts?, include_tins?, allow_open_drafts?: { reason } }` → `{ file, tins_included, tin_gaps, warnings?, note }`: the zip, @@ -2604,3 +2692,112 @@ state and discard tools wait for the books' format upgrade). drafts_open_on_or_before: [draft], unreconciled: [...], settings_problems: [...] }` for the report's end date, and take `strict: true` to be refused, naming what is open, instead of warned. + +## Tools — the journal in words (P5) + +- `books.journal_history { after_seq?, types?, subject?, limit? }` → `{ + lines: [{ seq, ts, type, summary, subject?, entry_id? }], more, head_seq, + tail_repairs: [{ at, bytes }] }`. The journal in order, a sentence for + each line (`summary`: what it did, to what, the short names a person + typed; never a setting's value, never a full TIN), the line's own + `subject` (`entry:12`, `invoice:3`, `bill:3`, `account:10`, `party:5`, + `asset:2`) and the entry it belongs to. `types` takes an exact type + (`entry.posted`) or a group ending in a dot (`entry.`); `subject` keeps + the lines about one thing (a line is about the subject its type names and + every one its payload refers to); `limit` is 50 by default, 200 at most; + `more` says to ask again with the last `seq` as `after_seq`. Read-only, + `books:read`. It is how a figure is traced to what produced it and the + only way to read the journal; `tail_repairs` lists torn tails set aside + when the books opened (when, how many bytes). The sentences come from + `ledger-store`'s `describe.rs`. + +## Prompts (P5) + +The server advertises the prompts capability (also in the bridge's cold +`initialize`). `prompts/list` and `prompts/get` need a token that may read. +Each prompt's text is a file in `ledger-mcp/prompts/`, with the company's +name and the arguments filled in; it names only tools that exist (a test +checks). An argument is one token (letters, digits, `-`, `_`, `.`, `:`; at +most 40 characters) of the shape the prompt asks for, so an argument can +never rewrite the text; an unknown prompt, a missing, extra or malformed +argument is refused. + +| Prompt | Arguments | Drives | +|---|---|---| +| `onboard` | none | the company's facts, the chart, the policies, the opening balances, the trial-balance check | +| `reconcile` | `account` (a code), `month` (YYYY-MM) | the statement, `books.record_statement`, `books.mark_cleared`, `books.reconcile` | +| `month_end_close` | `month` (YYYY-MM) | the company's own close steps, reconciliations, drafts, schedules, checks, `books.close_period` | +| `cpa_handoff` | `year` (YYYY) | `books.verify`, the periods, 1099 work, `books.export_cpa_package` | +| `annual_compliance` | `year` (YYYY) | the year's filings, 1099s, assets, open items | +| `audit` | `scope` (`month`, `annual`, `cpa_handoff` or `full`) | `books.verify`, `books.control_check`, the digest, evidence against entries (P8 fills in its own tools) | + +`migrate`, `process_inbox` and `receipts_from_email` are not built (D-46, +D-28, D-17). +## Tools — audit and attestations (P8) + +An audit is the books read as an auditor reads them, through a day, in one +run: the same books give the same findings and the same workpaper. An +attestation is a claim by a checker that it looked at something. The audit +finds; the attestations stand behind it; the CPA package waits for both +(rules.md B-45; decision D-9; the journal feature `audits`: the audit run +and the attestations wait for the books' format upgrade, refused with +`format_upgrade_pending` until then). + +- `books.audit { scope, through?, confirmed?, preview? }` → `{ status: + "preview" | "audited" | "already_run", preview, head, checks: [{ code, + severity, predicate }], run?: { id, through, through_seq, scope, + program_version, tb_hash, findings_sha256, workpaper_attachment, + workpaper_pdf, at_current_head }, findings: [{ severity, code, subject, + message }], summary }`. `scope` is `month`, `annual`, `cpa_handoff` or + `full`; `through` defaults to today. Without `confirmed` it lists the + checks and changes nothing; with `confirmed` and the `preview` digest it + runs them, files the workpaper as two documents (category `workpaper`: + JSON, and a PDF whose bytes depend only on the findings) and journals + `audit.run`. The checks are the statements (trial balance, the + accounting equation, the profit and loss against equity, the cash flow's + footing), the A/R and A/P control accounts, closes, schedules and + depreciation, the asset register, each posted entry against the receipt + policy, open drafts, tax categories, 1099 decisions and the settings; + then the journal's chain, the database against a replay, the stored + files, the access log, duplicate files and the backups. The same books + (the figure head), day, scope and programme return the run that exists + (`already_run`) and file and journal nothing. "The same books" means no + entry, document, close, settings or role change since: an audit, an + attestation or a filed receipt does not change them. Needs books:write to + preview, books:post to run. +- `books.workpaper { id }` → `{ run, workpaper, pdf? }`: the workpaper as + filed (what was checked, through which day and state, every finding) and + its PDF handed over (`pdf` is a download command; kept 15 minutes). + Read-only. +- `books.attest_receipt { attachment_id, entry_id, amount, date, method?, + note? }` → `{ attestation, bar, why_not_counted? }`. The checker's + reading of the receipt's total and date, compared by the books with the + posted entry's amounts (its debits, or any one line) and date; a + difference is recorded with `disagrees: true` and never counts. The + receipt must be filed on or linked to the entry. +- `books.review_entry { entry_id, method?, note? }` → the same shape: a + posted entry reviewed whole. +- `books.attest_period { period, method?, note? }` → the same shape. + `period` is an id or a name from `books.list_periods`. Refused unless an + audit through the period's end stands at the books' current head; the + attestation names that run. +- `books.attestations { through?, kind? }` → `{ bar, attestations: [{ seq, + kind, by, at, attachment_id?, entry_id?, period_id?, audit_run_id?, + amount_checked?, date_checked?, disagrees, method, note?, counted }], + counted }`. `kind` is `receipt`, `entry` or `period`. `counted` is read + against `policy.attestation_bar` as it stands when asked and never + stored: a bar change changes what counts, earlier claims too. + +`method` is `{ model?, reasoning_effort?, adversarial?, verifier_agents? }` +and is the client's own claim: the books record it and cannot verify it. +`by` is the token's name. The bar (`policy.attestation_bar`, default +`effort=high verifiers=2 adversarial=true`) holds every attestation to the +effort, and a period attested on a year-level audit (`annual`, +`cpa_handoff`, `full`) to the adversarial check and the verifier count too. + +`books.export_cpa_package` gains `without_audit_reason?`: on books that +declared `audits`, with `policy.cpa_package_requires_audit` at `refuse` (the +default once the books hold an audit run; before one it warns), the package is refused (`audit_required`) unless an audit with +scope `cpa_handoff` or `full` stands at the current head with a period +attested on it at the bar; a reason overrides the refusal and is journaled +as a decision. Under `warn` the package is made and its `warnings` say so. diff --git a/docs/packaging.md b/docs/packaging.md index 5a9b338..5312029 100644 --- a/docs/packaging.md +++ b/docs/packaging.md @@ -16,8 +16,8 @@ not a tool, so there is nothing for a recipe to call. | `.rpm` | `cargo-generate-rpm` (table in `crates/ledger-cli/Cargo.toml`) | same paths, marker `rpm`; 36 MB | dnf repository (`repo-indexes.sh dnf`) | unsigned until step 5 (`--signing-key` takes the PGP key then) | | AUR `PKGBUILD` | `cargo packager` makes the tarball (`pacman` format) and a PKGBUILD for it; ours is `deploy/packaging/aur/PKGBUILD`, which repacks the release's tarball (package `wanderbooks-bin`) | same paths, marker `aur`; built with `makepkg` | pacman (the AUR keeps old PKGBUILDs) | n/a (pacman trusts the AUR's checksums) | | AppImage | `cargo packager` (`appimage`); 44 MB | one file the owner puts anywhere and runs; needs no admin password | `wanderbooks update` (`$APPIMAGE` is the file replaced) against the manifest; minisign | minisign by the release key (step 5) | -| `.dmg` | `cargo packager` (`dmg`) | (trial) needs a Mac | `wanderbooks update` | Developer ID and notarization (step 5) | -| NSIS `.exe` | `cargo packager` (`nsis`) | (trial) needs Windows, and native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) | +| `.dmg` | `cargo packager` (`dmg`), `deploy/packaging/build-macos.sh`; **built in CI, unsigned; the trial on a Mac is the owner's** | `Wanderbooks.app` dragged to Applications; no admin password (the trial will say) | `wanderbooks update` (it replaces the program inside the `.app`: see the note below) | **unsigned**; Developer ID and notarization are step 5 | +| NSIS `.exe` | `cargo packager` (`nsis`) | **not built**: native Windows is unit P11c (D-62) | `wanderbooks update` | the Windows identity (step 5) | `just package-linux` builds the first four (`deploy/packaging/build-linux.sh`, about a minute after the release build, itself 8 to 12 minutes on a @@ -64,21 +64,18 @@ What the trial found, each of which is built around now: manifest's `signature` is that `.sig` as written, and `update` reads the public key in either form. Checked end to end by hand (below), and by `a_public_key_is_read_in_every_form_minisign_and_the_packager_write_it`. -7. **The AppImage updates itself but the books it holds do not restart - onto the new image.** `update` replaces `$APPIMAGE`, and an AppImage run - from a shell (`Wanderbooks.AppImage update`, or the one that was running) - proves the swap and the rollback: after `update`, the AppImage on disk - is the new file, the old one is kept, and `rollback` restores it. But the - host that is open runs from the old image's mount +7. **The AppImage updates itself, and (since step 4) the books it holds + restart onto the new image.** `update` replaces `$APPIMAGE`. The host + that is open used to run from the old image's mount (`/tmp/.mount_*/usr/bin/wanderbooks`), a path that does not change, so - `host.rs` never sees a replaced file and the open books keep the old - version until they are closed and opened again (`wanderbooks close`). - Fixing it means the host learning the image's path (its environment is - scrubbed on purpose; `APPIMAGE` would join the allow-list of `start.rs`, - or the path would come from the mount table) and starting its successor - from it: a decision about what the host may trust, left to the EM (the - unit's report names it). Package installs and a hand-placed executable - restart as designed (`bridge_survives_update`). + it never saw a replaced file. The host now reads `APPIMAGE` (it is in + the allow-list of `start.rs`'s scrubbed environment, only when it names + an absolute file; a path the host reads about itself and never tells an + agent), runs and restarts from that file, and tells a replaced program + by its inode, size and last write. Tested without FUSE by + `a_replaced_appimage_restarts_the_books` (the programs run from one + copy while `APPIMAGE` names another, as a mount does); not run against + a real image mount in this wave. 8. **Onboarding with no terminal does not exist yet.** The unit's Done-when (1) says an AppImage "opens, onboards and posts an entry with no terminal". The AppImage opens (a bare launch is the viewer), but the viewer @@ -118,6 +115,43 @@ package manager. Not tested: Ubuntu, openSUSE, a package-manager *downgrade* (the repositories keep every release by construction), and the `.rpm` and `.deb` under their real signatures (step 5). +## The macOS bundle (step 6, built in CI, unsigned) + +`deploy/packaging/packager-macos.toml` (cargo-packager: the `dmg` format, +which makes the `.app` first; the icon is the 512 px PNG, made into an +`.icns` by the packager; `minimum-system-version` 11.0), `Info.plist` +(merged into the generated one: one viewer at a time, no discrete GPU), +`build-macos.sh` (the same shape as the Linux script, in portable shell +because macOS has BSD `sed`) and `just package-macos`. The `macos` job of +`.github/workflows/release.yml` runs on `macos-latest` (Apple silicon: the +manifest key is `macos-aarch64`; an Intel build is not made), uploads the +`.dmg` as a run artifact (named `macos-dmg-unsigned`) and, on a tag, +attaches it to the release. **It is not signed or notarized** (step 5: the +owner's Developer ID and Q-4), so Gatekeeper refuses it on a Mac that +downloaded it; the owner opens it with right-click, Open. What was checked +here, on Linux: the config parses under cargo-packager 0.11.8's strict +schema (unknown keys are errors) and the script's `sed` is the portable +subset. **Not checked: that the `.dmg` builds, mounts or runs**: that needs +the CI run (Actions, release, Run workflow, this branch) and then a Mac. +Open items for the trial on a Mac: whether `wanderbooks update` replacing +the program inside a signed `.app` breaks its signature (it will: step 5 +must decide, probably by updating a whole `.app`); the translocated-app +check (`translocated_app_refuses_to_connect`) is not built. + +## Updates staged by the host (step 4) + +The host looks at the feed at most once a day while the books are open +(`update_check`), and downloads and verifies a newer release into +`/updates/staged/` with a `meta.json` (version, sha256, signature, +notes). It never swaps. The next opener that starts a host applies it +first; `wanderbooks update` applies it too and reuses the staged copy; +`update --check` says "already downloaded". A local release is named by +`/updates/feed` (a `file://` address, then the key file). Without a +compiled-in release key and without that file, the look does nothing but +log why. Not built: the in-app "restart now to update" choice and the +viewer's notice, which need an admin-socket intent and a place in the +viewer (see the unit's As-built block). + ## What `update` and `rollback` need from a release `manifest.json` at `releases/latest/download/` (D-26): diff --git a/docs/parity.md b/docs/parity.md index 0930ee6..5a3f598 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -223,3 +223,27 @@ backdated entries included: defect 6), `zero_net_accounts`, `drafts_by_state` and the reconciliation split; reports gained `as_of_status` and `strict`; the CPA package is refused while a draft dated in it is open (4.8). + +### P5: the agent surface (2026-10-29) + +No semantic feature moved, so no status changed and the floor stands. P5 +changes how an agent is told what to do, not how a figure is computed: the +result envelope on every tool result (`next_steps`, `guards`, `warnings`, +`head`), `books.start` v2, the prompts, the resource templates, +`books.journal_history` (the journal in words) and source corrections +(`source_revisions`: `convention.source_revised`, `decision.source_revised`, +`open_item.source_revised`). One new row, `partial` until a hand-verified +golden covers it: + +| Area | Counterpart (a cross-check, not the spec) | Status | Notes | +|---|---|---|---| +| Audit trail in words (`books.journal_history`, the `describe` sentences) | QBO "Audit log", Xero "History and notes" | partial | tests `journal_history_traces_what_the_books_did`, `every_event_type_has_a_sentence`; the viewer keeps its own sentences (the shared describer is the server's) | +### P8: audit and attestations (2026-10-29) + +Three new rows, none above changed status, and the floor stands. + +| Area | Counterpart (a cross-check, not the spec) | Status | Notes | +|---|---|---|---| +| Audit run: one programme of checks over the statements, the control accounts, closes, schedules, assets, evidence, drafts, tax categories, 1099 decisions, settings and the store's integrity (`books.audit`, `books.workpaper`) | An auditor's workpaper; QBO "Audit log"/"Review" and Xero's "Reports review" are a cross-check only | partial | rules.md B-45; `ledger_core::audit`, `ledger_store::audit`; tests `audit_detects_each_seeded_defect` (every code seeded), `audit_is_deterministic`, `workpaper_pdf_is_deterministic`; the fixture `p8_before` was written by the version before; `partial` until a hand-verified golden covers a workpaper | +| Attestations of receipts, entries and periods, weighed against `policy.attestation_bar` when asked (`books.attest_receipt`, `books.review_entry`, `books.attest_period`, `books.attestations`) | An accountant's sign-off on a month | partial | `below_bar_not_counted`, `amount_mismatch_never_counts`, `stale_head_period_attestation_refused`; the method is the client's own claim, and the books say so | +| The CPA package waits for a counted audit (`policy.cpa_package_requires_audit`) | A close checklist before handoff | partial | `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`; behind the feature `audits` | diff --git a/docs/receipt-store.md b/docs/receipt-store.md index 965d87b..29a7bd1 100644 --- a/docs/receipt-store.md +++ b/docs/receipt-store.md @@ -64,7 +64,16 @@ apply is safe — on startup the applier replays every journal line with `attachment.annotated`, `entry.settles` (P6), `entry.draft_state_set` (a draft building or awaiting its evidence: a note on the draft, no figure) and `entry.draft_discarded` (a draft thrown away, with why; the - draft's earlier lines stay in the journal) (P4, feature `drafts_v2`) + draft's earlier lines stay in the journal) (P4, feature `drafts_v2`), + and `convention.source_revised {id, source, reason}`, + `decision.source_revised {id, source: {decided_by: [..], decided_on, + citation?}, reason}` and `open_item.source_revised {id, source, reason}` + (P5, feature `source_revisions`: who decided a convention, a decision or + an open item, when and on what, corrected without touching its words; + replay sets the source and nothing else, and the projection updates the + row's existing source columns, so no schema changed) + `audit.run`, `receipt.attested`, `entry.reviewed` and `period.attested` + (P8, feature `audits`: claims about the books, no figure) (plus the A/R, A/P, asset, period, payroll, and class events listed on `JournalLine`). - `payload`: the full domain facts (account fields, entry with lines, blob @@ -167,6 +176,20 @@ a backup is then proven where it lands, by a drill (docs/mcp-interface.md, read off a file (the closed field list; a later annotation replaces the earlier record), and `entry.settles {entry_id, settles[]}` says which entries a payment pays back (the only source of "reimbursed"). +- **Audits and attestations are records, not figures** (P8, rules.md B-45, + feature `audits`): `audit.run {id, through, through_seq, scope, + program_version, tb_hash, findings_sha256, workpaper_attachment?, + workpaper_pdf?}` says a programme of checks was run through a day at the + books' figure head `through_seq` (the last line that bears on a figure; + audits, attestations and filings of evidence do not move it), and which + two filed documents (category `workpaper`) hold its findings; + `receipt.attested {attachment_id, entry_id, by, at, method, + amount_checked, date_checked, disagrees, note?}`, `entry.reviewed + {entry_id, by, at, method, note?}` and `period.attested {period_id, by, + at, method, audit_run_id}` are a named client's claim that it checked. + `method` ({model?, reasoning_effort?, adversarial?, verifier_agents?}) is + that client's own word. Whether a claim counts is never journaled: it is + derived against `policy.attestation_bar` when asked. - The store is policy-free: it records attachments faithfully. Whether a posting *requires* a receipt is decided by the MCP layer's receipt policy (`docs/mcp-interface.md`), which reads the `attachments` table. @@ -192,7 +215,11 @@ Suspected corruption is fixed by rebuild, never by forensics. as above; `extractions(attachment_id, extractor, version, text_sha256, pages, status, seq)` and `annotations(attachment_id, receipt_number, payee, amount, date, instrument, category, source, reimbursable_to, - note, seq)` (P6; amounts TEXT, never REAL); `events(seq, type, payload, + note, seq)` (P6; amounts TEXT, never REAL); `audits(id, through, + through_seq, scope, program_version, tb_hash, findings_sha256, + workpaper_attachment, workpaper_pdf, seq)` and `attestations(seq, kind, + attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts, method, + amount_checked, date_checked, disagrees, note)` (P8, schema 20); `events(seq, type, payload, created_at)` — the applier's output, a queryable mirror of the journal stream and the feed the future GUI tails. (Poll `WHERE seq > ?`; later SSE over the HTTP transport. This is diff --git a/docs/runbooks/re-audit.md b/docs/runbooks/re-audit.md new file mode 100644 index 0000000..5d963a7 --- /dev/null +++ b/docs/runbooks/re-audit.md @@ -0,0 +1,189 @@ +# The re-audit, through the MCP alone + +The owner's acceptance of the whole programme (plan/served.md, P10). An +agent audits a company's books using only the books tools, the books' own +filed documents and the viewer's Export menu. Nothing here reads the data +root, a database file or the journal file. The engineering mirror of this +runbook is `crates/ledger-cli/tests/defects.rs` (every tool named below +exists; every defect has a named regression test); the run itself is the +owner's, on the owner's books. + +Money is a decimal string everywhere. Every tool named is a tool in +`books.start`'s list; if one is missing, stop: the runbook is stale, not +the books. + +## Before you start + +- Two roles. The **auditor** runs `books.audit`, reads, and proposes fixes. + The **verifiers** (at least two agents, not the auditor, not whoever made + the entry) attest. The human confirms every correction. +- The bar is `policy.attestation_bar` (default `effort=high verifiers=2 + adversarial=true`, D-9). Read it with `books.settings`. A verifier's + `method` is its own claim, said truthfully: `{ model, reasoning_effort, + adversarial, verifier_agents }`. A reading that differs from the entry is + recorded with `disagrees: true` and never counts; never bend a reading to + fit. +- Context discipline. A receipt is read as text first + (`books.get_receipt`, the default `meta` view is under 8 KiB, `view: + "text"` gives the whole text), as one page picture (`view: "page_image"`, + `page`, `max_px`) only when the text is empty or looks wrong, and never as + `bytes`. Record each verifier's own token count for receipts; the exit + bar is under 100K tokens per verifier. +- Start with `books.start` (rules, conventions, open items, its `next_steps`) + and `books.handbook` section `attestations-and-audit`. The `audit` prompt + (`scope`: `month`, `annual`, `cpa_handoff` or `full`) is the same + procedure for a client that offers prompts. +- Audits and attestations need the books' `audits` feature (the owner's + format upgrade). Without it the tools refuse with `format_upgrade_pending`: + do the upgrade first, as its own step, with the owner. + +## The two cases + +- **T1, desktop only.** Claude Code and Codex on the owner's machine, through + `wanderbooks connect`. Separation between the agent and the data root is + advisory. The evidence that it held is the access log + (`books.access_log`, `books:admin`) and the agents' transcripts, which never + name the data root or a path into it. +- **T2, a self-host machine running** (D-30, D-31; `deploy/README.md`). The + same steps, plus claude.ai through the tunnel once P9b exists. Separation + is enforced by the machine's account split. Until P9b, T2 is T1 with the + server on its own machine. + +The steps are identical; only the evidence in step 8 differs. + +## Step 1. The first audit and its fixes + +1. `books.audit { scope: "full", through: }` without + `confirmed`: it lists the checks and changes nothing. Show the human. + Then run it with `confirmed: true` and the `preview` digest. Do the same + for `{ scope: "cpa_handoff", through: }`. +2. `books.workpaper { id }` for each run: the findings and the PDF as filed. +3. Fix every finding through journaled corrections, never an edit: + - a wrong posted entry: `books.reverse_entry { entry_id, date, + description }`, previewed, then `confirmed: true`; then the right entry + through `books.draft_entry`, `books.add_line`, `books.post_entry` + (preview, then `confirmed`); + - a reversal with no receipt of its own (an old one, `legacy_reversal_exempt`): + its evidence is its original's (D-12, D-47). Link the original's receipt + to the reversal with `books.link_attachment { attachment_id, subject }`; + `books.list_receipts { entry_id }` finds the receipt; + - a duplicate file: `books.consolidate_duplicates` (preview, then + `confirmed`); `books.find_attachment` to look one up by hash or number; + - a receipt's facts: `books.annotate_attachment`; + - an open draft: post it, or `books.discard_draft { reason }`; + - an undecided 1099 vendor: `books.set_vendor_1099`; + - a missing tax category: `books.set_tax_category`; + - a judgment the books should remember: `books.record_decision`. + Nothing is fixed without the human's confirmation. +4. Audit again at the new head. An audit is idempotent on the same books + (`already_run`), so a new run means the books changed. Repeat until the + `full` and `cpa_handoff` runs have no error findings; keep the final two + run ids. Each journaled fix can be traced with `books.journal_history + { subject }`. + +## Step 2. Every receipt attested + +For every receipt on a posted entry (`books.list_receipts`, entry by entry, +or `books.search_entries` then `books.get_entry`): + +1. Verifier A reads `books.get_receipt` (text; a page image only if needed) + and calls `books.attest_receipt { attachment_id, entry_id, amount, date, + method, note? }` with the total and the date as read off the receipt. +2. Verifier B does the same independently, without seeing A's reading. +3. `books.attestations { kind: "receipt" }` shows each claim with + `counted`. A claim below the bar, or with `disagrees: true`, does not + count; the disagreement is a finding: reopen step 1 for that entry. + +## Step 3. Every entry reviewed, every period attested + +1. `books.review_entry { entry_id, method }` for every posted entry, by a + verifier that did not make it. +2. `books.attest_period { period, method }` for each month since the + company's formation (`books.list_periods`), each against a standing + audit at the current head (`scope: "month"` or `"full"`), then once for + the fiscal year against the `cpa_handoff` run. A period attestation is + refused when the books changed since the audit: audit again, then attest. +3. `books.attestations` with no filter: every row has `counted: true` and + none has `disagrees`. + +## Step 4. The defect regressions + +The 2026-10-16 audit found 20 defects; P0 found two more (21, 22) and P3 +two (23, 24). Each has a named regression test in the workspace; +`crates/ledger-cli/tests/defects.rs` holds the list `(defect, test, file)` +and proves each name exists. Show them green, from a checkout of the build +under audit: + +``` +cargo test -p ledger-cli --test defects +``` + +then each test named in that list, by name, for example +`cargo test -p ledger-core --test spec_cash_flow +defect1_two_member_llc_first_year_regression`. Two defects have no code +fix to test: defect 2 (reversals without their own evidence) is closed by +D-12 and D-47 (its test shows the rule now) and defect 20 (the migration +scripts) was retired at the cutover (no tool exists; the test checks none +came back). Record the test run's output in the sign-off decision (step 7). + +## Step 5. The Sheet comparison + +The old Google Sheet and the books were frozen as of 2026-10-11 and +compared a final time before the cutover (P2, `verify-sheet`); that run is +filed as a document. There is no comparison tool and this +runbook builds none. + +1. `books.find_attachment { text: "verify-sheet" }` finds the filed run + (its extracted text names the tool); `books.get_receipt { attachment_id }` + reads it (text view; its `links` and `sha256` identify it). Cite it by + attachment id and sha256. If it is not found, stop: the comparison was + not filed at the cutover and the owner decides what stands in for it. +2. `books.record_decision { subject: "Sheet comparison as of 2026-10-11", + decision_md, decided_by: [], decided_on, evidence_attachment: + }` stating that the frozen Sheet and the books + agreed then, and nothing newer is claimed. + +## Step 6. The CPA package from both doors + +1. The agent's door: `books.export_cpa_package { as_of: , + include_tins: true }` (TINs are included by default, D-29). Without a + counted audit it is refused (`audit_required`); never use + `without_audit_reason` for the re-audit, the point is that it is not + needed. Save the zip with the download command in its `file`. +2. The viewer's door: `wanderbooks gui `, the Export menu, the CPA + package as of the same day. It lands in the Downloads folder. +3. Compare the two zips file by file: byte-identical except for timestamps + (the README's generated-at line and the zip entries' times). A difference + in any figure is a defect: stop and report it. + +## Step 7. Verify, back up, sign off + +1. `books.verify { blobs: true }`: `status: "ok"` (not `healed`: a healed + result means a torn tail was set aside; note it and look at + `books.journal_history`'s `tail_repairs`). +2. `books.backup`: every target not `stale`, `last_error` empty, and + `last_drill_at` set with no `drill_error`. If not, `books.back_up_now` + (an admin action) and read it again. The drill is the restore test; a + backup that was never restored is not a backup. +3. The owner's sign-off: `books.record_decision { subject: "Re-audit + sign-off", decision_md, decided_by: [], decided_on }` naming + the final `cpa_handoff` and `full` run ids, the journal head + (`books.verify`'s `head`), the defects run, and the CPA package's sha256. + +## Step 8. The exit evidence + +The re-audit passes only when all of these are true. The owner checks them; +the agent presents the proof for each. + +| Exit bar | Proof | +|---|---| +| Zero failing workpaper checks | the final `full` and `cpa_handoff` workpapers (`books.workpaper`) | +| Cash flow difference 0.00 | `books.cash_flow` for the year; no `cash_flow_unfooted` finding | +| Every receipt and posted entry has a counted attestation, none disagrees | `books.attestations` (step 3.3) | +| Every action under the right principal | `books.access_log`: the auditor's token and each verifier's token appear on their own calls; no action by an admin token that an agent token did | +| No transcript names the data root | grep each agent's transcript for the root path before sign-off (T1); T2: the tenant root is not readable by the agent's account | +| No result carried a full TIN to a client that should not see one | `books.access_log` shows `include_tins` only on `books.export_cpa_package` and `books.get_w9`/`books.vendor_1099` calls by the owner's agent; the agents' exports may carry them (D-29), nothing else | +| Receipt reading stayed under 100K tokens per verifier | each verifier's own token count for its receipt calls (the harness's count, written into the attestation `note`) | +| The sign-off is journaled | `books.decisions` shows it | + +When all are true the books are re-audited through the MCP alone. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 585f1bd..5e373a5 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -13,7 +13,7 @@ Foundation"). Spec blocks in the code cite these as `SOURCE: rules.md B-n`. Every rule also has a bookspec identifier, `**bookspec:** BS--`: the table -from B-1 to B-45 is `bookspec/spec/rules-index.md` (P13, D-34). +from B-1 to B-45 is `spec/rules-index.md` in the bookspec repository, https://git.swaits.com/swaits/bookspec (P13, D-34). --- @@ -1761,7 +1761,15 @@ saying what it duplicates, B-44). P4's is `drafts_v2` (an `entry.draft_state_set` or `entry.draft_discarded`, both new event types: an older version would refuse them, or, reading around them, keep a discarded draft that holds a period open and call a waiting draft one -being built, B-43). A command asks for +being built, B-43). P5's is `source_revisions` (a +`convention.source_revised`, `decision.source_revised` or +`open_item.source_revised`, new event types: an older version would +refuse them, or show the old source as the record's authority). A command asks for +being built, B-43). P8's is `audits` (an `audit.run`, `receipt.attested`, +`entry.reviewed` or `period.attested`, all new event types, and the +settings `policy.attestation_bar` and `policy.cpa_package_requires_audit`: +an older version would refuse the types and the keys, or drop the claims a +handoff rests on, B-45). A command asks for the feature it needs (`Store::ensure_feature`) before its first journaled line, and only when the line it is about to write uses it, so a write refused for waiting on the upgrade journals nothing, never an entry @@ -3582,3 +3590,93 @@ withdraw_attachment_duplicate}` and `Store::attach`'s dedup; feature `page_image_is_a_png_of_the_page`, `consolidation_never_removes_an_entrys_only_evidence`); fuzz targets `fuzz_extract_pdf` and `fuzz_extract_xlsx`. + +## B-45 An audit is deterministic over the books; an attestation is a claim by a named client + +**bookspec:** `BS-ledger-13` + +**Status:** CONVENTION (the SOURCE block below; Pub. 583 and Rev. Proc. 98-25 +for the audit trail); CHOICE D-9 (owner-adopted recommendation, +2026-10-16: the attestation bar, and the CPA package waits for a counted +audit unless given a reason). + +```text +GOAL: the books can be audited by a programme that gives the same + findings for the same books, and a claim that someone checked rests + on the books as they were when the audit ran, says how it was made, + and is weighed against the owner's bar when asked. +SOURCE: IRS Pub. 583 (records that support the entries); Rev. Proc. 98-25 + §5.01 (the relationship between the records and the books must be + demonstrable: an audit trail); CHOICE D-9 (the bar: reasoning + effort at least high; for a year, adversarial verification by at + least two independent agents; no model allow-list; the CPA package + refuses without a counted audit unless given a reason); CHOICE + D-53 (an audit is a run, not a job); CONVENTION (the figure head, + below; a client's method is its own claim). +INVARIANTS: + 1. An audit is a pure function of the journal's figures: its findings + (`ledger_core::audit::statements`, the store's integrity checks) are + a list in a fixed order, and the same books, day, scope and + programme give the same findings, the same trial balance hash and + the same workpaper bytes. A check that cannot be made is itself a + finding, never a silent pass. + 2. The figure head is the seq of the last journal line that bears on a + figure. Audit runs, attestations, filings of evidence (receipts, + documents, statements, reconciliations, extraction, annotations, + links), decisions and open items, and the format declaration do not + move it; an entry, a draft, a document, a close, a schedule, an + asset, a settings change or a role change does. A run is idempotent + on (through, head, scope, programme): asking again returns it and + journals nothing. + 3. A period is attested only on a run at the current head whose day is + on or after the period's end. + 4. An attestation is a claim by the token that made it (`by`); its + method (model, reasoning effort, adversarial, verifier agents) is + the client's own, recorded and never verified. A receipt attestation + whose amount or date differs from the entry's is recorded with + `disagrees` and never counts. + 5. `counted` is derived against `policy.attestation_bar` as it stands + when asked and never stored: a bar change changes what counts, past + claims too. Every attestation needs the reasoning effort; one on a + year-level run (annual, cpa_handoff, full) needs the adversarial + check and the verifier count too. + 6. The CPA package, on books that declared `audits` and under + `policy.cpa_package_requires_audit = refuse` (unset: refuse once the + books hold an audit run, warn before), needs a run of scope + cpa_handoff or full at the current head with a period attested on it + that counts; otherwise it is refused, unless a reason is given, + which is journaled as a decision. + 7. Nothing here moves a figure. Every refusal leaves the journal + unchanged; every write waits for the feature `audits`. +SCENARIOS: + 1. The same books audited twice through one day: one run, one + workpaper; the second call files and journals nothing. + 2. An entry posted after the audit: the period cannot be attested until + the audit is run again. + 3. A receipt read as 41.00 against a 40.00 entry: recorded, `disagrees`, + not counted, however hard the checker says it thought. + 4. The bar lowered afterwards: earlier attestations that now meet it + count. + 5. A CPA package with no counted audit: refused (`audit_required`); + with a reason, made, and the reason is a decision in the journal. + 6. Books that never declared `audits`: the package is as it was; the + audit run and the attestations are refused with + `format_upgrade_pending`. +``` + +**Enforced:** `ledger_core::audit` (`statements`, `Finding`, `CODES`, +`AttestationBar::counts`, `Scope`, `Method`), `ledger_store::audit` +(`Store::figure_head`, `integrity_findings`, `record_audit_run`, +`attest_receipt`, `review_entry`, `attest_period`, `run_for_period`, +`attestations`, `PROGRAM_VERSION`), `ledger_import::workpaper_pdf`, +ledger-mcp `tools/audit.rs` and the gate in `books.export_cpa_package`, +journal feature `audits`. +**Checked:** `ledger-core` `tests/audit.rs` +(`audit_detects_each_seeded_defect`, `below_bar_not_counted`, +`amount_mismatch_never_counts`); `ledger-store` `tests/audit.rs` +(`audit_writes_wait_for_audits`, `a_run_is_idempotent_on_the_figure_head`, +`stale_head_period_attestation_refused`, `integrity_detects_each_seeded_defect`, +`books_the_version_before_p8_wrote_wait_for_the_upgrade`); `ledger-import` +`workpaper_pdf_is_deterministic`; `ledger-cli` `tests/audit.rs` +(`audit_is_deterministic`, `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`, +`audits_wait_for_the_upgrade`). diff --git a/docs/testing.md b/docs/testing.md index 19957c8..27e3284 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3769,6 +3769,25 @@ it (the same program with a tail, so its hash differs). All in file now on disk (not a deleted one). The host's own restart laws stay `open_while_held.rs`'s (`a_restart_is_not_a_close`). +Step 3's gap and step 4 (same file): + +- `a_replaced_appimage_restarts_the_books`: the programs run from one copy + (a mount's) while `APPIMAGE` names another (the image); `update` + replaces only the image; the host restarts onto it (its `/proc/PID/exe` + is the image), the mount's copy is untouched, the session writes on. +- `the_host_stages_a_daily_update_and_the_next_start_applies_it`: with a + local feed named in `updates/feed`, the host stages 9.0.0 by itself; + the program, the host and the session are unchanged meanwhile; after + `close`, the next opener applies it (the old program is kept, the staged + folder gone) and the books are as they were. +- `a_daily_update_that_is_not_ours_is_not_staged`: a signature by another + key, then a wrong sha256, each looked at and neither staged. +- `update_installs_the_copy_the_host_staged_without_downloading_again`: the + release's file is deleted; `update` installs from the staged copy. +- `a_staged_update_that_was_tampered_with_is_dropped_not_applied`: a staged + file edited together with its recorded checksum fails the signature; the + next start drops it and runs the old program. + Unit tests in `update.rs`: `versions_are_three_numbers_and_order_by_number`, `a_package_marker_beside_the_program_makes_it_package_managed`, `a_manifest_address_is_resolved_beside_the_manifest`, @@ -3803,3 +3822,226 @@ covers the feature one; a case with a higher format number is a gap to add), `swap_in`'s temporary-then-rename (`update_is_atomic`), `healthy` (`failed_update_rolls_back`), `install_kind` (the marker test), and `parse_version`'s rejections. + +## P5: the agent surface (2026-10-29) + +Every tool result carries the envelope (`next_steps`, `guards`, `warnings`, +`head`), `books.start` is v2, the prompts and resource templates are +served, `books.journal_history` reads the journal in words, and a +convention, decision or open item can have its source corrected. Journal +feature `source_revisions`; events `convention.source_revised`, +`decision.source_revised`, `open_item.source_revised`; no schema change +(the projection updates the rows' existing source columns). + +- **`ledger-mcp` `src/server_tests.rs`:** + `every_mutating_result_has_head_and_next_steps` (every tool of the + router is given the envelope; the non-read-only ones have `head` equal to + the journal's and a step each with `why` and `required`; the text block + says what the structure says), + `the_envelope_keeps_what_a_tool_said_and_carries_a_refusal_guard`, + `books_start_is_always_loaded`, `tool_descriptions_fit_700_characters` + (the allowance list is gone: every description is at most 700 + characters). `guards.rs`: `every_guard_names_an_existing_tool`, + `a_refusal_becomes_its_guard`. `next_steps.rs`: + `a_preview_gets_its_confirm_step`, `every_step_a_tool_names_exists`. + `tools/prompts.rs`: `the_prompts_name_only_tools_that_exist`, + `an_argument_is_one_token_of_the_right_shape`. +- **`ledger-store`:** `tests/source_revisions.rs`: + `a_source_only_revision_is_journaled` (the three events, words and + revision unchanged, live equals replay equals a rebuilt database), + `a_source_revision_must_change_only_the_source` (other words, the same + source, no reason, unknown record: refused, journal unchanged), + `source_revisions_wait_for_the_upgrade` (refused before the go-ahead with + `format_upgrade_pending`, journaling nothing). `format.rs`: + `source_revisions_is_its_own_feature`. `describe.rs`: + `every_event_type_has_a_sentence`, `settings_show_their_keys_never_their_values`, + `a_full_tin_in_a_description_is_masked`, + `history_filters_in_order_and_says_when_more_remain`. +- **8.0.6:** `books_written_under_older_rules_still_open` hand-writes the + three events; the fixture `old-books/p5_before` (writer + `old-books-writer/src/bin/p5_before.rs`, run against b97234ac, the + revision before P5: a convention revised once, two decisions, two open + items, a draft) opens to that version's figures, so no figure moved; + `books_p2_served_cut_over_whole` refuses `books.raise_item` with `revises` + before consent and takes it after. +- **`ledger-cli` `tests/agent_surface.rs`:** + `journal_history_traces_what_the_books_did`, + `a_source_only_revision_over_the_wire`, `the_prompts_list_and_render` + (each prompt rendered equals its file with the blanks filled; an + argument that is not one token of the right shape, a missing or extra + one, and a struck prompt are refused), + `the_resource_templates_are_advertised_and_read`, + `books_start_v2_is_whole_and_fits` (at most 8 KiB on the P2 fixture; the + agent's self-report in the access log only), + `a_new_book_is_told_to_onboard`, + `a_mutating_call_answers_with_the_head_and_a_refusal_names_its_remedy`. +- **`ledger-cli` `tests/agent_evals.rs`** (transcripts in `tests/agent_evals/`): + `agent_evals_follow_only_next_steps` plays six scripted transcripts (no + model): onboarding to a first post, a receipt-backed post, a draft that + waits for its receipt and posts when it arrives, a reversal, a + reconciliation, a month-end close. After `books.start` a transcript may + call only a tool (or fetch only a prompt) the previous result's + `next_steps` named, with the arguments the step gave and the ones the + agent decides. `setup` calls (the chart, the books an agent finds) are + not the agent's moves. +- **The sweep:** the `party_id`, `document_id` and `cash_account` aliases of + `books.create_invoice`, `books.create_bill`, `books.receive_payment` and + `books.pay_bill` are gone (S4, S6 said "until P5"); the over-the-wire + tests that used them name `customer`/`vendor`, `invoice`/`bill` and + `deposit_to`/`paid_from`. + +**For P12 (mutation targets):** `next_steps::apply`'s three branches (an +error, a result without an object, the normal path) and the `mutating` +test that gives `head`; `tool_steps`'s confirm branch (`!confirmed` and a +`preview`), the `receipt_missing` pair before it, and each tool's arm; +`attention_steps`'s subject match (entry against invoice) and its +`Problem` severity for `required`; `subject_of`'s precedence; +`guards::from_refusal`'s structured and worded branches and +`Guard::remedy_tool`'s table; `prompts::checked`'s token test and each +`Check` shape (month length and calendar, year length, the word list); +`prompt_get`'s extra-argument and missing-argument refusals; +`describe::subjects`'s per-prefix id choice, `describe`'s `settings.set` +key list and TIN mask, `history`'s `>` on `after_seq`, its `limit` cut and +`more`; `check_convention_source`'s three equalities (title/body/structure, +source, kind), `check_decision_source` and `check_open_item_source`'s +equal-source refusals and the empty-reason refusals; the three +`apply_*_source` arms (a mutant that also bumps `revision` or `seq` shows +in `a_source_only_revision_is_journaled`); `books.start`'s fit order and +`reconciliation` split (`reconciled_through.is_some()`). +## P8: audit and attestations (2026-10-29) + +An audit run is a deterministic programme of checks over the books through +a day, filed as a JSON and a PDF workpaper; attestations are claims by a +named client that it checked a receipt, an entry or a period, weighed +against `policy.attestation_bar` when asked. Journal feature `audits`; +events `audit.run`, `receipt.attested`, `entry.reviewed`, `period.attested` +(no figure moves); schema 20 (taken as the next after the last, renumbered +at merge): the `audits` and `attestations` tables; settings +`policy.attestation_bar` (a new `Kind::AttestationBar`) and +`policy.cpa_package_requires_audit`. No kernel change, no Kani harness, no +Lean theorem: the audit calls kernel functions and adds none, and the laws +it checks are already theorems. + +- **`ledger-core` `tests/audit.rs`:** `audit_detects_each_seeded_defect` + (the code table `audit::CODES` has a row per check and the test iterates + it: a seeding fixture for each, exactly that code found, its severity the + row's; the checks a correct engine never lets happen are seeded through + the predicate each check is built on, `check_trial_balance`, + `check_equation`, `check_articulation`, `check_cash_flow`, + `check_control`, `check_split`, `check_register`), + `a_whole_book_gives_no_failure_or_warning`, + `findings_come_in_one_order`, `below_bar_not_counted` (the bar's effort, + the year-level adversarial check and verifier count, a lowered bar, the + parse), `amount_mismatch_never_counts`. +- **`ledger-store` `tests/audit.rs`:** `audit_writes_wait_for_audits` + (R-A: every write refused with `FormatUpgradePending`, nothing + journaled, taken after `upgrade_format`, replay equal to live), + `a_run_is_idempotent_on_the_figure_head` (a filing and a run do not move + the head, an entry does, asking again journals nothing), + `amount_mismatch_never_counts` (a wrong amount and a wrong date are + recorded as disagreeing; a receipt not on the entry, a draft and an + unknown entry are refused), `stale_head_period_attestation_refused` + (no audit, an audit that stops short of the period, a standing one, an + entry since), `integrity_detects_each_seeded_defect` (every code of + `ledger_store::audit::CODES`: a missing and a damaged stored file, a + missing extraction text, a duplicate file written by an older version, + a damaged access log, a database that differs from a replay, a damaged + journal, a stale backup, a failed drill), + `books_the_version_before_p8_wrote_wait_for_the_upgrade` (8.0.6 item 2). + `tests/store.rs::books_written_under_older_rules_still_open` hand-writes + the four events (8.0.6 item 1); `tests/format.rs` and + `format.rs::audits_is_its_own_feature` (8.0.6 item 4). The four event + names are now this version's, so the tests that need an event type no + version has written use `ledger.not_yet_written` instead of + `period.attested` (`tests/format.rs`, `ledger-gui/tests/newer_journal.rs`). +- **8.0.6:** the fixture `old-books/p8_before` (writer + `old-books-writer/src/bin/p8_before.rs`, run against b97234ac) holds + receipted entries, a 40.00 purchase with no receipt, a small one, a + reversal, an invoice part paid, an open draft and a closed month; + `books_an_earlier_version_wrote_open_to_its_figures` opens it to that + version's figures, so no figure moved and no golden changed; + `books_p2_served_cut_over_whole` refuses `books.audit` and + `books.review_entry` before consent and takes them after. +- **`ledger-import` `workpaper.rs`:** `workpaper_pdf_is_deterministic`, + `a_different_finding_is_a_different_document`. +- **`ledger-cli` `tests/audit.rs`:** `audit_is_deterministic` (preview, + run, the same run and findings again with nothing journaled, the + workpaper and its PDF hash equal twice, a new entry makes a new audit), + `attestations_count_against_the_bar_when_asked` (a disagreeing reading, + a medium effort, no claim, a lowered bar read retroactively), + `a_period_is_attested_on_a_standing_audit`, + `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given` + (refused, a reason journaled as a decision, an audit alone not enough, a + year attested below the whole bar not enough, the whole bar, an entry + since, `warn`), `audits_wait_for_the_upgrade` (the fixture over the + wire; the package is not held up by an audit it cannot ask for). +- **`ledger-cli` `tests/attention_parity.rs`:** + `audit_items_are_on_both_lists_once_audits_are_declared`. + +**For P12 (mutation targets):** `audit::statements`' order of checks and +`sort` (severity, then code, subject, message); each `check_*` predicate's +comparison (`check_trial_balance`'s `balanced`, `check_articulation`'s +`!=`, `check_split`'s `> unit`, `check_register`'s two conditions, +`check_cash_flow`'s three arms, `check_control`'s `difference`, unattached +and `Overpaid` arms); `evidence`'s three arms (Required, the reversal +exemption and the policy test that lists it, the plain exemption) and its +receipt-set lookup; `closed_net`'s sign for revenue and expense; +`AttestationBar::counts` (each of the four conditions, `>=`, the `full` +branch, the `disagrees` early return) and `parse`; `Scope::full_bar` and +`serves_handoff`; `Store::figure_head` (the `NOT_FIGURES` list: a type +wrongly in it lets a stale audit stand, a type wrongly out of it makes an +audit stale at its own filing), `find_audit_run`'s four equalities, +`record_audit_run`'s idempotence return, `attest_receipt`'s amount rule +(the debit total or any one line) and date equality, `run_for_period`'s +`through_seq == head` and `through >= period.end`; +`integrity_findings`'s eight arms; `backup_findings`' three conditions; the +export gate (`cpa_audit_standing`'s four conditions and its feature test, +the `refuse`/`warn` split, the reason's trim); `audit_items` in Needs +attention (the declared test, the attested set, the last-ended fiscal +year, the three year conditions). + +## P10: the re-audit runbook's mirror + +`docs/runbooks/re-audit.md` is the owner's acceptance, run on his books +through the MCP alone. What CI can check of it is in +`crates/ledger-cli/tests/defects.rs`: + +- `every_defect_reproduction_is_a_named_test`: a table of `(defect, test + name, file)` for the 2026-10-16 audit's defects 1 to 19, P0's 21 and 22 + and P3's 23 and 24 (23 rows); each named function exists in its file, is + a `#[test]` (or inside `proptest!`) and is not `#[ignore]`d. Defect 2 is + closed by D-12 and D-47, so its row is the rule as it now stands + (`a_reversal_needs_no_receipt_of_its_own`). +- `defect_20_no_migration_tool_exists`: defect 20 (the migration scripts) + was retired at the cutover; no tool of that kind is in the scopes table. +- `the_re_audit_runbook_names_only_what_exists`: every `books.` in + the runbook is in `crates/ledger-mcp/src/scopes.rs`, the commands it + names are dispatched by `main.rs`, and its eight steps are present. + +The mirror proves the tests exist, not that they pass; the runbook's step 4 +runs them by name. No CI job was added: the test runs in the existing +`check` job's `cargo test --workspace`. The run itself (a counted +attestation on every receipt and entry, the two CPA packages equal, the +sign-off) is the owner's and has no CI equivalent. + +**For P12 (mutation targets):** the `DEFECTS` table's file/name lookup (a +`starts_with` on `fn (`; the `#[test]`/`#[ignore]` window of eight +lines above it) and the scopes-line scan in `defect_20_*`. A mutant that +widens the window or drops the `ignore` check survives only if no ignored +or untested name is in the table; the table is the oracle. + +## P13 step 6: the specification in its own repository (2026-10-29) + +The specification (text, examples, checker, runner, Lean, GnuCash adapter) +lives at https://git.swaits.com/swaits/bookspec (CC BY 4.0 for `spec/` and `examples/`, MIT OR Apache-2.0 for the +code) and is pinned by commit in `crates/ledger-core/Cargo.toml` and +`crates/ledger-package/Cargo.toml`. `just check` runs the examples exactly as +before: `cargo test --workspace` includes `ledger-core` `tests/bookspec.rs` +(`every_bookspec_example_holds`, `the_runner_reports_every_claimed_rule_green`), +which reads them through `bookspec_checker::examples()`, and `ledger-package` +`tests/package.rs` reads the IIF example the same way. Two checks that need +this repository's files live in `tests/bookspec.rs`: `every_vendored_pin_is_verbatim` +(each vendored pin in the spec is verbatim in `docs/spec/sources`) and +`rules_md_carries_every_id`. The examples named `bookspec/examples/...` in the +older sections above are files of that repository. The checker's, the +runner's and the derivation tests run in its own CI. diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index 0f2c1e6..2869326 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -45,6 +45,8 @@ const TYPES: &[&str] = &[ "reconciliation.voided", "role.assigned", "statement.recorded", "statement.voided", "project.set", "entry.settles", "entry.draft_state_set", "entry.draft_discarded", + "convention.source_revised", "decision.source_revised", "open_item.source_revised", + "audit.run", "receipt.attested", "entry.reviewed", "period.attested", // Merged 2026-10-28 (S7, P6): every type EVENT_TYPES names. ]; @@ -67,6 +69,9 @@ const FIELDS: &[&str] = &[ "tax_basis_723", "depreciable_basis", "built_in_gain_704c", "on", "convention", "status", "party_id", "started_on", "ended_on", "links", "notes", "projects", "via", "by", "to", "reference", "note", "state", + "through", "through_seq", "scope", "program_version", "tb_hash", "findings_sha256", + "workpaper_attachment", "workpaper_pdf", "at", "amount_checked", "date_checked", + "disagrees", "audit_run_id", "period_id", "verifier_agents", "reasoning_effort", ]; /// Journal features a `journal.format_set` may declare. diff --git a/justfile b/justfile index a6d248e..f61ddc3 100644 --- a/justfile +++ b/justfile @@ -14,13 +14,9 @@ default: # Everything CI should check: format, clippy and rustdoc (zero warnings), tests check: sweep-test-tmp cargo fmt --all --check - cargo fmt --manifest-path bookspec/Cargo.toml --all --check cargo clippy --workspace --all-targets -- -D warnings - cargo clippy --manifest-path bookspec/Cargo.toml --workspace --all-targets -- -D warnings RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps --quiet - RUSTDOCFLAGS="-D warnings" cargo doc --manifest-path bookspec/Cargo.toml --workspace --no-deps --quiet cargo test --workspace - cargo test --manifest-path bookspec/Cargo.toml --workspace @just server-only @just sweep-test-tmp @@ -273,6 +269,11 @@ package-linux version="" formats="deb,appimage,pacman": cargo build --release -p ledger-cli deploy/packaging/build-linux.sh {{version}} {{formats}} +# Build the unsigned macOS .dmg (on a Mac; CI does it on a tag or dispatch) +package-macos version="": + cargo build --release -p ledger-cli + deploy/packaging/build-macos.sh {{version}} + # A release folder on this machine signed with a throwaway key; then # `wanderbooks update --feed file://DIR/manifest.json --trusted-key DIR/release.pub` # Make a local release feed to try `wanderbooks update` against diff --git a/plan/served.md b/plan/served.md index 3d7a8cb..8e5e7ee 100644 --- a/plan/served.md +++ b/plan/served.md @@ -678,7 +678,7 @@ The `WANDERBOOKS_TOML` template (`ledger-cli/src/main.rs:237-347`) becomes the e | P4 | `list_drafts`. Leftover drafts are discarded or set to `awaiting_evidence`. | landed 2026-10-29 (`b97234ac`; schema 19; `Store::post_entry` itself is not gated, the server's `post_entry_checked` is: see the As-built block) | P6 | • `consolidate_duplicates` resolves the 11 redundant records (preview first).
• Extraction is backfilled for all 66 attachments.
• Receipt numbers 1000–1029 are annotated from the "Receipt ID" in each description. Anomalies become annotations: 1009 unissued; 1011/1020 byte-identical pairs (merged); 43/45/47 have no ID. The next number is derived as 1030.
• Instruments: Quillmere, joint Amex, member cards, Chase Ink 2050.
~~• `verify-sheet.py`'s tables become a `mapping` convention, then `compare_reference` runs against attachment 37 as of 2026-10-11.
• `drive-manifest.json` text goes to `record_inventory`; the Drive connector listing goes to `check_inventory`.~~ → not built (8.4): the final `verify-sheet` run is a filed workpaper (P2 pre-cutover) and P10 cites it. | Every attachment's `get_receipt` default answer under 8 KiB; the 11 duplicates consolidated; receipt numbers annotated | | ~~P7~~ | ~~`wanderbooks vault init` … `keys/legacy.key` shredded~~ → D-48: nothing; the v1 key stays in the books and in every backup (D-31) | — | -| P10 | Re-audit (8.2 P10; `docs/runbooks/re-audit.md`). | the exit criteria there | +| P10 | Re-audit (8.2 P10; `docs/runbooks/re-audit.md`). | the engineering deliverable landed 2026-10-29 (`docs/runbooks/re-audit.md`, the defect regression table); the re-audit itself is the owner's run --- @@ -826,19 +826,19 @@ One row per unit of work. A unit is what one engineer takes into one git worktre | P4b S6 | Paying on terms: multi-document payments, discounts, the processor fee, bills due, aging exports | landed 2026-10-29 (`be580598`; no schema change; law 36); `confirmed` deferred to P4 | S4.1 (`Terms`), U0a | S5, S7, P6 | `documents_v2` | 8-10 | | P4b S7 | Bad debts (write-offs) | landed 2026-10-29 (`7ebeeed2`; law 37; `just prove` green) | S1; S4.3 for the same-call evidence attach; U0a | S4.4 onward, S5, S6 | `documents_v2` | 4-6 | | P4 | Draft states, the evidence gate in one place, the preview digest, the digest tool | not started | S4-S7 merged (one sweep over every confirm tool), U0b | P6, P9a, P11a, P13 | `drafts_v2` (same upgrade as `documents_v2`, D-52) | 8-10 | -| P5 | Agent surface complete: the result envelope, prompts, `books.start` v2, journal history, agent evals | in progress (wave 2, branch `P5`); merges after P8 | P4, U0b; the envelope step is single-owner and merges when no other MCP branch is open | P6, P9a, P11a, P13 | `source_revisions` | 10-12 | +| P5 | Agent surface complete: the result envelope, prompts, `books.start` v2, journal history, agent evals | landed 2026-10-29 (`d9d63b16`; no schema change; the viewer keeps its own describer, see the As-built block) | P4, U0b; the envelope step is single-owner and merges when no other MCP branch is open | P6, P9a, P11a, P13 | `source_revisions` | 10-12 | | P6 | Evidence pipeline: extraction, dedup, annotations, company record categories | landed 2026-10-29 (`9351f89d`, schema 18, with the integration fixes `b66fc893`, `0cbb49b6`, `fb83af59`, `6c26160e`, `bfd453b6`); the `ledger-store` → `ledger-import` dependency is Q-9 | U0a, U0b | S4-S7, P4, P5 (not its envelope step), P9a, P11a, P13 | `attachment_text` | 8-10 | | P7 | The TIN scanner's separators; vault v2 closed (D-48) | landed 2026-10-26 (`3e65c83f`) | none | everything | none | 1 | -| P8 | Audit and attestations | in progress (wave 2, branch `P8`) | P6 (extraction), P4 (drafts), U0b | P9a, P11a, P13 | `audits` | 10-12 | +| P8 | Audit and attestations | landed 2026-10-29 (schema 20; `policy.cpa_package_requires_audit` unset refuses once the books hold an audit run, see the As-built block) | P6 (extraction), P4 (drafts), U0b | P9a, P11a, P13 | `audits` | 10-12 | | P9a | Self-host recipe, `books adopt` move with the head check, static bearer over a tunnel, the two-user CI job | landed 2026-10-27 (`c8541ea9`); Done-when 1 verified 2026-10-29 (the two-user job green on `fb83af59`, after `79fb7ff2` gave the server account the fixture copies); Done-when 2 (the recipe on a clean VM) is the owner's | none (code); a second machine to try it on | everything | none | 3-4 | | P9b | claude.ai through an external identity provider (D-7 b, Q-6) | not planned until Q-6 is answered | P9a | | | | | P9c | Remote viewer replica, invitations | not planned (D-51) | | | | | | P10 | The re-audit runbook and its CI mirror; the owner runs the re-audit | not started | P5, P6, P8 | P11a, P13 | none | 1 (+ owner days) | -| P11a | Packaging and updates: step 0 the platform groundwork, the trial, `wanderbooks update`/`rollback`, Linux packages, signing, macOS and Windows bundles | in progress: step 0 landed 2026-10-29 (`5e220cca`) for the macOS keyring store and the portable CI job; **the keyring stores go away under D-63 (unit U0e)** and native Windows is its own unit P11c (D-62); steps 1-7 open, macOS and Linux first | none (code); certificates and keys from the owner for the signing steps; Q-4 before the manifest step | everything | none | 20-25 | +| P11a | Packaging and updates: step 0 the platform groundwork, the trial, `wanderbooks update`/`rollback`, Linux packages, signing, macOS and Windows bundles | in progress: steps 0-4 and the macOS half of step 6 landed 2026-10-29 (update/rollback, Linux packages, AppImage with restart, the daily staged update, the unsigned `.dmg` job); step 5 (signing) deferred by D-64; left: the in-app accept and the viewer's update notice (names needed), the release workflow's first run (owner's click), the trials on a Mac | none (code); certificates and keys from the owner for the signing steps; Q-4 before the manifest step | everything | none | 20-25 | | P11b | The agent dock and books homes | not started | P11a (an installed app to dock into) | P12 | none | 8-10 | | P11c | The native Windows port: named pipes with the SID check, owner-only DACLs, file locking, the `%LOCALAPPDATA%` data root, the Windows CI job made gating (D-62) | in progress (wave 2, branch `P11c`) | U0e; P11a step 0 (the portable job) | P11b, P12 | none | 5-8 | | P12 | Max testing, again | not started | everything else merged | | none | 2-3 (+ machine time) | -| P13 | bookspec: checker crate, `Engine` trait, rule ids, the spec text, the runner | in progress: steps 1-3 landed 2026-10-26 (`579d0eed`); steps 4-5 landed 2026-10-29 (`d839e709`; ten stated rules have no example yet because the schema has no event or query for them, listed in the As-built block); step 6 (the spec's own public repository) is the owner's call | none for steps 1-4; the engine stable for step 5 | everything | none | 15-20 | +| P13 | bookspec: checker crate, `Engine` trait, rule ids, the spec text, the runner | in progress: steps 1-5 landed (`579d0eed`, `d839e709`); step 6 (the spec's own public repository) decided 2026-10-29 (D-65): split out now | none for steps 1-4; the engine stable for step 5 | everything | none | 15-20 | #### 8.0.2 Dependency graph and merge order @@ -1515,7 +1515,15 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-mcp/src/guards.rs`, `tools/prompts.rs`, `prompts/*.md`, `crates/ledger-store/src/describe.rs`, `crates/ledger-cli/tests/agent_evals/`; touches `server.rs` (the result path), `tools/kept.rs`, `opening.rs`, `bridge.rs`, `handbook.rs`, the 8.0.3 files, `docs/mcp-interface.md`. 6. **Tests and proofs:** named above; no kernel or Lean change. 7. **Done when:** (1) every mutating result carries `head` and non-empty `next_steps`; (2) every guard's remedy tool exists; (3) `books.start` ≤ 8 KiB on the `p2-served` fixture and on the demo books; (4) the prompts list and render; (5) `tool_descriptions_fit_700_characters` pins 0; (6) the six evals pass; (7) `just check` green. The live check (a fresh Claude Code session in an empty folder, and a Codex session, each posting an entry by following `next_steps`) is the EM's, recorded in the As-built block. -8. **As built:** none yet. +8. **As built (2026-10-29, branch P5, eight commits on b97234ac; no schema change, so no migration number was taken):** + - Step 1 as written, with these shapes: `next_steps` items are `{ tool | prompt | command, args?, why, required }` (the `command` form carries `books.export_cpa_package`'s download command, which is not a tool); a result that already has `next_steps` or `head` keeps it (`books.start` has its own `next_steps` and `head`, `books.verify` and `books.access_log` their own `head`); the envelope adds no `warnings` of its own (a tool's own stay as they are, `books.cash_flow`'s object warnings included) and `guards` is on a refusal only; `head` and `next_steps` are on every tool that is not read-only (its annotation), `head` being the journal's head after the call; a read has `next_steps` only when something follows it, because a resource and its tool must say the same (`the_kept_resources_read` compares `books://settings` with `books.settings`), and an empty `warnings` on `books.add_account` broke `add_account_warns_without_tax_category_defect_8`. A refusal that is a guard carries `structuredContent.guards`; its text is unchanged. After a close the step is `books.verify`; **P8 replaces it with `attest_period` when it merges**. The guard registry has ten codes (the plan's seven examples plus `party_shares`, `party_required`, `open_drafts`); `untagged_control_line`, `duplicate_vendor_ref` and `first_number_locked` are in the registry and recognized from a refusal that begins with the code, but no tool refuses with them yet. The Needs-attention items are added after a change to an entry or an invoice only (not to a party or an account). `every_guard_names_an_existing_tool` is in `guards.rs`, `every_mutating_result_has_head_and_next_steps` in `server_tests.rs`. + - Step 2 as written, except: `head.last_handoff`, `rules.attestation_bar`, `rules.approval_rules` as a list and `attestation` are not in v2 (P8's); `you.verified`/`self_reported` and `isolation`'s shape were not added to `you` (the plan's `you` has four keys and has them). `agent` is stored on the access record as `agent {model?, reasoning_effort?, harness?}` (a new optional field of `ledger_store::access::Access`; the access log is its own chain, not the journal, so 8.0.6 does not apply). `you` is absent on the `books://start` resource (no token made that call). The fit loop shortens attention items to four and waiting drafts to three before the lists. + - Step 3 as written. The prompt tests are in `crates/ledger-cli/tests/agent_surface.rs` (`the_prompts_list_and_render`), not `crates/ledger-mcp/tests/prompts.rs`: the prompt renderer is `pub(crate)` and the p2-served harness is in ledger-cli; the "golden render" is each prompt's file with its blanks filled, compared exactly, rather than a stored copy. Prompts need `books:read`. The report resources now take their dates from the query; `books://entries/{id}` and `books://journal/{seq}` are new reads; `books://attachments/{id}` was already served. + - Step 4: `books.journal_history` as written, in `tools/journal.rs`, `subject` being the line's first subject. **Departure:** the describer in `crates/ledger-store/src/describe.rs (new)` is the server's; the viewer's `describe` arms in `ledger-gui/src/pages/journal.rs` were not moved (they carry icons, routes and today's party and account names; moving them behind a formatting trait was a large change to the viewer for no figure or behavior). So there are two describers; the server's quotes only identifiers, dates, amounts and short typed names and never a setting's value. Decide with the EM whether to unify them (U-unit). + - Step 5: parameter names are `reason` (on `books.set_convention`) and `revises` with `reason` (on `books.record_decision` and `books.raise_item`); `subject`, `decision_md` and `title` became optional in those two schemas so a source-only call can leave them out (given, they must equal the record's). Test names: `a_source_only_revision_is_journaled`, `source_revisions_wait_for_the_upgrade` (both in `crates/ledger-store/tests/source_revisions.rs`), plus `a_source_revision_must_change_only_the_source` and the over-the-wire `a_source_only_revision_over_the_wire`. 8.0.6: all six items (the `p5_before` fixture was written by b97234ac; no golden moved). + - Step 6: the eight descriptions were moved into the handbook sections `cpa-handoff`, `parties-and-tins`, `1099`, `files-in-out`, `reconciliation` and `viewer`; `tool_descriptions_fit_700_characters` pins none and the allowance list is gone. `instructions_budget` was already green with the final connect text (it already had no `preview_token`, reversal drafts or `request_tin_entry`). The aliases are removed (and `customer`/`vendor` are required); every over-the-wire test that used them was changed to the new names (`party_id_alias_still_works` became `a_customer_by_id_and_by_name_is_one_record`). `docs/mcp-interface.md`'s overview sections now describe the surface as built. + - Step 7: the six transcripts are as named. Each file may have `setup` (direct calls that make the chart and the books an agent finds; not the agent's moves), `settings`, and steps `call` (only `books.start`, first), `follow`, `follow_prompt`, `expect_next_steps_contains` and `expect`; `{"$upload": ..}` and `{"$var": ..}` stand for an uploaded file's id and a value a setup call saved. The runner is `agent_evals.rs`. + - Not run by the worker (D-60): `just check`, `just prove`; the live check of Done-when (7) (a fresh Claude Code session in an empty folder and a Codex session, each posting an entry by following `next_steps`) is the EM's. Checked: `cargo test -p ledger-mcp`, the store tests `source_revisions`, `format` and `store`, and the over-the-wire files this unit edited, with fmt and clippy on the five crates touched. **Struck from P5:** ~~`books.job` and the 20 s rule~~ → D-53 (no measured call approaches a client limit over the bridge; jobs come with P9b if claude.ai's 240 s limit is ever real); ~~URL-mode elicitation~~ → nothing local to elicit (D-28); ~~`ledger-view` crate~~ → U0b (`PackageBooks`); ~~`books.viewer_status`~~ → `books.show` already reports `viewer_open`. @@ -1581,7 +1589,19 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-core/src/audit.rs`, `crates/ledger-store/src/audit.rs`, `crates/ledger-mcp/src/tools/audit.rs`, `templates/workpaper.typ`, `crates/ledger-cli/tests/audit.rs`, `handbook/attestations-and-audit.md`; touches the 8.0.3 files, `tools/prompts.rs`, the CPA package builder. 6. **Tests and proofs:** `audit_is_deterministic`, `audit_detects_each_seeded_defect`, `amount_mismatch_never_counts`, `below_bar_not_counted`, `stale_head_period_attestation_refused`, `cpa_package_refuses_without_a_counted_audit_unless_a_reason_is_given`, `audits_wait_for_the_upgrade`, `workpaper_pdf_is_deterministic`; no kernel change (the audit calls kernel functions; it adds none); Lean: none (the laws it checks are already theorems). 7. **Done when:** (1) the code table has one row per check with its predicate, code, seeding fixture and severity, and `audit_detects_each_seeded_defect` iterates it; (2) a fixture with an unfooted cash flow, a missing receipt, an open draft and a duplicate blob produces exactly those findings; (3) the same head gives the same workpaper sha twice; (4) `export_cpa_package` refuses and the override is journaled; (5) 8.0.6 in the PR; (6) `just check` green. -8. **As built:** none yet. +8. **As built** (2026-10-29, branch `P8`; schema 20, taken at merge): every step built; departures: + - `ledger_core::audit::statements(ledger, input, through)` takes an `AuditInput` (the policy settings, the set of entries with a receipt, the cash flow's footing or why it could not be made, the settings problems, the undecided 1099 vendors) because a ledger cannot read attachments, the role settings or `ledger-config`; `settings_problems` stays in the server's helpers (it needs `ledger_config::Settings`) and the handler passes its strings. Predicates that a correct engine never lets happen (`check_trial_balance`, `check_equation`, `check_articulation`, `check_cash_flow`, `check_control`, `check_split`, `check_register`) are seeded directly; the rest are seeded through the ledger (`crates/ledger-core/tests/audit.rs`, not through `Store`: core cannot depend on the store). `close_split_drifts` tests each member's running total against its exact share within a minor unit, not by recomputing `kernel::apportion_cumulative`. `cash_flow_unfooted` also covers a statement the engine refuses. + - `legacy_reversal_exempt` did not exist as a name in code: it is the info finding for a reversal the policy would cover that has no receipt of its own. + - The integrity module is `crates/ledger-store/src/audit.rs`, a child module of `store` (it reads the store's private state). Backup standing is read by the server and handed in (`BackupStanding`, `backup_findings`); books with no backup target set up have no finding. Codes: `journal_damaged`, `projection_differs`, `blob_missing`, `blob_damaged`, `access_log_damaged`, `extraction_blob_missing`, `duplicate_blob`, `backup_stale`, `drill_failed`. + - The workpaper template is `crates/ledger-import/templates/workpaper.typ` with `crates/ledger-import/src/workpaper.rs` (that is where the other templates live), not `templates/workpaper.typ`. `audit.run` also carries `workpaper_pdf` (the JSON is `workpaper_attachment`). A run is idempotent on (through, figure head, scope, programme); `through_seq` is the figure head (the last line bearing on a figure: audits, attestations, evidence filings, decisions and the format declaration do not move it), not the journal head. + - `policy.attestation_bar` is one canonical text, `effort=high verifiers=2 adversarial=true`, not an object (the TOML codec has no table value); `Kind::AttestationBar`. `counted` is read when asked; a month needs the effort, a year-level run (annual, cpa_handoff, full) the whole bar. + - Scopes: `books.audit` previews at books:write and runs at books:post; the three attest tools are books:write; `books.workpaper` and `books.attestations` books:read. `books.attest_period` takes `period` as an id or a name. A receipt reading agrees when its amount is the entry's debit total or any one line, and its date the entry's. + - The CPA gate applies only on books that declared `audits`, so merging changes nothing on the live books before the go-ahead. `policy.cpa_package_requires_audit` unset means refuse once the books hold an audit run and warn before (not refuse from the start: every export of fresh books in ~30 existing tests would otherwise need a reason); the owner who wants it refused from the start sets `refuse`. `books.export_cpa_package` gained `without_audit_reason`. + - Needs attention: "Month-end attestation due", "Full audit due before the CPA handoff" and "Annual audit due", only for books that declared `audits`. The `audit` prompt (P5) gives the audit procedure; the attestation procedure is the handbook section `attestations-and-audit`. + - `period.attested` was the placeholder "unknown event" in `tests/format.rs` and `ledger-gui/tests/newer_journal.rs`; they use `ledger.not_yet_written` now. + - 8.0.6: `books_written_under_older_rules_still_open` hand-writes the four events; fixture `old-books/p8_before` (writer `p8_before.rs`, run against b97234ac); `audits_is_its_own_feature`; `books_p2_served_cut_over_whole` refuses `books.audit` and `books.review_entry` before consent. No figure moved, no golden changed. Merge does not need the owner's go-ahead; the live books refuse the new writes until he gives it. + - Fitted to P5 after it merged first: `next_steps.rs` (after `books.close_period` the steps are `books.audit` and `books.attest_period`; an audit's run leads to `books.workpaper`, the receipt and entry attestations and `books.attest_period`; the attest tools lead to `books.attestations` and `books.export_cpa_package`), the guard `audit_required` (remedy `books.audit`, also the refusal of a stale `books.attest_period`), the `audit` prompt names the audit tools, and the tool count is 140. + - No kernel, Kani or Lean change. **Struck from P8:** ~~bar versioning (`counts` derived against the bar in force at that seq; `bar_change_is_prospective`), the Lean counting lemma~~ → IZ-3d; ~~"vault public-key sha"~~ → D-48; ~~"access-log tooling"~~ → `books.access_log` exists (P2); ~~`books.audit` run as a job~~ → D-53. @@ -1619,7 +1639,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `docs/runbooks/re-audit.md`, `crates/ledger-cli/tests/defects.rs`. 6. **Tests and proofs:** the one test. 7. **Done when (engineering):** the runbook exists and names only tools and commands that exist; the test passes. **Exit (the owner's run):** zero failing workpaper checks; cash flow difference 0.00; every receipt and posted entry carries a counted attestation and none disagrees; the access log shows every action under the right principal and no transcript names the data root; no result carried a full TIN to a client that should not see one (D-29: the agent's exports may); agent context on receipts stayed under 100K tokens per the harness's own token count; the sign-off journaled. -8. **As built:** none yet. +8. **As built:** (2026-10-29, branch `P10`.) `docs/runbooks/re-audit.md`: eight steps (the audit and its fixes; receipts attested; entries reviewed and periods attested; the defect regressions; the Sheet comparison; the CPA package from both doors; verify, backup drill and sign-off; the exit table), T1 and T2, every tool named is in the scopes table. `crates/ledger-cli/tests/defects.rs`: `every_defect_reproduction_is_a_named_test` (23 rows, defects 1 to 19 and 21 to 24), plus two the unit did not list: `defect_20_no_migration_tool_exists` and `the_re_audit_runbook_names_only_what_exists` (Done-when "names only tools that exist" needed a check). Departures: (a) **`books.inherit_evidence` does not exist** (only the event type is gated in `format.rs`; no tool, no scope row); the runbook links the original's receipt to the old reversal with `books.link_attachment`, which journals `attachment.linked` and satisfies the evidence rule, and `legacy_reversal_exempt` is informational so the audit is clean either way. If the owner wants the D-12 annotation as such, a tool is a later unit. (b) Defect 2 (closed by D-12/D-47) maps to `a_reversal_needs_no_receipt_of_its_own`'s rule; defect 20 has no test of its own and gets the no-tool check. (c) The Sheet workpaper is found with `books.find_attachment { text }` because `workpaper` is not a `books.file_document` category (the audit files its own). (d) No CI job added: `check` runs the new test. **Owner's, not checked here:** the run itself (zero failing checks, counted attestations, equal CPA packages, backup drill, sign-off, transcripts free of the data root, token counts). #### P11a: packaging and updates (D-19, D-22, D-24, D-25, D-26) @@ -1638,7 +1658,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-host/src/update.rs`, `deploy/packaging/*`, `.github/workflows/release.yml`, `docs/packaging.md`; touches `commands.rs`, `crates/ledger-gui/src/owner.rs`, `mise.toml`. 6. **Tests and proofs:** named above. 7. **Done when:** (1) on a clean Linux VM the AppImage installs, opens, onboards and posts an entry with no terminal and no admin password, and updates itself from a test feed; (2) on Arch, Debian and Fedora the packages install and upgrade with the head unchanged; (3) an update leaves every book's head and goldens unchanged and a bridge session carries on across it; (4) a backup made on one system restores on another; (5) macOS and Windows: the same as (1), once the signing prerequisites exist (record which are done in the As-built block); (6) `just check` green. -8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `/updates//`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILD in Debian trixie, Fedora and Arch containers, hold books open as an ordinary user, upgrade to 0.1.1, host restarted onto the new file, journal head unchanged, `update` refuses: passes on all three. By hand: the AppImage updated itself and rolled back against a feed signed by `cargo packager signer` (`just local-release`). *Done-when so far:* (1) **partly**: the AppImage opens (bare launch) and updates itself from a test feed, but "onboards and posts an entry with no terminal" is false today (the viewer opens only an existing book and `setup`/`books create` need a terminal; not in these steps), and a held-open books' host does not restart onto an updated AppImage (it runs from the old image's mount; fix needs the host to learn the image's path, a trust decision left to the EM); (2) true on Debian, Fedora and Arch containers; (3) true for a hand-placed program (`bridge_survives_update`) and for packages (the container test), not yet for the AppImage's host; (4) not touched; (5) and (6) not done (macOS/Windows need machines and signing; `just check` is the EM's). *Not done, as instructed:* step 4 (daily check, staged update applied at restart), step 5 (signing, Q-4), step 6 (macOS and Windows bundles), step 7 (architecture note is written; the website and `docs/parity.md` are not touched, since nothing is published). +8. **As built:** (steps 0 to 3 as far as stated, 2026-10-29; steps 4 to 7 are later waves.) **Step 0, partly.** Built: the macOS login Keychain store (`apple-native-keyring-store` 1.0.2, feature `keychain`) and the Windows Credential Manager store (`windows-native-keyring-store` 1.1.0) behind `tokens.rs::Keeper`, on the same `Items` trait and the same laws as the Secret Service (`os` modules per OS; shared `refused`, `make_in`, `read_item`, `remove_item`); `NOT_YET` now only for other OSes; the ignored `os_keyring_keeps_and_forgets` compiles on all three; the `portable` job in `.github/workflows/check.yml` (`macos-latest` gating, `windows-latest` allowed to fail) builds `ledger-cli --no-default-features`, `cargo build --workspace --all-targets`, `cargo test --workspace` and the ignored keyring test. Checked here: Linux only (fmt, clippy, the tokens tests); no macOS/Windows target is installed, so both stores and the job are proven only by the first CI run. **Not built, and not a `cfg` gap: the Windows host.** Windows data root from the known-folder API with an owner-only DACL, named pipes with a SID check, `flock` and the Unix-only `host`, `start`, `bridge`, `opener` and `local`/`admin` sockets are a port of several crates (inventory in `docs/packaging.md`); macOS needs the CI run to show which Linux-only items (`seccomp` in `refuse.rs`, `renameat2`, `close_range`) need fences. So the step 0 Done-when ("setup ... gui work on each OS") is open for both. **Step 1 (the trial): not run in that wave**: it needs clean macOS, Windows and Linux machines; `mise.toml` now pins `cargo-packager` 0.11.8, `cargo-packager-updater` 0.2.3, `cargo-generate-rpm` 0.21.1 and `docs/packaging.md` holds the empty table and what is known. **Steps 1 (Linux half), 2 and 3 (2026-10-29, branch `P11a`).** *Pins:* the EM removed step 0's early pins; `mise.toml` now pins `cargo-packager` 0.11.8 and `cargo-generate-rpm` 0.21.1, which `just package-linux` calls; **`cargo-packager-updater` is a library, not a tool, so it is not pinned** (the plan's three-tool list was wrong on that); `update.rs` verifies with `minisign-verify` 0.3 (the crate the updater uses) and the tests sign with the `minisign` 0.10 dev-dependency. *Step 1:* `docs/packaging.md` has the Linux rows filled from real builds (47 MB .deb, 36 MB .rpm, 44 MB AppImage, the pacman tarball and PKGBUILD) and eight findings; the `.dmg` and NSIS rows stay "(trial)" (need a Mac and Windows; Windows is P11c). The findings that shaped the build: a binary linked on this machine (glibc 2.44) does not start on Debian 13, so packages are built on `ubuntu-24.04` in CI and in `rust:1.99-trixie` locally (`just release-trixie`); the books refuse to run under Docker's default seccomp filter (tests use `seccomp=unconfined`); an AppImage's launcher appends arguments to the desktop file's `Exec`, so the desktop files say plain `wanderbooks` and a bare launch with no terminal is the viewer (`main.rs::is_bare_launch`); the AppImage needs a square icon (**a placeholder PNG is committed; the owner should replace it**). *Step 2:* `crates/ledger-host/src/update.rs` (`update`, `rollback`) and the two CLI modes: a manifest (version; per platform `url`, `sha256`, `signature` = base64 of the minisign `.sig`, the shape cargo-packager signs in), accepted only if higher, sha256 equal AND signature valid against `WANDERBOOKS_RELEASE_PUBKEY` (compiled in; **no build carries one until step 5**, so today a build updates only from a `file://` feed with `--trusted-key`); stage under `/updates//`, keep the running version under `updates/previous/` with the journal format it knew, swap by renaming a complete copy made beside the target, `--help` health check with automatic put-back; `rollback` refuses when a book declares a format or feature the kept version lacks; a package marks itself with `share/wanderbooks/install-method` and then both refuse. The restart is the host's existing one (nothing new in `host.rs`). Tests: `crates/ledger-cli/tests/update.rs` (`update_is_atomic`, `failed_update_rolls_back`, `unsigned_update_refused`, `bridge_survives_update`, `update_waits_for_a_running_call`, `package_managed_install_refuses_update`, `rollback_refuses_after_a_book_consented_to_a_newer_format`, `an_update_replaces_the_program_keeps_the_old_one_and_rollback_brings_it_back`) all pass (8 in 12 s); five unit tests in `update.rs`. *Step 3:* `deploy/packaging/` (`packager.toml`, the rpm table in `crates/ledger-cli/Cargo.toml`, `aur/PKGBUILD`, `build-linux.sh`, `repo-indexes.sh`, `local-release.sh`, `test-linux-packages.sh`), the `just` recipes `package-linux`, `local-release`, `release-trixie`, `test-linux-packages`, and `.github/workflows/release.yml` (Linux packages on a `v*` tag, attached to the release, apt and dnf indexes pushed to `gh-pages`; untested, GitHub only). `linux_packages_install_and_upgrade` is `test-linux-packages.sh`: install 0.1.0 from an apt, a dnf repository and `makepkg` over the AUR PKGBUILD in Debian trixie, Fedora and Arch containers, hold books open as an ordinary user, upgrade to 0.1.1, host restarted onto the new file, journal head unchanged, `update` refuses: passes on all three. By hand: the AppImage updated itself and rolled back against a feed signed by `cargo packager signer` (`just local-release`). *Done-when so far:* (1) **partly**: the AppImage opens (bare launch) and updates itself from a test feed, but "onboards and posts an entry with no terminal" is false today (the viewer opens only an existing book and `setup`/`books create` need a terminal; not in these steps), and a held-open books' host does not restart onto an updated AppImage (it runs from the old image's mount; fix needs the host to learn the image's path, a trust decision left to the EM); (2) true on Debian, Fedora and Arch containers; (3) true for a hand-placed program (`bridge_survives_update`) and for packages (the container test), not yet for the AppImage's host; (4) not touched; (5) and (6) not done (macOS/Windows need machines and signing; `just check` is the EM's). *Not done, as instructed:* step 4 (daily check, staged update applied at restart), step 5 (signing, Q-4), step 6 (macOS and Windows bundles), step 7 (architecture note is written; the website and `docs/parity.md` are not touched, since nothing is published). **Step 3's AppImage gap, step 4 and the macOS half of step 6 (2026-10-29, branch `P11a`).** *AppImage restart (EM decision):* `start.rs` lets `APPIMAGE` into the host's environment only when it is an absolute file (`own_appimage`; read about the host itself, never told to an agent, I-2), `this_executable()` returns that image, and `host.rs` tells a replaced program by (dev, inode, size, mtime) instead of (dev, inode); test `a_replaced_appimage_restarts_the_books` (the programs run from one copy, `APPIMAGE` names another). Not run against a real FUSE mount. *Step 4:* `update.rs::stage_if_due` (the host's look, at most once per `update_check` knob, default 24 h, noted in `updates/checked`; from a thread of the host's loop; downloads, checks sha256 and signature, writes `/updates/staged/{wanderbooks,meta.json}`; never swaps), `apply_staged` (called by `start.rs::start_host` before it starts a host, so never during a call; re-checks the signature; a staged file that fails or does not start is dropped), `update` reuses a staged copy and `--check` says it is downloaded, `/updates/feed` names a local `file://` feed and its key (INVARIANT 8's rule), `set_program_version` because packages rewrite only the cli's version. Tests in `crates/ledger-cli/tests/update.rs`: `the_host_stages_a_daily_update_and_the_next_start_applies_it`, `a_daily_update_that_is_not_ours_is_not_staged`, `update_installs_the_copy_the_host_staged_without_downloading_again`, `a_staged_update_that_was_tampered_with_is_dropped_not_applied`; all 13 in the file pass; `ledger-host` lib tests pass (74). **Not built, needs a name or a decision:** "applied at the next restart the owner accepts in the app" and the `books.start`/viewer notice. The accept is a new lease/admin intent (`Intent` in `lease.rs`) and the notice a place in the viewer (P11b's files); neither name is allocated in 8.0.3, so the owner accepts today with `wanderbooks update`, and `update --check` and the host log say a version is waiting. *macOS bundle:* `deploy/packaging/{packager-macos.toml,Info.plist,build-macos.sh}`, `just package-macos`, a `macos` job in `release.yml` (`macos-latest`, Apple silicon, uploads the `.dmg` as run artifact `macos-dmg-unsigned`, attaches it on a tag), `docs/packaging.md` macOS rows "built in CI, unsigned; the trial on a Mac is the owner's". Checked on Linux only: the config parses under cargo-packager 0.11.8's strict schema; the `.dmg` itself is unverified until a CI run (the owner: Actions > release > Run workflow > branch `P11a`; the EM pushes the branch first). Not built: translocated-app refusal, "Check for updates" in the viewer's menu, the Intel build, the Windows installer (P11c), signing (step 5). Known issue for step 5: `update` replaces the program inside the `.app`, which breaks a signed bundle's seal; step 5 must update a whole `.app`. #### P11b: the agent dock and books homes (D-27) @@ -1667,7 +1687,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns the Windows arms of the files above; touches `docs/packaging.md` (the inventory rows are struck as each lands), `docs/architecture.md`, `docs/testing.md`, `README.md` (the Windows caveat goes). 6. **Tests and proofs:** the existing root, lock, lease and transport tests run on the Windows job (`cargo test --workspace` there); `a_second_user_cannot_open_the_pipe` (new, Windows-only, `crates/ledger-host`); no kernel, Kani or Lean change. 7. **Done when:** (1) the `portable (windows-latest)` job is green with `experimental: false`; (2) `wanderbooks setup`, `books create`, `connect claude`, a bridge session, an upload and `wanderbooks gui` work on Windows from a source build (the engineer or the owner on a Windows machine; recorded in the As-built block); (3) `docs/packaging.md`'s Unix-only inventory is empty. -8. **As built:** (2026-10-29, branch `P11c`; written on Linux, type-checked and linted for `x86_64-pc-windows-msvc` with the target pinned in `mise.toml`, **not run on Windows**: the `portable (windows-latest)` job's first run is the evidence for everything marked unverified here, and Done-when 2 needs a person on a Windows machine.) **Step 1, the data root:** `ledger-store/src/winsec.rs (new, Windows only)` holds the Windows FFI (dependency `windows-sys` 0.61, `cfg(windows)`): `set_owner_only` (a protected DACL of one allow entry for the account's SID, inherited by what a folder holds), `is_private` (owner is the account or Administrators; no allow entry for anyone but the account, SYSTEM or Administrators; a missing DACL is not private; any failure is "no"), `current_user`/`process_user`/`pid_is_us`, the shell's known folders (`profile_folder`, `local_app_data`), `rename_new` (`MoveFileExW` without the replace flag, write-through) and the pipe process-id calls. `root.rs`: `account_home` is the profile known folder; `platform_data_dir(home)` is the shell's `LocalAppData` for the account's own home and `home\AppData\Local` for any other (tests); `set_mode`, `check_private_dir` (now takes the path), `recorded_root` and `rename_new` have their Windows arms beside the Unix ones. **Step 2, locking:** `ledger-store/src/lock.rs (new)` is the one seam, on std's `try_lock`/`try_lock_shared` (flock; `LockFileEx`), replacing the only `libc::flock` (`ops.rs`); the plan's "LockFileEx behind lock.rs" is std's, so there is no second `unsafe` copy. **Step 3, the transport:** `ledger-mcp/src/ipc.rs (new)` is the seam for both families: `Endpoint`, `Listener`, `connect`, `connect_std` (a blocking client with a read limit; on Windows polled with `PeekNamedPipe`, since a pipe read cannot be cut short), `Peer` (`peer_is_us`, `peer_pid`), `stop_signals`, `split_accepted`/`split_connected`. A Windows pipe is `\\.\pipe\wanderbooks--`, made with an owner-only DACL, remote clients refused, `first_pipe_instance` once, and 16 idle instances kept waiting (each its own task, replaced on connect) so a client never finds all busy; clients open with `SECURITY_IDENTIFICATION`; both ends check the other's process token user against their own SID. `local.rs`, `admin.rs`, `host.rs`, `opener.rs`, `bridge.rs`, `commands.rs` use `ipc` (their `peer_uid`/`peer_pid`/`own_uid` moved into it); `ledger-host/src/channel.rs (new)` gives reqwest the Unix socket or `windows_named_pipe`. `lib.rs` of `ledger-host` and `ledger-mcp` and `main.rs` of `ledger-cli` lost every `cfg(unix)` gate and the "comes with P11" stubs. `start.rs`: the Unix start (`setsid`, `umask`, systemd-run, `XDG_RUNTIME_DIR`) is `cfg(unix)`; the Windows start is `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB` (without the last when the job refuses it) with a fixed environment (`host_env`); `host.rs` told a replaced program by (size, last write) on Windows; `close_range` and `seccomp` stay Linux-only (D-32). Also: `backup.rs` stops a quiet git with `taskkill /T` and finds Git for Windows; `tokens.rs` and the viewer's thumbnail folder take the DACL; `.gitattributes` pins LF (autocrlf would break the byte-for-byte golden and hash tests on the runner). **Tests:** see `docs/testing.md` "P11c": `ledger-store/tests/windows_root.rs`, `ledger-mcp/src/ipc.rs` (4 on every OS), `ledger-host/tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`, Windows variants in `start.rs` and `host.rs`; the whole over-the-wire suite of `ledger-cli` now compiles for Windows and runs there through `common::clean_env`/`private_folder`/`default_root`/`mcp_http`. **Checked here:** `cargo check` and `cargo clippy -D warnings` for `x86_64-pc-windows-msvc` over the workspace and all targets (C dependencies stubbed with a no-op compiler/archiver shim, which `check` allows); on Linux fmt, clippy on the crates touched and their tests. **Not verifiable here, so the first CI run decides:** everything that runs on `windows-latest`, in particular the pipe code, the DACL reads, `taskkill`, the host's detached start and the over-the-wire suite. **Answers I chose** (alternatives in the report to the EM): the Windows ancestors check is none (CHOICE); the bridge's scripted-server tests (`against_a_server`) stay `cfg(unix)`; vault and TIN-registry files rely on inheritance from the private folder they sit in. **Left open:** Done-when 2 (a person on Windows runs setup, create, connect, a bridge session, an upload, `gui`), and Done-when 1 until CI shows the job green. +8. **As built:** (2026-10-29, branch `P11c`; written on Linux, type-checked and linted for `x86_64-pc-windows-msvc` with the target pinned in `mise.toml`, **not run on Windows**: the `portable (windows-latest)` job's first run is the evidence for everything marked unverified here, and Done-when 2 needs a person on a Windows machine.) **Step 1, the data root:** `ledger-store/src/winsec.rs (new, Windows only)` holds the Windows FFI (dependency `windows-sys` 0.61, `cfg(windows)`): `set_owner_only` (a protected DACL of one allow entry for the account's SID, inherited by what a folder holds), `is_private` (owner is the account or Administrators; no allow entry for anyone but the account, SYSTEM or Administrators; a missing DACL is not private; any failure is "no"), `current_user`/`process_user`/`pid_is_us`, the shell's known folders (`profile_folder`, `local_app_data`), `rename_new` (`MoveFileExW` without the replace flag, write-through) and the pipe process-id calls. `root.rs`: `account_home` is the profile known folder; `platform_data_dir(home)` is the shell's `LocalAppData` for the account's own home and `home\AppData\Local` for any other (tests); `set_mode`, `check_private_dir` (now takes the path), `recorded_root` and `rename_new` have their Windows arms beside the Unix ones. **Step 2, locking:** `ledger-store/src/lock.rs (new)` is the one seam, on std's `try_lock`/`try_lock_shared` (flock; `LockFileEx`), replacing the only `libc::flock` (`ops.rs`); the plan's "LockFileEx behind lock.rs" is std's, so there is no second `unsafe` copy. **Step 3, the transport:** `ledger-mcp/src/ipc.rs (new)` is the seam for both families: `Endpoint`, `Listener`, `connect`, `connect_std` (a blocking client with a read limit; on Windows polled with `PeekNamedPipe`, since a pipe read cannot be cut short), `Peer` (`peer_is_us`, `peer_pid`), `stop_signals`, `split_accepted`/`split_connected`. A Windows pipe is `\\.\pipe\wanderbooks--`, made with an owner-only DACL, remote clients refused, `first_pipe_instance` once, and 16 idle instances kept waiting (each its own task, replaced on connect) so a client never finds all busy; clients open with `SECURITY_IDENTIFICATION`; both ends check the other's process token user against their own SID. `local.rs`, `admin.rs`, `host.rs`, `opener.rs`, `bridge.rs`, `commands.rs` use `ipc` (their `peer_uid`/`peer_pid`/`own_uid` moved into it); `ledger-host/src/channel.rs (new)` gives reqwest the Unix socket or `windows_named_pipe`. `lib.rs` of `ledger-host` and `ledger-mcp` and `main.rs` of `ledger-cli` lost every `cfg(unix)` gate and the "comes with P11" stubs. `start.rs`: the Unix start (`setsid`, `umask`, systemd-run, `XDG_RUNTIME_DIR`) is `cfg(unix)`; the Windows start is `DETACHED_PROCESS | CREATE_NEW_PROCESS_GROUP | CREATE_BREAKAWAY_FROM_JOB` (without the last when the job refuses it) with a fixed environment (`host_env`); `host.rs` told a replaced program by (size, last write) on Windows; `close_range` and `seccomp` stay Linux-only (D-32). Also: `backup.rs` stops a quiet git with `taskkill /T` and finds Git for Windows; `tokens.rs` and the viewer's thumbnail folder take the DACL; `.gitattributes` pins LF (autocrlf would break the byte-for-byte golden and hash tests on the runner). **Tests:** see `docs/testing.md` "P11c": `ledger-store/tests/windows_root.rs`, `ledger-mcp/src/ipc.rs` (4 on every OS), `ledger-host/tests/windows_pipe.rs::a_second_user_cannot_open_the_pipe`, Windows variants in `start.rs` and `host.rs`; the whole over-the-wire suite of `ledger-cli` now compiles for Windows and runs there through `common::clean_env`/`private_folder`/`default_root`/`mcp_http`. **Checked here:** `cargo check` and `cargo clippy -D warnings` for `x86_64-pc-windows-msvc` over the workspace and all targets (C dependencies stubbed with a no-op compiler/archiver shim, which `check` allows); on Linux fmt, clippy on the crates touched and their tests. **Not verifiable here, so the first CI run decides:** everything that runs on `windows-latest`, in particular the pipe code, the DACL reads, `taskkill`, the host's detached start and the over-the-wire suite. **Answers I chose** (alternatives in the report to the EM): the Windows ancestors check is none (CHOICE); the bridge's scripted-server tests (`against_a_server`) stay `cfg(unix)`; vault and TIN-registry files rely on inheritance from the private folder they sit in. **Second CI run (Windows):** `spec_vault` read every file under a book, lock files included; a held `LockFileEx` lock is mandatory, so the read failed (os error 33). The seam already locks dedicated files (`wanderbooks.lock`, `viewer.lock`, `server.lock`), never data files, so the lock-file approach was already in place; the tests now skip `*.lock` files (they hold no data). `update`/`rollback` and the host's update poll are `cfg(unix)` (P11a step 5 is Windows' updater). **Left open:** Done-when 2 (a person on Windows runs setup, create, connect, a bridge session, an upload, `gui`), and Done-when 1 until CI shows the job green. #### P12: max testing, again (D-33) @@ -1697,14 +1717,15 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `bookspec/**`; touches `crates/ledger-core/tests/bookspec.rs`, `crates/ledger-package/tests/package.rs`, `docs/spec/rules.md` (ids), `Cargo.toml` (workspace members if the checker joins the workspace). 6. **Tests and proofs:** `every_bookspec_example_holds` unchanged; `bookspec/checker` tests (the schema, the derivations); `bookspec/lean` builds with standard axioms only. 7. **Done when:** (1) the checker crate exists and `just check` still checks all 56+ examples; (2) the `Engine` trait and the wanderbooks adapter run the runner green for every claimed rule; (3) every B-rule has an id, statement, source and example; (4) the five laws are theorems in `bookspec/lean`; (5) the GnuCash adapter runs and its differences are settled from the sources (recorded in the As-built block). -8. **As built:** steps 1-3 landed 2026-10-26 (`579d0eed`); steps 4 and 5 built 2026-10-29 on branch `P13` (step 6 not started: it waits on the owner, see below). +8. **As built:** steps 1-3 landed 2026-10-26 (`579d0eed`); steps 4 and 5 built 2026-10-29 on branch `P13` and step 6 on 2026-10-29 (the repository of its own, below). - **Step 4, the text.** `bookspec/spec/` holds `00-model.md` (definitions, the model, the five laws, how an example is checked) and one file per area (`entry`, `ledger`, `statement`, `period`, `partner`, `asset`, `prepaid`, `revenue`, `arap`, `1099`, `format`), 667 lines of the 3,300 a 60-page edition allows. Every B-rule has a section `### BS-- (B-n)` with status, MUST/SHOULD/MAY statement, sources, symbolic form, examples and applicability; B-39, B-40, B-43, B-45 are reserved sections (allocated, not yet stated in `rules.md`); B-44, stated in `rules.md`, now carries its `bookspec:` line and CONVENTION status (catalogue and index updated). `checker/tests/spec.rs` fails the build on a missing section, a statement without a modal verb, a status that differs from the catalogue, examples that differ from the catalogue, a vendored pin that is not verbatim in its source (whitespace and curly quotes normalized), or more than 60 pages. - **The five laws** are Lean theorems `Bookspec.law_debits_equal_credits`, `law_accounting_equation`, `law_trial_balance`, `law_net_income_articulates`, `law_cash_flow_is_change_in_cash` in `bookspec/lean` (Basic, Entry, Statements, Closing, Ledger ported from `verify/lean` under the namespace `Bookspec`; `Laws.lean` states them; `Audit.lean` prints their axioms: `propext`, `Quot.sound`, `Classical.choice` only). `checker/tests/lean.rs` builds and audits when Lean is installed. - **New examples (8)**, each figure worked by hand in the file: `entry-an-unbalanced-entry-is-refused`, `entry-a-line-amount-is-positive`, `entry-cents-add-exactly`, `ledger-a-fraction-of-a-cent-is-refused`, `ledger-a-child-account-has-its-parents-type`, `ledger-a-mistake-is-reversed-never-erased`, `statement-the-books-balance-and-the-equation-holds`, `period-a-close-sweeps-the-year-into-capital` (66 in all). Rules now with an example: B-1 to B-12 and B-21, B-23 (`arap-hand-control-posting-refused` also shows it). New refusal words: `unbalanced`, `too_few_lines`, `negative_amount`, `zero_amount`, `finer_than_minor_unit`, `parent_type_mismatch`, `already_reversed`, `period_closed`; a refused entry may carry a non-positive amount. Finding while writing: `close ... to = "retained_earnings"` posts to the role account 3999 Retained Earnings, not to a setup capital account; the example says so. - **Step 5, the derivation.** `bookspec/checker/src/derive.rs` is an engine-free reference model (exact decimals) that replays entries, reversals, a close to retained earnings, bills and invoices, derives `trial_balance`, `balances`, `profit_and_loss` and `entry` figures plus the law checks (books net to zero, A = L + E + (R - X)) and checks each refusal against its rule; everything else is reported `Skipped`, never passed. `checker/tests/derive.rs`: no example disagrees; the eight law examples are derived in full; a figure moved by a cent and an unjustified refusal are both caught. - **The runner against wanderbooks** (`the_runner_reports_every_claimed_rule_green`) is green for every claimed rule, now including BS-entry-1 to -3, BS-ledger-1 to -6 and -8, BS-statement-1 and -2, BS-period-1 and -2. - **GnuCash, the second engine** (`bookspec/gnucash`, ignored tests; needs the `just oracle` image). It claims the rules whose examples ask only for balances. Passes BS-entry-3, BS-ledger-3, BS-statement-1, BS-statement-2. Three differences, settled from the sources and pinned by `gnucash/tests/gnucash.rs`: GnuCash books an unbalanced entry and parks the difference in `Imbalance-USD` (BS-entry-1 refuses it: Pacioli ch. 36, Pub. 583); it reads a credit of minus 100.00 as a debit (BS-entry-2 is a CONVENTION that removes the ambiguity); it rounds 10.005 to 10.01 silently (BS-ledger-1's CHOICE, found by the first cross-check, is not to). The examples stand in each case. GnuCash has no reversal link, so BS-ledger-5, -6 are not claimed by it. - - **Not done, and why.** (a) Done when 3 is met for the rules the schema can show; these stated rules still have no example because the schema has no event or query for them: BS-entry-4 (B-28), BS-ledger-7 (B-20), -9 (B-29), -12 (B-44), BS-asset-2 (B-22), -3 (B-24), BS-revenue-1 (B-17), BS-arap-1 (B-18), BS-arap-6 (B-42, whose allocated names `arap-settlement-exact` and `processor-fee-gross-presentation` wait on a payment/settlement event and an aging query), BS-format-1 (B-27); each section says why. B-39, B-40, B-43, B-45 are unstated in `rules.md`. (b) Step 6, the repository of its own, is the owner's call (a public repository, CC BY 4.0 for the text) and waits on step 5 being stable; nothing was pushed. (c) The derivation covers the ledger-core examples; depreciation, schedules, members, 1099s, formats and the cash-flow sections are skipped by the model and checked by the engines only. + - **Not done, and why.** (a) Done when 3 is met for the rules the schema can show; these stated rules still have no example because the schema has no event or query for them: BS-entry-4 (B-28), BS-ledger-7 (B-20), -9 (B-29), -12 (B-44), BS-asset-2 (B-22), -3 (B-24), BS-revenue-1 (B-17), BS-arap-1 (B-18), BS-arap-6 (B-42, whose allocated names `arap-settlement-exact` and `processor-fee-gross-presentation` wait on a payment/settlement event and an aging query), BS-format-1 (B-27); each section says why. B-39, B-40, B-43, B-45 are unstated in `rules.md`. (b) Step 6 is done, see the next bullet. (c) The derivation covers the ledger-core examples; depreciation, schedules, members, 1099s, formats and the cash-flow sections are skipped by the model and checked by the engines only. + - **Step 6, the repository of its own (D-34, D-65 as amended: Gitea only).** `https://git.swaits.com/swaits/bookspec` is the public home (no GitHub repository, none planned; GitHub's token could not create one and the owner chose Gitea). `git subtree split --prefix=bookspec` kept the history of `bookspec/` (539 commits). The new tree adds `LICENSE-MIT`, `LICENSE-APACHE` (the code), `LICENSE-CC-BY-4.0` (`spec/` and `examples/`), a README licence table, `.github/workflows/check.yml` (fmt, clippy, rustdoc, `cargo test --workspace`; the two Docker-gated GnuCash tests stay ignored) and `repository` metadata. `bookspec_checker::examples()` and `spec_files()` (`include_dir` over the crate's `../examples` and `../spec`) carry the files, so no consumer needs a path. wanderbooks pins `bookspec-checker` and `bookspec-runner` as git dev-dependencies of `ledger-core` (and the checker of `ledger-package`) at `rev = 74f2853d5f0db0066147c17fed8033cfe3d83a24`; `bookspec/` is deleted here, the four `just check` lines for it are gone, and `just check` still runs every example through `cargo test -p ledger-core --test bookspec`. The two checks that read this repository moved here: `every_vendored_pin_is_verbatim` (the pins against `docs/spec/sources`) and `rules_md_carries_every_id` (both in `crates/ledger-core/tests/bookspec.rs`); bookspec keeps the citations' form check. `bookspec/lean` and the GnuCash adapter went with the repository. Raising the pin is a one-line `rev` change in two `Cargo.toml` files. ### 8.3 P4b reference design: receivables and payables, first class (D-45) @@ -2162,6 +2183,8 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-61 | Q-9 answered (owner, 2026-10-29, the question tool: "Split the reader out later") | **The reader (`extract.rs`) moves into a small library crate of its own, `ledger-reader`, that `ledger-store` and `ledger-import` both depend on; a half-day unit (U0d) for the implementing team after S4.5.** Until then the arrow stands as built; `ledger-import` never depends on `ledger-store`. | U0d; `docs/architecture.md` | | D-62 | Native Windows (owner, 2026-10-29, the question tool, after P11a step 0 found the Windows side is a port, not cfg fixes: "macOS first, Windows port as its own unit") | **The first bundled app ships for macOS and Linux; the native Windows port is unit P11c**, built after U0e beside P11b; Windows stays WSL2-only meanwhile and the `portable (windows-latest)` job is allowed to fail until P11c lands. D-56's destination is unchanged, only its order. | P11a, P11b, P11c | | D-63 | Where agents' tokens live (owner, 2026-10-29, the question tool, on seeing P11a step 0 add Keychain and Credential Manager stores: "why are you talking about the OS keyring? I thought we made a decision to not use that garbage"; the plan had no such decision recorded, so this one records it) | **A 0600 file under the 0700 data root, on every OS; no OS keyring.** The data root is the trust boundary already (I-1: hidden, not locked, same user), so the keyring added no wall and cost headless servers a D-Bus session, CI a knob, and every OS its own store. Unit U0e removes the Secret Service, Keychain and Credential Manager stores and the `file_keyring` knob; 3.3's sentence is struck. | U0e, P9a's recipe, P11a, P11c | +| D-64 | Q-4 answered (owner, 2026-10-29, the question tool: "Defer: ship unsigned builds to yourself first") | **P11a step 5 (the signed release list, the key ceremony, Apple and Microsoft code signing) is deferred until the first public release is near.** CI builds unsigned artifacts for the owner's own machines; `wanderbooks update` works from a local feed meanwhile (no compiled-in key). The two-key design of D-25 is unchanged, only its date. | P11a step 5, `release.yml` | +| D-65 | P13 step 6 (owner, 2026-10-29, the question tool: "Split it out now") | **The bookspec specification gets a public repository of its own now**, with this repository depending on it; the ten stated rules without examples and the three reserved numbers are published as such. | P13 step 6 | ### 10.2 Deferred @@ -2172,7 +2195,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form **Open (2026-10-29): Q-4, Q-6, Q-7, each when its unit starts.** One at a time, in plain English; none blocks the units before it. Q-5, Q-8 and Q-9 were answered (D-57, D-58, D-61) and are kept below for the record. Q-5 and Q-8 were answered on 2026-10-28 (D-57, D-58) and are kept below for the record. -**Q-4 (before P11a step 5).** Releases: should the app check updates against our own signed release list as well (two keys, one on your hardware token and one offline, a version that only goes up, an expiry), as decided on 2026-10-18 (D-25), or is the packaging tool's own single-key signature plus Apple's and Microsoft's code signing enough? The second is simpler and already refuses an older version; the first also stops a frozen or replayed update list. Recommended: the first, as decided, since the key work is small once the token exists. +**Q-4 (closed 2026-10-29 → D-64: deferred; unsigned builds first).** Releases: should the app check updates against our own signed release list as well (two keys, one on your hardware token and one offline, a version that only goes up, an expiry), as decided on 2026-10-18 (D-25), or is the packaging tool's own single-key signature plus Apple's and Microsoft's code signing enough? The second is simpler and already refuses an older version; the first also stops a frozen or replayed update list. Recommended: the first, as decided, since the key work is small once the token exists. **Q-5 (closed 2026-10-28 → D-57: once, after everything).** The receivables and payables work needs one more go-ahead to switch your books to the new format (numbers, terms, line accounts, sent records, discounts, write-offs). Should we ask once, after everything is built (write-offs and discounts included), or earlier, after invoice numbers and terms land, with a second ask later? Recommended: once, after everything, unless you need numbered invoices sooner.