From 5bc0633cdd5f09bcf77f26412a10c09c13a5ef0f Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 19 Oct 2026 17:15:48 -0600 Subject: [PATCH] P2 slice 6: the viewer `wanderbooks gui [BOOK]` finds the data root, reads settings from the journal, the vault of the book, presence through ops/, Save a copy / Open on every document, Save the W-9, the Export menu, and colour schemes. One builder for the CPA package and the 1099 worksheet (ledger-package), shared by the server and the viewer. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj --- Cargo.lock | 18 + Cargo.toml | 2 +- crates/ledger-cli/src/main.rs | 55 +- crates/ledger-cli/tests/local_server.rs | 240 ++- crates/ledger-cli/tests/one_executable.rs | 4 +- crates/ledger-cli/tests/open_while_held.rs | 17 + crates/ledger-gui/Cargo.toml | 3 + crates/ledger-gui/src/app.rs | 14 +- crates/ledger-gui/src/attention.rs | 1 - crates/ledger-gui/src/books.rs | 69 +- crates/ledger-gui/src/data.rs | 162 +- crates/ledger-gui/src/demo.rs | 18 +- crates/ledger-gui/src/export.rs | 45 +- crates/ledger-gui/src/lib.rs | 87 +- crates/ledger-gui/src/menu.rs | 214 +++ crates/ledger-gui/src/pages/company.rs | 17 +- crates/ledger-gui/src/pages/documents.rs | 40 + crates/ledger-gui/src/pages/parties.rs | 31 + crates/ledger-gui/src/pages/reports.rs | 2 - crates/ledger-gui/src/schemes.rs | 1 - crates/ledger-gui/src/test_support.rs | 2 - crates/ledger-gui/tests/assets.rs | 3 +- crates/ledger-gui/tests/attachments.rs | 3 +- crates/ledger-gui/tests/breadcrumbs.rs | 1 - crates/ledger-gui/tests/copy.rs | 3 +- crates/ledger-gui/tests/edges.rs | 1 - crates/ledger-gui/tests/exports.rs | 490 ++++++ crates/ledger-gui/tests/kept.rs | 1 - crates/ledger-gui/tests/live.rs | 75 +- crates/ledger-gui/tests/newer_journal.rs | 1 - crates/ledger-gui/tests/old_books.rs | 107 +- crates/ledger-gui/tests/parties.rs | 84 +- crates/ledger-gui/tests/read_only.rs | 1 - crates/ledger-gui/tests/schemes.rs | 1 - crates/ledger-gui/tests/screens.rs | 3 +- crates/ledger-gui/tests/show.rs | 3 +- crates/ledger-gui/tests/spec_gui.rs | 54 +- crates/ledger-gui/tests/tin_copy.rs | 1 - crates/ledger-gui/tests/vault_key_flag.rs | 1 - crates/ledger-gui/tests/view_model.rs | 42 +- crates/ledger-host/src/commands.rs | 173 +- crates/ledger-mcp/Cargo.toml | 6 +- crates/ledger-mcp/handbook/1099.md | 2 +- crates/ledger-mcp/handbook/cpa-handoff.md | 4 +- crates/ledger-mcp/handbook/files-in-out.md | 2 +- .../ledger-mcp/handbook/parties-and-tins.md | 2 +- crates/ledger-mcp/src/lib.rs | 6 + crates/ledger-mcp/src/local.rs | 172 +- crates/ledger-mcp/src/server.rs | 1388 +++-------------- crates/ledger-mcp/src/tools/files.rs | 114 ++ crates/ledger-mcp/src/types.rs | 79 +- crates/ledger-mcp/tests/mcp_stdio.rs | 144 +- crates/ledger-mcp/tests/oracle_mcp.rs | 39 +- crates/ledger-mcp/tests/spec_attachments.rs | 20 +- crates/ledger-mcp/tests/spec_mcp.rs | 21 +- crates/ledger-mcp/tests/spec_vault.rs | 458 +++--- crates/ledger-mcp/tests/tin_files.rs | 40 +- crates/ledger-package/Cargo.toml | 22 + crates/ledger-package/src/books.rs | 87 ++ crates/ledger-package/src/iif.rs | 53 + crates/ledger-package/src/lib.rs | 46 + crates/ledger-package/src/package.rs | 615 ++++++++ crates/ledger-package/src/reports.rs | 281 ++++ crates/ledger-package/src/text.rs | 153 ++ crates/ledger-package/src/tins.rs | 100 ++ crates/ledger-package/src/vendor_1099.rs | 250 +++ crates/ledger-package/tests/package.rs | 799 ++++++++++ crates/ledger-store/src/exports.rs | 373 +++++ crates/ledger-store/src/lib.rs | 4 +- crates/ledger-store/src/ops.rs | 307 ++++ crates/ledger-store/src/saving.rs | 164 ++ crates/ledger-store/src/viewer.rs | 232 --- docs/mcp-interface.md | 126 +- docs/parties-and-attachments.md | 22 +- docs/spec/rules.md | 16 +- docs/testing.md | 10 +- plan/served.md | 13 + 77 files changed, 5833 insertions(+), 2427 deletions(-) create mode 100644 crates/ledger-gui/src/menu.rs create mode 100644 crates/ledger-gui/tests/exports.rs create mode 100644 crates/ledger-package/Cargo.toml create mode 100644 crates/ledger-package/src/books.rs create mode 100644 crates/ledger-package/src/iif.rs create mode 100644 crates/ledger-package/src/lib.rs create mode 100644 crates/ledger-package/src/package.rs create mode 100644 crates/ledger-package/src/reports.rs create mode 100644 crates/ledger-package/src/text.rs create mode 100644 crates/ledger-package/src/tins.rs create mode 100644 crates/ledger-package/src/vendor_1099.rs create mode 100644 crates/ledger-package/tests/package.rs create mode 100644 crates/ledger-store/src/exports.rs create mode 100644 crates/ledger-store/src/ops.rs create mode 100644 crates/ledger-store/src/saving.rs delete mode 100644 crates/ledger-store/src/viewer.rs diff --git a/Cargo.lock b/Cargo.lock index b1a793c..4f74ee6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3887,6 +3887,7 @@ dependencies = [ "ledger-config", "ledger-core", "ledger-import", + "ledger-package", "ledger-store", "ledger-vault", "notify", @@ -3894,6 +3895,8 @@ dependencies = [ "rust_decimal", "serde", "serde_json", + "tempfile", + "zip 9.0.2", ] [[package]] @@ -3952,6 +3955,7 @@ dependencies = [ "ledger-config", "ledger-core", "ledger-import", + "ledger-package", "ledger-store", "ledger-vault", "libc", @@ -3974,6 +3978,20 @@ dependencies = [ "zip 9.0.2", ] +[[package]] +name = "ledger-package" +version = "0.1.0" +dependencies = [ + "chrono", + "ledger-core", + "ledger-import", + "ledger-store", + "ledger-vault", + "rust_decimal", + "tempfile", + "zip 9.0.2", +] + [[package]] name = "ledger-store" version = "0.1.0" diff --git a/Cargo.toml b/Cargo.toml index 1798317..df11c86 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault", "crates/ledger-host"] +members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault", "crates/ledger-host", "crates/ledger-package"] [workspace.package] edition = "2024" diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 6d8cf84..28b1fb3 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -3,7 +3,7 @@ //! //! ```text //! wanderbooks new [DIR] [--codex] [--force] [--no-start] (alias: init) -//! wanderbooks gui [DIR | viewer flags] +//! wanderbooks gui [BOOK | viewer flags] //! wanderbooks dev-stdio [server flags] //! wanderbooks setup [--root PATH] [--home DIR] //! ``` @@ -75,14 +75,13 @@ gui: set -u if just gui-status &>/dev/null; then echo "The viewer is already open."; exit 0; fi log="{{justfile_directory()}}/books-data/gui.log" - nohup "@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" \ - --books-toml "{{justfile_directory()}}/wanderbooks.toml" >"$log" 2>&1 "$log" 2>&1 /dev/null; then echo "Viewer open (pid $!)."; else echo "The viewer didn't start:"; cat "$log"; exit 1; fi # Open the viewer in the foreground (logs in the terminal; Ctrl-C closes it) gui-fg: - "@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" --books-toml "{{justfile_directory()}}/wanderbooks.toml" + "@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" # Is the viewer open for these books? Prints open/closed; exits 0 only if open gui-status: @@ -385,13 +384,16 @@ books-data/wanderbooks.lock exports/ # The background viewer's log (`just gui`). books-data/gui.log +# The viewer's side channel (books.show's requests, its presence): not the +# books, never journaled. +books-data/ops/ "#; const START_PROMPT: &str = "Hi! These are brand-new books. Please set them up with me."; fn print_usage() { eprintln!("usage: wanderbooks new [DIR] [--codex] [--force] [--no-start]"); - eprintln!(" wanderbooks gui [DIR | --data-dir PATH ...]"); + eprintln!(" wanderbooks gui [BOOK | --root PATH --book ID | --data-dir PATH ...]"); eprintln!(" wanderbooks dev-stdio [--data-dir PATH ...]"); eprintln!(" wanderbooks setup [--root PATH] [--home DIR]"); eprintln!(" wanderbooks books list | create ID | adopt DATA-DIR --id ID"); @@ -405,7 +407,7 @@ fn print_usage() { eprintln!(" wanderbooks serve | close | doctor"); eprintln!(); eprintln!(" new make a books directory and start the agent in it (alias: init)"); - eprintln!(" gui open the live, read-only view of a books directory"); + eprintln!(" gui open the live, read-only view of the books (a book of the data root)"); eprintln!(" dev-stdio run the books server over stdio (`dev-stdio --help` for its flags)"); eprintln!(" setup make the books' private folder (only your account can open it)"); eprintln!(" books list, start or adopt books (`books --help`)"); @@ -772,24 +774,14 @@ fn new(dir: &Path, force: bool, start: bool, agent: Agent) -> Result<(), String> Ok(()) } -/// The viewer's arguments for `wanderbooks gui ARGS`: a lone books -/// directory (none means this one) becomes its `books-data/` and -/// `wanderbooks.toml`, named explicitly as before the viewer moved into -/// this executable, so a settings file made later is still followed; -/// anything else is the viewer's own flags, passed as they are. +/// The viewer's arguments for `wanderbooks gui ARGS` (plan 3.6): a lone +/// word names the book of the data root to show (`--book`), never a +/// folder; nothing, the root's book; anything else is the viewer's own +/// flags, passed as they are. #[cfg(feature = "gui")] fn gui_args(args: Vec) -> Vec { match args.as_slice() { - [] => gui_args(vec![".".into()]), - [dir] if !dir.starts_with('-') => { - let dir = Path::new(dir); - vec![ - "--data-dir".into(), - dir.join("books-data").display().to_string(), - "--books-toml".into(), - dir.join("wanderbooks.toml").display().to_string(), - ] - } + [book] if !book.starts_with('-') => vec!["--book".into(), book.clone()], _ => args, } } @@ -1131,23 +1123,10 @@ mod gui_args_tests { } #[test] - fn a_lone_books_dir_names_its_data_and_settings() { - let dir = std::path::Path::new("some/books"); - assert_eq!( - gui_args(v(&["some/books"])), - v(&[ - "--data-dir", - &dir.join("books-data").display().to_string(), - "--books-toml", - &dir.join("wanderbooks.toml").display().to_string(), - ]) - ); - } - - #[test] - fn no_dir_means_this_one_named_explicitly() { - assert_eq!(gui_args(v(&[])), gui_args(v(&["."]))); - assert_eq!(gui_args(v(&[]))[0], "--data-dir"); + fn a_lone_word_names_a_book_never_a_folder() { + assert_eq!(gui_args(v(&["acme"])), v(&["--book", "acme"])); + assert_eq!(gui_args(v(&["."])), v(&["--book", "."])); + assert_eq!(gui_args(v(&[])), v(&[])); } #[test] diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs index c536926..8e9f7bb 100644 --- a/crates/ledger-cli/tests/local_server.rs +++ b/crates/ledger-cli/tests/local_server.rs @@ -703,17 +703,20 @@ async fn a_book_in_the_root_keeps_its_vault_and_key_inside_its_own_folder() { std::fs::read_dir(book.join("vault")).unwrap().count() > 0, "the TIN wasn't sealed into the book's vault" ); - // A file with TINs is never written into the root. - let err = call( + // A file with TINs is handed over, never written loose in the root. + let w = call( &c, "books.vendor_1099", - json!({"year": 2026, "include_tins": true, - "save_csv_to": home.root().join("x.csv").display().to_string()}), + json!({"year": 2026, "include_tins": true, "csv": true}), ) .await - .unwrap_err(); - home.assert_hidden(&err); - assert!(!home.root().join("x.csv").exists()); + .unwrap(); + home.assert_hidden(&w.to_string()); + assert!( + w["csv"]["download"].as_str().unwrap().contains(" ex_"), + "{w}" + ); + assert!(w["csv"].get("content_base64").is_none(), "{w}"); drop(c); assert!(server.close().success()); } @@ -960,32 +963,33 @@ async fn nothing_is_written_in_the_root_and_the_vault_stays_private() { home.create("acme", "USD"); let c = connect(&home, "acme").await.unwrap(); let book = home.root().join("books/acme"); - let err = call( + call( &c, - "books.export_cpa_package", - json!({"as_of": "2026-10-18", "include_tins": false, - "save_to": book.display().to_string()}), + "books.add_account", + json!({"code": "1000", "name": "Cash", "account_type": "asset"}), ) .await - .unwrap_err(); - home.assert_hidden(&err); + .unwrap(); + let got = call( + &c, + "books.export_cpa_package", + json!({"as_of": "2026-10-18", "include_tins": false, "cash_accounts": ["1000"]}), + ) + .await + .unwrap(); + home.assert_hidden(&got.to_string()); let zips: Vec<_> = std::fs::read_dir(&book) .unwrap() .filter_map(|e| e.ok()) .filter(|e| e.file_name().to_string_lossy().ends_with(".zip")) .collect(); assert!(zips.is_empty(), "a package was written into the root"); - // A TIN file written outside first: the vault folder it records into - // is made private, and storing a TIN then works. - let out = tempfile::Builder::new() - .prefix("wb-srv-out-") - .tempdir() - .unwrap(); + // A TIN file made first: the vault folder it records into is made + // private, and storing a TIN then works. let _ = call( &c, "books.vendor_1099", - json!({"year": 2026, "include_tins": true, - "save_csv_to": out.path().join("1099.csv").display().to_string()}), + json!({"year": 2026, "include_tins": true, "csv": true}), ) .await; if book.join("vault").exists() { @@ -2473,16 +2477,11 @@ async fn a_w9_and_a_file_holding_a_tin_go_by_upload_only_where_they_belong() { .await .unwrap_err(); assert!(e.contains("no upload"), "{e}"); - let out = home.dir.path().join("payee copy.pdf"); - let got = call( - &c, - "books.get_w9", - json!({"party": "Payee", "save_to": out.to_str().unwrap()}), - ) - .await - .unwrap(); - assert_eq!(got["file_name"], json!("payee-w9.pdf")); - assert_eq!(std::fs::read(&out).unwrap(), w9); + let got = call(&c, "books.get_w9", json!({"party": "Payee"})) + .await + .unwrap(); + assert_eq!(got["file"]["file_name"], json!("payee-w9.pdf")); + assert_eq!(fetch_export(&home, &token, &got["file"]).await, w9); // The W-9's upload is gone; the refused file's waits to lapse. let left: Vec = std::fs::read_dir(home.book_dir("acme").join("uploads")) .unwrap() @@ -2510,9 +2509,28 @@ async fn lapsed_uploads_are_swept_while_the_server_runs() { let mut grant: Value = serde_json::from_slice(&std::fs::read(&json).unwrap()).unwrap(); grant["expires"] = json!(0); std::fs::write(&json, grant.to_string()).unwrap(); + // An export too (a file the books made), aged the same way. + let made = call(&c, "books.vendor_1099", json!({"year": 2026, "csv": true})) + .await + .unwrap(); + assert!(made["csv"]["download"].as_str().unwrap().contains(" ex_")); + let exports = home.book_dir("acme").join("exports"); + for e in std::fs::read_dir(&exports).unwrap() { + let p = e.unwrap().path(); + if p.extension().is_some_and(|x| x == "json") { + let mut x: Value = serde_json::from_slice(&std::fs::read(&p).unwrap()).unwrap(); + x["expires"] = json!(0); + std::fs::write(&p, x.to_string()).unwrap(); + } + } let deadline = Instant::now() + Duration::from_secs(25); - while std::fs::read_dir(&folder).unwrap().count() > 0 { - assert!(Instant::now() < deadline, "the lapsed upload wasn't swept"); + while std::fs::read_dir(&folder).unwrap().count() > 0 + || std::fs::read_dir(&exports).unwrap().count() > 0 + { + assert!( + Instant::now() < deadline, + "a lapsed upload or export wasn't swept" + ); tokio::time::sleep(Duration::from_millis(200)).await; } drop(c); @@ -2547,3 +2565,159 @@ async fn an_mcp_request_is_at_most_a_mebibyte() { drop(http); assert!(server.close().success()); } + +/// Fetch the export a tool handed over in `file` (its `download` +/// command's id) with `token`: its bytes, checked against its sha256. +async fn fetch_export(home: &Home, token: &str, file: &Value) -> Vec { + let command = file["download"] + .as_str() + .unwrap_or_else(|| panic!("{file}")); + let id = command + .split(' ') + .find(|w| w.starts_with("ex_")) + .unwrap_or_else(|| panic!("{command}")); + let r = mcp_http(home) + .get(format!("http://localhost/b/acme/x/{id}")) + .bearer_auth(token) + .send() + .await + .unwrap(); + assert_eq!(r.status().as_u16(), 200, "{:?}", r.headers()); + let bytes = r.bytes().await.unwrap().to_vec(); + assert_eq!( + file["sha256"], + json!(ledger_store::journal::sha256_hex(&bytes)) + ); + assert_eq!(file["size_bytes"], json!(bytes.len())); + bytes +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn a_file_the_books_make_is_fetched_by_its_own_token() { + let home = Home::new("exports"); + let server = Server::start(&home); + home.create("acme", "USD"); + let c = connect(&home, "acme").await.unwrap(); + let token = home.token("acme"); + call( + &c, + "books.add_party", + json!({"name": "Member One", "roles": ["member"]}), + ) + .await + .unwrap(); + call( + &c, + "books.set_tin", + json!({"party": "Member One", "tin": "620-37-1111", "tin_type": "ssn"}), + ) + .await + .unwrap(); + for (code, name, ty) in [("1000", "Cash", "asset"), ("3000", "Capital", "equity")] { + call( + &c, + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + ) + .await + .unwrap(); + } + // The CPA package, TINs and all: handed over, never inline, never a + // path; the command runs as this token. + let got = call( + &c, + "books.export_cpa_package", + json!({"as_of": "2026-10-18", "cash_accounts": ["1000"]}), + ) + .await + .unwrap(); + home.assert_hidden(&got.to_string()); + assert_eq!(got["tins_included"], json!(true)); + let file = &got["file"]; + assert_eq!(file["file_name"], json!("cpa-package-2026-10-18.zip")); + assert_eq!(file["mime_type"], json!("application/zip")); + assert!(file.get("content_base64").is_none(), "{file}"); + let command = file["download"].as_str().unwrap(); + assert!( + command.starts_with("wanderbooks download --book acme --token test ex_") + && command.ends_with(" --to "), + "{command}" + ); + assert!(file["expires_at"].as_str().unwrap().ends_with('Z')); + // Fetched whole, as often as needed while it's kept. + let zip = fetch_export(&home, &token, file).await; + assert!(zip.starts_with(b"PK"), "a zip"); + assert_eq!(fetch_export(&home, &token, file).await, zip); + let id = command.split(' ').find(|w| w.starts_with("ex_")).unwrap(); + let get = |bearer: Option, what: String| { + let http = mcp_http(&home); + async move { + let mut r = http.get(format!("http://localhost/b/acme/x/{what}")); + if let Some(b) = bearer { + r = r.bearer_auth(b); + } + r.send().await.unwrap() + } + }; + // Only its own token; a reader at that. + let other = home.token_with("acme", "other", &["read"]); + let r = get(Some(other), id.to_string()).await; + assert_eq!(r.status().as_u16(), 403); + assert_eq!(r.headers()[ledger_mcp::local::REFUSED_HEADER], "not_yours"); + let writer = home.token_with("acme", "writer", &["write"]); + assert_eq!( + get(Some(writer), id.to_string()).await.status().as_u16(), + 403 + ); + assert_eq!(get(None, id.to_string()).await.status().as_u16(), 401); + let r = get( + Some(token.clone()), + "ex_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa".into(), + ) + .await; + assert_eq!(r.status().as_u16(), 404); + home.assert_hidden(&r.text().await.unwrap()); + // A token that may make an export but not read the books fetches its + // own: the export is bound to it, whatever its scopes. + let admin = home.token_with("acme", "cpa", &["admin"]); + let ca = connect_with(&home, "acme", Some(admin.clone())) + .await + .unwrap(); + let w = call(&ca, "books.vendor_1099", json!({"year": 2026, "csv": true})) + .await + .unwrap(); + let csv = fetch_export(&home, &admin, &w["csv"]).await; + assert!(csv.starts_with(b"vendor_id,name,")); + drop(ca); + // Each fetch is in the access log as an export, under its id. + let log = call( + &c, + "books.access_log", + json!({"action": "export", "limit": 20}), + ) + .await + .unwrap(); + let records = log["records"].as_array().unwrap(); + assert_eq!(records.len(), 7, "{log}"); + assert!(records.iter().filter(|r| r["subject"] == json!(id)).count() >= 5); + // Given back to be filed, it is refused: the server made it with TINs. + let e = call( + &c, + "books.file_document", + json!({"category": "tax", "file_name": "p.zip", "mime_type": "application/zip", + "content_base64": b64(&zip), "sha256": ledger_store::journal::sha256_hex(&zip)}), + ) + .await; + assert!(zip.len() <= 256 * 1024, "small enough to give inline"); + let e = e.unwrap_err(); + assert!(e.contains("TIN"), "{e}"); + // Nothing loose in the book's folder: the export is in its own. + let loose: Vec = std::fs::read_dir(home.book_dir("acme")) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".zip") || n.ends_with(".csv") || n.ends_with(".pdf")) + .collect(); + assert!(loose.is_empty(), "{loose:?}"); + drop(c); + assert!(server.close().success()); +} diff --git a/crates/ledger-cli/tests/one_executable.rs b/crates/ledger-cli/tests/one_executable.rs index d31dfff..8992800 100644 --- a/crates/ledger-cli/tests/one_executable.rs +++ b/crates/ledger-cli/tests/one_executable.rs @@ -108,7 +108,7 @@ fn each_mode_names_itself_in_its_usage() { let out = wanderbooks().arg("--help").output().unwrap(); let err = String::from_utf8_lossy(&out.stderr); for line in [ - "wanderbooks gui [DIR", + "wanderbooks gui [BOOK", "wanderbooks dev-stdio", "dev-stdio run the books server", ] { @@ -123,7 +123,7 @@ fn the_viewer_names_itself_in_its_usage() { assert!(out.status.success()); let text = String::from_utf8_lossy(&out.stdout); assert!( - text.contains("usage: wanderbooks gui [--data-dir PATH]"), + text.contains("usage: wanderbooks gui [--root PATH] [--book ID]"), "{text}" ); } diff --git a/crates/ledger-cli/tests/open_while_held.rs b/crates/ledger-cli/tests/open_while_held.rs index a57de68..75d0d7e 100644 --- a/crates/ledger-cli/tests/open_while_held.rs +++ b/crates/ledger-cli/tests/open_while_held.rs @@ -2278,6 +2278,23 @@ fn the_commands_given_run_as_the_agents_own_token() { ); assert!(out.status.success(), "{out:?}"); assert_eq!(std::fs::read(folder.join("r (1).pdf")).unwrap(), bytes); + // A file the books make (an export) is saved the same way. + let w = a + .call("books.vendor_1099", json!({"year": 2026, "csv": true})) + .unwrap(); + let out = run_given( + &home, + w["csv"]["download"].as_str().unwrap(), + " --to ", + &["--to".as_ref(), folder.as_os_str()], + ); + assert!(out.status.success(), "{out:?}"); + let csv = std::fs::read(folder.join("1099-worksheet-2026.csv")).unwrap(); + assert_eq!( + w["csv"]["sha256"], + json!(ledger_store::journal::sha256_hex(&csv)) + ); + assert!(csv.starts_with(b"vendor_id,name,")); // A file whose name isn't text goes, and comes back into a folder // whose name isn't text. use std::os::unix::ffi::OsStrExt; diff --git a/crates/ledger-gui/Cargo.toml b/crates/ledger-gui/Cargo.toml index 15cfd20..d80d8de 100644 --- a/crates/ledger-gui/Cargo.toml +++ b/crates/ledger-gui/Cargo.toml @@ -16,6 +16,7 @@ ledger-store = { path = "../ledger-store" } ledger-config = { path = "../ledger-config" } ledger-import = { path = "../ledger-import" } ledger-vault = { path = "../ledger-vault" } +ledger-package = { path = "../ledger-package" } eframe = { version = "0.36", default-features = false, features = ["default_fonts", "wgpu", "wayland", "x11", "persistence"] } egui = "0.36" egui_extras = { version = "0.36", features = ["image", "datepicker", "serde"] } @@ -33,6 +34,8 @@ serde_json = "1" chrono = "0.4" egui_kittest = { version = "0.36", features = ["wgpu", "snapshot", "eframe"] } proptest = "1" +tempfile = "3" +zip = "9.0.1" [lints] workspace = true diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 2bf8726..78abee0 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -145,6 +145,8 @@ pub struct App { palette: crate::palette::Palette, updated_at: f64, live_label: bool, + /// The export menu (menu.rs). + menu: crate::menu::Menu, } impl App { @@ -188,6 +190,7 @@ impl App { palette: Default::default(), updated_at: -1e9, live_label: false, + menu: Default::default(), } } @@ -368,9 +371,9 @@ impl App { } /// Go where the agent asked (`books.show`). - pub fn show(&mut self, target: ledger_store::viewer::ViewerTarget) { + pub fn show(&mut self, target: ledger_store::ops::ViewerTarget) { use crate::nav::ReportKind; - use ledger_store::viewer::ViewerTarget as T; + use ledger_store::ops::ViewerTarget as T; let Some(b) = self.books.clone() else { return; }; @@ -638,6 +641,10 @@ impl App { }); ui.with_layout(Layout::bottom_up(Align::Min), |ui| { self.scheme_menu(ui, false); + if let Some(b) = &books { + ui.add_space(2.0); + self.menu.ui(ui, b, false); + } ui.add_space(6.0); if let Some(b) = &books { live_status( @@ -733,6 +740,9 @@ impl App { }); ui.with_layout(Layout::bottom_up(Align::Center), |ui| { self.scheme_menu(ui, true); + if let Some(b) = &books { + self.menu.ui(ui, b, true); + } ui.add_space(6.0); if let Some(b) = &books { let live = self.loader.is_some() || self.live_label; diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index 36bf7d7..83ff4b9 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -676,7 +676,6 @@ mod tests { ledger_store::Store::create(&data_dir, ledger_core::Currency::USD).unwrap(); crate::data::load_once(&crate::data::Source { data_dir, - books_toml: None, today: Some(today), }) .unwrap() diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index bda21ef..56e4cb7 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -540,10 +540,12 @@ impl Books { let ledger = snap.ledger().clone(); let attachments = snap.attachments(None).map_err(|e| e.to_string())?; let journal = snap.journal().to_vec(); - // The server's own rule (the books' settings when they keep them, - // else config.json, else wanderbooks.toml), so the viewer flags - // exactly what the server would refuse. - let policy = if ledger.settings().is_journaled() { + // The server's own rule, so the viewer flags exactly what the + // server would refuse: the books' settings; for an older books dir + // that keeps none, its config.json, else its settings file. + let policy = if ledger.settings().is_journaled() + || ledger_store::root::inside_a_root(snap.data_dir()) + { ledger_config::codec::receipt_policy(ledger.settings()) } else { ledger_config::effective_receipt_policy( @@ -1521,14 +1523,34 @@ impl Books { /// INVARIANT 1: read only on demand (a click), never on load or paint. /// INVARIANT 2: never creates a key file; a missing key is a locked /// vault ("restore it from your password manager"). - /// INVARIANT 3: the key file in force is the server's: the one given - /// on the command line (--vault-key-file), else a settings file's - /// [vault] key_file, else ~/.config/wanderbooks/vault.key; one - /// inside the books dir is refused, as the server refuses it. + /// INVARIANT 3: the vault and key are the server's: for a book in the + /// data root, its own `vault/` and `keys/legacy.key`; for older + /// books, `vault/` beside `books-data/` and the key file the one + /// given on the command line (--vault-key-file), else the settings' + /// [vault] key_file, else ~/.config/wanderbooks/vault.key, one + /// inside the books dir refused, as the server refuses it. /// INVARIANT 4: an error never carries the digits (the vault's errors /// are redacted); a party with no record is "not in the vault". /// ``` pub fn tin_from_vault(&self, party: PartyId) -> Result { + let vault = self.open_vault()?; + match vault.tin(party) { + Ok(Some(t)) => Ok(t.formatted()), + Ok(None) => Err("Not in the vault on this computer.".into()), + Err(e) => Err(e.to_string()), + } + } + + /// Where the vault and its key are (INVARIANT 3 of [`Self::tin_from_vault`]). + fn vault_place(&self) -> Result<(PathBuf, PathBuf), String> { + if ledger_store::root::inside_a_root(&self.data_dir) { + return Ok(( + self.data_dir.join(ledger_store::root::VAULT_DIR), + self.data_dir + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE), + )); + } let key = crate::vault_key_file() .or_else(|| self.settings.vault_key_file.clone()) .unwrap_or_else(ledger_vault::default_key_file); @@ -1539,7 +1561,22 @@ impl Books { key.display() )); } - let vault = ledger_vault::Vault::open(&books.join("vault"), &key, false).map_err( + Ok((books.join("vault"), key)) + } + + /// A party's signed W-9 file from the vault, on demand (a click). + pub fn w9_file(&self, party: PartyId) -> Result { + self.open_vault()? + .w9(party) + .map_err(|e| e.to_string())? + .ok_or_else(|| "The vault holds no W-9 file for this party.".to_string()) + } + + /// The vault, opened read-only, never making a key (a missing one is a + /// locked vault). + pub fn open_vault(&self) -> Result { + let (dir, key) = self.vault_place()?; + ledger_vault::Vault::open(&dir, &key, false).map_err( |e| match e { ledger_vault::Error::NoKey(k) => format!( "The vault is locked: its key file {} is missing. Restore it from your password manager.", @@ -1547,12 +1584,7 @@ impl Books { ), other => other.to_string(), }, - )?; - match vault.tin(party) { - Ok(Some(t)) => Ok(t.formatted()), - Ok(None) => Err("Not in the vault on this computer.".into()), - Err(e) => Err(e.to_string()), - } + ) } /// One entry's what-for, for one party (see [`PartyFlow`] INVARIANT @@ -1835,6 +1867,13 @@ impl Books { pub fn blob_root(&self) -> PathBuf { self.data_dir.join("blobs") } + + /// A stored file's bytes, read only, checked against its SHA-256. + pub fn attachment_bytes(&self, a: &Attachment) -> Result, String> { + ledger_store::BlobStore::open_read_only(&self.blob_root()) + .get(&a.blob_sha) + .map_err(|e| e.to_string()) + } } /// An entry flow's what_for apportioned to one party's `share` of its net: diff --git a/crates/ledger-gui/src/data.rs b/crates/ledger-gui/src/data.rs index e571d05..bfb84f4 100644 --- a/crates/ledger-gui/src/data.rs +++ b/crates/ledger-gui/src/data.rs @@ -14,38 +14,79 @@ use std::collections::HashSet; use std::path::PathBuf; use std::sync::Arc; use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender, channel}; -use std::time::{Duration, SystemTime}; +use std::time::Duration; -/// Where the books come from. +/// Where the books come from: their folder (a book of the data root, or +/// an older books dir's `books-data`). Their settings are their own, in +/// their journal (plan 3.6); only an older books dir whose journal keeps +/// none is read as its server reads it, from its settings file (see +/// `settings`). #[derive(Clone, Debug)] pub struct Source { - /// The `books-data` directory (journal and blobs). + /// The books' folder (journal and blobs). pub data_dir: PathBuf, - /// The company's `wanderbooks.toml`, if any. - pub books_toml: Option, /// Fix "today" (tests, screenshots); otherwise the local date. pub today: Option, } +/// The settings file an older books dir's server reads when the books +/// keep no settings of their own: `wanderbooks.toml` beside `books-data`, +/// or the one named with `--books-toml`. Never for a book of the data +/// root. +pub fn legacy_toml(data_dir: &std::path::Path) -> Option { + legacy_toml_path(data_dir).filter(|p| p.is_file()) +} + +/// Where an older books dir's settings file is, whether or not it is +/// there yet (a file made later is followed, as the server follows it). +fn legacy_toml_path(data_dir: &std::path::Path) -> Option { + if ledger_store::root::inside_a_root(data_dir) { + return None; + } + if let Some(named) = LEGACY_TOML.get() { + return Some(named.clone()); + } + Some(data_dir.parent()?.join("wanderbooks.toml")) +} + +/// `--books-toml`, for an older books dir (once, at start). +static LEGACY_TOML: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Name an older books dir's settings file (`--books-toml`). +pub fn set_legacy_toml(path: PathBuf) { + let _ = LEGACY_TOML.set(path); +} + pub enum Update { Books(Arc), Error(String), /// The agent asked the viewer to show something (`books.show`). - Show(ledger_store::viewer::ViewerTarget), + Show(ledger_store::ops::ViewerTarget), } pub struct Loader { rx: Receiver, + /// Set when this is dropped: the loader's thread then ends, letting go + /// of the books (and the viewer's presence on them). + gone: Arc, +} + +impl Drop for Loader { + fn drop(&mut self) { + self.gone.store(true, std::sync::atomic::Ordering::SeqCst); + } } impl Loader { pub fn spawn(source: Source, ctx: egui::Context) -> Loader { let (tx, rx) = channel(); + let gone = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let stop = gone.clone(); std::thread::Builder::new() .name("books-loader".into()) - .spawn(move || run(source, ctx, tx)) + .spawn(move || run(source, ctx, tx, &stop)) .expect("spawn loader"); - Loader { rx } + Loader { rx, gone } } /// Every update since the last call, in order (books before the @@ -98,30 +139,35 @@ fn today(source: &Source, settings: &BooksSettings) -> NaiveDate { .unwrap_or_else(|| ledger_core::settings::today_in(settings.timezone.as_deref())) } -/// The settings: the books' own when they keep them (the settings file is -/// then never read, as the server never reads it), else the settings -/// file's. +/// The settings, as the server reads them: the books' own, from their +/// journal; for an older books dir that keeps none, its settings file +/// (else the defaults). A book of the data root reads no file. fn settings(source: &Source, snap: &Snapshot) -> Result { + settings_of(&source.data_dir, snap) +} + +/// `settings` for the books in `data_dir`, as `snap` holds them. +pub fn settings_of(data_dir: &std::path::Path, snap: &Snapshot) -> Result { let books = snap.ledger().settings(); - if books.is_journaled() { - return Ok(ledger_config::codec::books_settings( - books, - snap.ledger().base_currency().code(), - )); - } - match &source.books_toml { - Some(p) if p.exists() => ledger_config::load(p), - _ => Ok(BooksSettings::default()), + if !books.is_journaled() + && let Some(toml) = legacy_toml(data_dir) + && toml.is_file() + { + return ledger_config::load(&toml); } + Ok(ledger_config::codec::books_settings( + books, + snap.ledger().base_currency().code(), + )) } -fn mtime(p: &Option) -> Option { - p.as_ref() - .and_then(|p| std::fs::metadata(p).ok()) - .and_then(|m| m.modified().ok()) -} - -fn run(source: Source, ctx: egui::Context, tx: Sender) { +fn run( + source: Source, + ctx: egui::Context, + tx: Sender, + gone: &std::sync::atomic::AtomicBool, +) { + let gone = || gone.load(std::sync::atomic::Ordering::SeqCst); let send = |u: Update| { let _ = tx.send(u); ctx.request_repaint(); @@ -133,6 +179,9 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { send(Update::Error(problem(&e, || { format!("Can't read the books in {}", source.data_dir.display()) }))); + if gone() { + return; + } std::thread::sleep(Duration::from_secs(2)); } } @@ -151,6 +200,21 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { }; (update, disk) }; + // An older books dir's settings file, followed as its server follows + // it (only read while the books keep no settings of their own). + // And its config.json, whose receipt rule the server reads too. + let toml = legacy_toml_path(&source.data_dir); + let config = (!ledger_store::root::inside_a_root(&source.data_dir)) + .then(|| source.data_dir.join("config.json")); + let mtime = |t: &Option| { + t.as_ref() + .and_then(|p| std::fs::metadata(p).ok()) + .and_then(|m| m.modified().ok()) + }; + let toml_mtime = |t: &Option| (mtime(t), mtime(&config)); + // Taken before the first build, so a file written while it is being + // built is a change, never part of the starting state. + let mut toml_seen = toml_mtime(&toml); let (first, disk) = build(&snap, None); let mut last_disk = Some(disk); send(first); @@ -161,7 +225,7 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { let started = chrono::Utc::now(); let mut last_show = 0u64; let mut check_show = |send: &dyn Fn(Update)| { - if let Some(cmd) = ledger_store::viewer::read(&source.data_dir) + if let Some(cmd) = ledger_store::ops::read(&source.data_dir) && cmd.seq != last_show { let first_look = last_show == 0; @@ -184,25 +248,38 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { .ok(); if let Some(w) = watcher.as_mut() { let _ = w.watch(&source.data_dir, RecursiveMode::NonRecursive); - let cmd = ledger_store::viewer::command_path(&source.data_dir); - if let Some(dir) = cmd.parent() { - let _ = std::fs::create_dir_all(dir); - let _ = w.watch(dir, RecursiveMode::NonRecursive); - } - if let Some(t) = &source.books_toml - && let Some(dir) = t.parent() - { - let _ = w.watch(dir, RecursiveMode::NonRecursive); - } } - let mut toml_mtime = mtime(&source.books_toml); + // The books' `ops/`, once the server has made it (the viewer makes + // nothing): its show requests watched, and this viewer's presence + // held there while it runs (ledger_store::ops INVARIANT 3). + let ops = ledger_store::ops::ops_dir(&source.data_dir); + let mut ops_watched = false; + let mut presence: Option = None; + let mut look_at_ops = |watcher: &mut Option| { + if !ops_watched + && ops.is_dir() + && let Some(w) = watcher.as_mut() + { + ops_watched = w.watch(&ops, RecursiveMode::NonRecursive).is_ok(); + } + if presence.is_none() { + presence = ledger_store::ops::Presence::hold(&source.data_dir); + } + }; + look_at_ops(&mut watcher); let day_now = |snap: &Snapshot| { settings(&source, snap) .map(|s| today(&source, &s)) .unwrap_or_else(|_| today(&source, &BooksSettings::default())) }; let mut day = day_now(&snap); + if let (Some(w), Some(dir)) = (watcher.as_mut(), toml.as_ref().and_then(|t| t.parent())) { + let _ = w.watch(dir, RecursiveMode::NonRecursive); + } loop { + if gone() { + return; + } match ev_rx.recv_timeout(Duration::from_secs(3)) { Ok(()) => { // Let a burst of writes settle, then take them all at once. @@ -224,7 +301,8 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { continue; } }; - let toml_now = mtime(&source.books_toml); + look_at_ops(&mut watcher); + let toml_now = toml_mtime(&toml); let new_day = day_now(&snap); // The writer journals first and updates its database right after: // a "behind" database usually catches up a moment later, so look @@ -233,9 +311,9 @@ fn run(source: Source, ctx: egui::Context, tx: Sender) { && last_disk .as_ref() .is_some_and(|d| *d != ledger_store::DiskCheck::Matches && snap.check_disk() != *d); - if grew || disk_changed || toml_now != toml_mtime || new_day != day { - toml_mtime = toml_now; + if grew || disk_changed || new_day != day || toml_now != toml_seen { day = new_day; + toml_seen = toml_now; let (update, disk) = build(&snap, grew.then_some(before)); last_disk = Some(disk); send(update); diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index b97f5bb..9800c79 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -16,7 +16,8 @@ use ledger_store::{Statement, Store, StoreError, Subject}; use rust_decimal::Decimal; use std::path::Path; -/// The demo's `wanderbooks.toml`. +/// The demo's settings, written as a `wanderbooks.toml` and imported into +/// its journal, as real books keep theirs. pub const BOOKS_TOML: &str = r#"[company] name = "Juniper Studio LLC" entity_type = "LLC, single-member" @@ -149,12 +150,21 @@ fn receipt(vendor: &str, date: NaiveDate, item: &str, amount: Decimal, paid: &st } /// Build the demo books in `dir` (which must not already hold books): the -/// journal and blobs under `dir/books-data`, the settings in -/// `dir/wanderbooks.toml`. +/// journal and blobs under `dir/books-data`, its settings in the journal. pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { std::fs::create_dir_all(dir)?; - std::fs::write(dir.join("wanderbooks.toml"), BOOKS_TOML)?; let mut s = Store::create(&dir.join("books-data"), Currency::USD)?; + let import = ledger_config::codec::import(BOOKS_TOML, None, s.ledger().settings(), "USD") + .map_err(|e| StoreError::Io(std::io::Error::other(e)))?; + s.adopt_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "the demo".into(), + decided_on: NaiveDate::from_ymd_opt(2025, 6, 2).expect("a date"), + citation: None, + note: None, + }, + )?; let mut rng = Rng(0x5eed_1234); let start = NaiveDate::from_ymd_opt(today.year(), today.month(), 1).expect("valid") - Months::new(15); diff --git a/crates/ledger-gui/src/export.rs b/crates/ledger-gui/src/export.rs index 5fa3fd7..167225d 100644 --- a/crates/ledger-gui/src/export.rs +++ b/crates/ledger-gui/src/export.rs @@ -3,9 +3,20 @@ use std::path::{Path, PathBuf}; -/// Where saved files go: `$XDG_DOWNLOAD_DIR`, else `~/Downloads`, else -/// the home directory. +/// The folder saved files go to, when set (tests, at start). +static DOWNLOADS: std::sync::OnceLock = std::sync::OnceLock::new(); + +/// Name the folder saved files go to (once; tests use their own). +pub fn set_downloads_dir(dir: PathBuf) { + let _ = DOWNLOADS.set(dir); +} + +/// Where saved files go: the folder set, else `$XDG_DOWNLOAD_DIR`, else +/// `~/Downloads`, else the home directory. pub fn downloads_dir() -> PathBuf { + if let Some(d) = DOWNLOADS.get() { + return d.clone(); + } if let Some(d) = std::env::var_os("XDG_DOWNLOAD_DIR").map(PathBuf::from) && d.is_dir() { @@ -96,19 +107,37 @@ pub fn file_name(name: &str, ext: &str) -> String { format!("{base}.{ext}") } -/// Write `bytes` to the downloads folder as `name.ext` (replacing a file -/// of that name: the same report renders the same bytes) and open it with -/// the system's viewer. +/// Save `bytes` in the downloads folder as `name.ext`, made safe: a new +/// file beside any of that name (`name (1).ext`), never over one, never a +/// part of it left under the name (ledger_store::saving). +pub fn save(name: &str, ext: &str, bytes: &[u8]) -> Result { + ledger_store::saving::save_new( + &downloads_dir(), + &file_name(name, ext), + &file_name("wanderbooks file", ext), + bytes, + ) +} + +/// [`save`], then open it with the system's viewer. pub fn save_and_open(name: &str, ext: &str, bytes: &[u8]) -> Result { - let path = downloads_dir().join(file_name(name, ext)); - std::fs::write(&path, bytes).map_err(|e| format!("{}: {e}", path.display()))?; + let path = save(name, ext, bytes)?; open(&path); Ok(path) } +/// A stored file's name split into the name and its extension, for +/// [`save`]: "receipt.pdf" is ("receipt", "pdf"); with none, "bin". +pub fn split_name(file_name: &str) -> (&str, &str) { + match file_name.rsplit_once('.') { + Some((stem, ext)) if !stem.is_empty() && !ext.is_empty() && ext.len() <= 16 => (stem, ext), + _ => (file_name, "bin"), + } +} + /// Open a file with whatever the desktop uses for its type. Best effort: /// the file is saved either way. -fn open(path: &Path) { +pub fn open(path: &Path) { #[cfg(target_os = "macos")] let cmd = std::process::Command::new("open").arg(path).spawn(); #[cfg(target_os = "windows")] diff --git a/crates/ledger-gui/src/lib.rs b/crates/ledger-gui/src/lib.rs index 7ebb9a6..9155235 100644 --- a/crates/ledger-gui/src/lib.rs +++ b/crates/ledger-gui/src/lib.rs @@ -11,6 +11,7 @@ pub mod data; pub mod demo; pub mod export; pub mod fmt; +pub mod menu; pub mod nav; pub mod pages; pub mod palette; @@ -62,23 +63,41 @@ impl std::error::Error for Exit {} /// `wanderbooks gui`). It opens the books read-only and returns when the /// window closes. An unknown argument returns [`Exit`] (code 2). /// -/// `ledger-gui [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]`: -/// with no flags it looks for `books-data/` and `wanderbooks.toml` in the -/// current directory; `--demo` builds a demo company in a temp directory -/// and opens that. +/// `ledger-gui [--root PATH] [--book ID] [--data-dir PATH [--books-toml PATH] [--vault-key-file PATH]] [--today YYYY-MM-DD] [--demo]`: +/// a book of the data root (plan 3.6): `--root`, else the root `wanderbooks +/// setup` made, never the current directory or a settings file; `--book` +/// names one, else the root's only one (or the first, by id). +/// `--data-dir` opens an older books dir's `books-data` instead (its +/// settings file `--books-toml`, read while its journal keeps none, else +/// the `wanderbooks.toml` beside it; its vault key `--vault-key-file`); +/// `--demo` builds a demo company in a temp directory and opens that. pub fn run( prog: &str, args: impl IntoIterator, ) -> Result<(), Box> { + let mut root: Option = None; + let mut book: Option = None; let mut data_dir: Option = None; - let mut books_toml: Option = None; let mut today = None; let mut demo = false; let mut args = args.into_iter(); + let usage = || { + format!( + "usage: {prog} [--root PATH] [--book ID] [--data-dir PATH [--books-toml PATH] [--vault-key-file PATH]] [--today YYYY-MM-DD] [--demo]" + ) + }; while let Some(a) = args.next() { match a.as_str() { + "--root" => root = args.next().map(PathBuf::from), + "--book" => book = args.next(), "--data-dir" => data_dir = args.next().map(PathBuf::from), - "--books-toml" => books_toml = args.next().map(PathBuf::from), + // An older books dir's settings file, read only while its + // books keep no settings of their own (data::legacy_toml). + "--books-toml" => { + if let Some(t) = args.next() { + crate::data::set_legacy_toml(PathBuf::from(t)); + } + } "--vault-key-file" => { if let Some(k) = args.next() { crate::set_vault_key_file(PathBuf::from(k)); @@ -87,13 +106,12 @@ pub fn run( "--today" => today = args.next().and_then(|d| d.parse().ok()), "--demo" => demo = true, "-h" | "--help" => { - println!( - "usage: {prog} [--data-dir PATH] [--books-toml PATH] [--vault-key-file PATH] [--today YYYY-MM-DD] [--demo]" - ); + println!("{}", usage()); return Ok(()); } other => { eprintln!("unknown argument {other}"); + eprintln!("{}", usage()); return Err(Box::new(Exit(2))); } } @@ -108,21 +126,18 @@ pub fn run( } eprintln!("demo books: {}", dir.display()); data_dir = Some(dir.join("books-data")); - books_toml = Some(dir.join("wanderbooks.toml")); } - let data_dir = data_dir.unwrap_or_else(|| PathBuf::from("books-data")); - let books_toml = books_toml.or_else(|| { - let guess = data_dir - .parent() - .unwrap_or(&data_dir) - .join("wanderbooks.toml"); - guess.exists().then_some(guess) - }); - let source = Source { - data_dir, - books_toml, - today, + let data_dir = match data_dir { + Some(d) => d, + None => match book_in_root(root.as_deref(), book.as_deref()) { + Ok(d) => d, + Err(why) => { + eprintln!("{why}"); + return Err(Box::new(Exit(2))); + } + }, }; + let source = Source { data_dir, today }; let options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_title("wanderbooks") @@ -143,6 +158,34 @@ pub fn run( )?) } +/// The folder of a book in the data root (plan 3.4): `root`, else the +/// one `wanderbooks setup` made; `book`, else its only book (or the first +/// by id). Why not, in words for the human. +pub fn book_in_root(root: Option<&std::path::Path>, book: Option<&str>) -> Result { + use ledger_store::root::{BookId, Root}; + let root = Root::find(root).map_err(|e| { + format!("no books to show: {e}. `wanderbooks setup` makes a place for them.") + })?; + let books = root.list_books().map_err(|e| e.to_string())?; + let id = match book { + Some(b) => { + let id = BookId::parse(b).map_err(|e| e.to_string())?; + if !books.contains(&id) { + return Err(format!( + "there are no books called {:?}: `wanderbooks books list` lists them", + id.as_str() + )); + } + id + } + None => books.into_iter().next().ok_or_else(|| { + "there are no books yet: your agent makes them (`wanderbooks books create`)." + .to_string() + })?, + }; + Ok(root.book_dir(&id)) +} + #[cfg(test)] mod exit_tests { use super::Exit; diff --git a/crates/ledger-gui/src/menu.rs b/crates/ledger-gui/src/menu.rs new file mode 100644 index 0000000..b2808a4 --- /dev/null +++ b/crates/ledger-gui/src/menu.rs @@ -0,0 +1,214 @@ +//! The viewer's menu (plan P2 "Viewer menu", D-29): the files the books +//! make for the human, made by the viewer itself into the Downloads +//! folder, with their full TINs from the vault. Nothing here writes the +//! books. +//! +//! ```text +//! GOAL: the owner gets the CPA package and the 1099 worksheet from the +//! viewer, the same files the agent would hand over. +//! SOURCE: plan P2 ("Export for CPA" (done by the viewer, into +//! Downloads)); D-29 ("The viewer's menu makes the same files into +//! Downloads"); ledger_package (the one builder). +//! INVARIANTS: +//! 1. Each is built from a fresh read-only snapshot of the books (its +//! chain checked when it loads) by ledger_package, the builder the +//! server uses: the same files, the same figures. +//! 2. The TINs come from the vault, read-only; a locked vault or a +//! missing TIN never stops the file: the package lists the gaps. +//! 3. The file is saved as a new file in Downloads (export::save), never +//! over one, off the UI thread; what came of it is shown. +//! ``` + +use crate::books::Books; +use chrono::Datelike; +use egui::{RichText, Ui, vec2}; +use egui_phosphor::regular as icon; +use std::path::PathBuf; +use std::sync::Arc; +use std::sync::mpsc::{Receiver, channel}; + +/// What a menu job came to: the file saved, and anything to say about it. +pub type Done = Result<(PathBuf, Option), String>; + +/// The menu's state: the job under way, and the last one's outcome. +#[derive(Default)] +pub struct Menu { + job: Option<(String, Receiver)>, + done: Option<(String, Done)>, +} + +impl Menu { + /// The menu button, its items, and a note of the last job's outcome. + pub fn ui(&mut self, ui: &mut Ui, books: &Arc, compact: bool) { + let p = crate::theme::pal(ui.ctx()); + if let Some((what, rx)) = &self.job + && let Ok(done) = rx.try_recv() + { + self.done = Some((what.clone(), done)); + self.job = None; + } + let text = if compact { + RichText::new(icon::ARCHIVE).size(16.0).color(p.muted) + } else { + RichText::new(format!("{} Export", icon::ARCHIVE)) + .font(crate::theme::regular(12.5)) + .color(p.muted) + }; + let mut button = egui::Button::new(text).frame(false); + if compact { + button = button.min_size(vec2(32.0, 32.0)); + } + let busy = self.job.is_some(); + let year = books.today.year(); + let mut start: Option = None; + let response = ui + .add_enabled_ui(!busy, |ui| { + egui::containers::menu::MenuButton::from_button(button).ui(ui, |ui| { + ui.set_min_width(240.0); + if ui + .button(format!("{} Export for CPA", icon::PACKAGE)) + .on_hover_text( + "The CPA package as of today, full TINs included, into your Downloads folder", + ) + .clicked() + { + start = Some(Job::Package); + } + ui.separator(); + for y in [year - 1, year] { + if ui + .button(format!("{} 1099 worksheet, {y}", icon::FILE_TEXT)) + .on_hover_text( + "The year's 1099 worksheet as a CSV, full TINs included, into your Downloads folder", + ) + .clicked() + { + start = Some(Job::Worksheet(y)); + } + } + }) + }) + .inner; + if let Some(job) = start { + self.start(ui.ctx(), books.clone(), job); + } + let said = if self.job.is_some() { + Some(("Making the file…".to_string(), p.muted)) + } else { + self.done.as_ref().map(|(what, done)| match done { + Ok((path, None)) => (format!("{what}: saved {}", path.display()), p.muted), + Ok((path, Some(note))) => ( + format!("{what}: saved {}. {note}", path.display()), + p.warning, + ), + Err(why) => (format!("{what} couldn't be made: {why}"), p.warning), + }) + }; + // Beside the button; in the narrow rail, on hovering it. + match said { + Some((text, _)) if compact => { + response.0.on_hover_text(text); + } + Some((text, color)) => { + ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap()); + } + None => {} + } + } + + fn start(&mut self, ctx: &egui::Context, books: Arc, job: Job) { + let (tx, rx) = channel(); + let what = job.label(); + let ctx = ctx.clone(); + std::thread::spawn(move || { + let _ = tx.send(job.run(&books)); + ctx.request_repaint(); + }); + self.job = Some((what, rx)); + self.done = None; + } +} + +/// A file the menu makes. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Job { + /// The CPA package as of the books' today. + Package, + /// The 1099 worksheet for a calendar year. + Worksheet(i32), +} + +impl Job { + fn label(self) -> String { + match self { + Job::Package => "The CPA package".to_string(), + Job::Worksheet(y) => format!("The {y} 1099 worksheet"), + } + } + + /// Make the file and save it (INVARIANTS 1-3). + pub fn run(self, books: &Books) -> Done { + let snap = ledger_store::Snapshot::load(&books.data_dir).map_err(|e| e.to_string())?; + let ledger = snap.ledger(); + // The settings the rest of the viewer shows, read as the server + // reads them (data::settings: an older books dir that keeps none + // in its journal reads its settings file). + let st = &crate::data::settings_of(&books.data_dir, &snap)?; + let settings = ledger_package::Settings { + company: &st.company, + cash_accounts: &st.cash_accounts, + financing_accounts: &st.financing_accounts, + investing_accounts: &st.investing_accounts, + }; + let vault = books.open_vault(); + let tin = |id| ledger_package::vault_tin(ledger, &vault, id); + match self { + Job::Package => { + let p = ledger_package::build_package( + &snap, + &settings, + &ledger_package::PackageRequest { + as_of: books.today, + prepared_on: books.today, + cash_accounts: &[], + financing_accounts: &[], + investing_accounts: &[], + include_receipts: true, + tins: Some(&tin), + }, + )?; + let (stem, ext) = crate::export::split_name(&p.file_name); + let path = crate::export::save(stem, ext, &p.zip)?; + let note = (!p.tin_gaps.is_empty()).then(|| { + format!( + "{} TIN(s) aren't in it (the README says which): ask your agent to collect them.", + p.tin_gaps.len() + ) + }); + Ok((path, note)) + } + Job::Worksheet(year) => { + let w = ledger_package::vendor_1099_worksheet( + ledger, + &settings, + year, + None, + Some(&tin), + )?; + let csv = ledger_package::vendor_1099_csv(&w, true); + let name = ledger_package::vendor_1099_csv_name(year, true); + let (stem, ext) = crate::export::split_name(&name); + let path = crate::export::save(stem, ext, csv.as_bytes())?; + let gaps = w + .lines + .iter() + .filter(|l| l.reportable && l.tin.is_none()) + .count(); + let note = (gaps > 0).then(|| { + format!("{gaps} reportable payee(s) have no TIN in it: ask your agent to collect them.") + }); + Ok((path, note)) + } + } + } +} diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index e566035..0ca6d46 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -147,13 +147,18 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { |_| {}, |ui| { let kept = b.ledger.settings(); + // Where the settings shown come from: a settings + // file only for an older books dir that keeps none + // in its journal and has one (data::settings). + let from_file = + !kept.is_journaled() && crate::data::legacy_toml(&b.data_dir).is_some(); // The currency is always known: facts are the rest. if c.profile.iter().all(|(k, _)| k == "base_currency") { ui.label( - RichText::new(if kept.is_journaled() { - "No company facts set yet: the agent records them as you give them." - } else { + RichText::new(if from_file { "No company facts in the settings file yet: the agent records them as you give them." + } else { + "No company facts set yet: the agent records them as you give them." }) .color(p.muted), ); @@ -177,10 +182,10 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { kv(ui, "Time zone", z); } ui.label( - RichText::new(if kept.is_journaled() { - "Kept in the books: the agent changes settings, with who decided and when." - } else { + RichText::new(if from_file { "Read from the settings file: the agent can move it into the books." + } else { + "Kept in the books: the agent changes settings, with who decided and when." }) .font(theme::regular(12.0)) .color(p.muted), diff --git a/crates/ledger-gui/src/pages/documents.rs b/crates/ledger-gui/src/pages/documents.rs index a789940..b921cdb 100644 --- a/crates/ledger-gui/src/pages/documents.rs +++ b/crates/ledger-gui/src/pages/documents.rs @@ -253,6 +253,8 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) { RichText::new(&a.file_name).font(theme::bold(20.0)), &a.file_name, ); + ui.add_space(6.0); + file_actions(ui, b, a); ui.add_space(8.0); egui::Grid::new(("docfacts", a.id)) .num_columns(2) @@ -324,6 +326,44 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) { }); } +/// "Save a copy" and "Open" (plan 3.6): a copy in the Downloads folder, +/// never over a file there, opened with the system's app on "Open". The +/// books are only read. What came of it shows under the buttons. +fn file_actions(ui: &mut Ui, b: &Books, a: &Attachment) { + let p = pal(ui.ctx()); + let said = ui.id().with(("file-said", a.id)); + ui.horizontal(|ui| { + let save = ui + .button(format!("{} Save a copy", icon::FILE_ARROW_UP)) + .on_hover_text("A copy in your Downloads folder"); + let open = ui + .button(format!("{} Open", icon::ARROW_SQUARE_OUT)) + .on_hover_text("A copy in your Downloads folder, opened with its app"); + if save.clicked() || open.clicked() { + let result = save_copy(b, a, open.clicked()); + ui.data_mut(|d| d.insert_temp(said, result)); + } + }); + if let Some(result) = ui.data(|d| d.get_temp::>(said)) { + let (text, color) = match result { + Ok(path) => (format!("Saved {path}"), p.muted), + Err(why) => (format!("Couldn't save a copy: {why}"), p.warning), + }; + ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap()); + } +} + +/// A copy of `a` in the Downloads folder (opened if `open`): its path. +fn save_copy(b: &Books, a: &Attachment, open: bool) -> Result { + let bytes = b.attachment_bytes(a)?; + let (stem, ext) = crate::export::split_name(&a.file_name); + let path = crate::export::save(stem, ext, &bytes)?; + if open { + crate::export::open(&path); + } + Ok(path.display().to_string()) +} + /// The icon for a subject's kind. pub fn subject_icon(s: Subject) -> &'static str { match s { diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index 0f90d87..c0dc3fa 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -1053,6 +1053,9 @@ fn w9_card(ui: &mut Ui, b: &Books, party: &Party) { h, ); ui.end_row(); + k(ui, "The form"); + w9_save(ui, b, party.id); + ui.end_row(); } }); }, @@ -1563,6 +1566,34 @@ fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate) ); } +/// "Save the W-9": the signed form from the vault, a copy in the Downloads +/// folder (D-29: the viewer hands out what the agent can), never over a +/// file there; what came of it shows beside the button. +fn w9_save(ui: &mut Ui, b: &Books, party: PartyId) { + let p = pal(ui.ctx()); + let said = ui.id().with(("w9-said", party)); + ui.vertical(|ui| { + if ui + .button("Save the W-9") + .on_hover_text("A copy of the signed form in your Downloads folder") + .clicked() + { + let result = b.w9_file(party).and_then(|f| { + let (stem, ext) = crate::export::split_name(&f.file_name); + crate::export::save(stem, ext, &f.bytes).map(|path| path.display().to_string()) + }); + ui.data_mut(|d| d.insert_temp(said, result)); + } + if let Some(result) = ui.data(|d| d.get_temp::>(said)) { + let (text, color) = match result { + Ok(path) => (format!("Saved {path}"), p.muted), + Err(why) => (why, p.warning), + }; + ui.add(egui::Label::new(RichText::new(text).size(12.0).color(color)).wrap()); + } + }); +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-gui/src/pages/reports.rs b/crates/ledger-gui/src/pages/reports.rs index f07d03c..8972600 100644 --- a/crates/ledger-gui/src/pages/reports.rs +++ b/crates/ledger-gui/src/pages/reports.rs @@ -2191,7 +2191,6 @@ mod tests { crate::demo::build(&dir, today).unwrap(); let b = crate::data::load_once(&crate::data::Source { data_dir: dir.join("books-data"), - books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), }) .unwrap(); @@ -2257,7 +2256,6 @@ mod tests { crate::demo::build(&dir, today).unwrap(); let b = crate::data::load_once(&crate::data::Source { data_dir: dir.join("books-data"), - books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), }) .unwrap(); diff --git a/crates/ledger-gui/src/schemes.rs b/crates/ledger-gui/src/schemes.rs index d34b154..8227139 100644 --- a/crates/ledger-gui/src/schemes.rs +++ b/crates/ledger-gui/src/schemes.rs @@ -968,7 +968,6 @@ mod painted { crate::demo::build(&dir.0, today).unwrap(); let source = crate::data::Source { data_dir: dir.0.join("books-data"), - books_toml: Some(dir.0.join("wanderbooks.toml")), today: Some(today), }; let entry = crate::data::load_once(&source) diff --git a/crates/ledger-gui/src/test_support.rs b/crates/ledger-gui/src/test_support.rs index b5f6d1d..8c753d4 100644 --- a/crates/ledger-gui/src/test_support.rs +++ b/crates/ledger-gui/src/test_support.rs @@ -118,7 +118,6 @@ impl Build { drop(self.s); let books = data::load_once(&Source { data_dir, - books_toml: None, today: Some(today()), }) .unwrap(); @@ -270,7 +269,6 @@ pub(crate) fn old_books(name: &str) -> (TempDir, Books) { std::fs::copy(from.join("journal.jsonl"), data_dir.join("journal.jsonl")).unwrap(); let books = data::load_once(&Source { data_dir, - books_toml: None, today: Some(today()), }) .unwrap(); diff --git a/crates/ledger-gui/tests/assets.rs b/crates/ledger-gui/tests/assets.rs index 3c52b57..5ad0bef 100644 --- a/crates/ledger-gui/tests/assets.rs +++ b/crates/ledger-gui/tests/assets.rs @@ -14,7 +14,7 @@ use ledger_gui::pages::assets::{MonthStatus, TaxFacts, missing_tax_notice, notic use ledger_gui::pages::journal::describe; use ledger_gui::{App, Source, attention, data, fmt, palette}; use ledger_store::Store; -use ledger_store::viewer::ViewerTarget; +use ledger_store::ops::ViewerTarget; use proptest::prelude::*; use rust_decimal::Decimal; use std::path::{Path, PathBuf}; @@ -54,7 +54,6 @@ fn usd(cents: i64) -> Money { fn load(dir: &Path, today: NaiveDate) -> Books { data::load_once(&Source { data_dir: dir.join("books-data"), - books_toml: None, today: Some(today), }) .unwrap() diff --git a/crates/ledger-gui/tests/attachments.rs b/crates/ledger-gui/tests/attachments.rs index 5f7b142..a483f24 100644 --- a/crates/ledger-gui/tests/attachments.rs +++ b/crates/ledger-gui/tests/attachments.rs @@ -59,7 +59,6 @@ fn today() -> NaiveDate { fn load(dir: &Path) -> Books { data::load_once(&Source { data_dir: dir.join("books-data"), - books_toml: None, today: Some(today()), }) .unwrap() @@ -589,7 +588,7 @@ fn every_new_page_renders() { #[test] fn books_show_opens_a_reconciliation() { - use ledger_store::viewer::ViewerTarget; + use ledger_store::ops::ViewerTarget; let mut f = fixture("show"); let (_, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31)); let books = load(&f.dir.0); diff --git a/crates/ledger-gui/tests/breadcrumbs.rs b/crates/ledger-gui/tests/breadcrumbs.rs index 094fea2..2c86b62 100644 --- a/crates/ledger-gui/tests/breadcrumbs.rs +++ b/crates/ledger-gui/tests/breadcrumbs.rs @@ -34,7 +34,6 @@ fn demo(tag: &str) -> (TestDir, Source) { ledger_gui::demo::build(&dir, today).unwrap(); let source = Source { data_dir: dir.join("books-data"), - books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), }; (TestDir(dir), source) diff --git a/crates/ledger-gui/tests/copy.rs b/crates/ledger-gui/tests/copy.rs index d765fbd..c9fded8 100644 --- a/crates/ledger-gui/tests/copy.rs +++ b/crates/ledger-gui/tests/copy.rs @@ -68,7 +68,6 @@ fn clicking_a_value_copies_it() { ledger_gui::demo::build(&dir, today).unwrap(); let source = Source { data_dir: dir.join("books-data"), - books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), }; let open = |route: Route| { @@ -80,7 +79,7 @@ fn clicking_a_value_copies_it() { h }; - // The company's EIN, as written in wanderbooks.toml. + // The company's EIN, as the demo journals it. let ein = data::load_once(&source) .unwrap() .settings diff --git a/crates/ledger-gui/tests/edges.rs b/crates/ledger-gui/tests/edges.rs index 9108180..3256405 100644 --- a/crates/ledger-gui/tests/edges.rs +++ b/crates/ledger-gui/tests/edges.rs @@ -107,7 +107,6 @@ fn fixture(tag: &str) -> Fixture { drop(s); let books = data::load_once(&Source { data_dir: data, - books_toml: None, today: Some(today()), }) .unwrap(); diff --git a/crates/ledger-gui/tests/exports.rs b/crates/ledger-gui/tests/exports.rs new file mode 100644 index 0000000..32ae3f7 --- /dev/null +++ b/crates/ledger-gui/tests/exports.rs @@ -0,0 +1,490 @@ +//! What the viewer saves for the human (plan 3.6, P2's viewer menu, +//! D-29): a copy of a document, a party's W-9, the CPA package and the +//! 1099 worksheet, each a new file in the downloads folder, never over +//! one; and for a book in the data root, the vault is the book's own. +//! Nothing here writes the books. + +use chrono::NaiveDate; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use ledger_core::{AccountType, Currency, Money, Role, Side, TinFacts, TinType}; +use ledger_gui::App; +use ledger_gui::books::Books; +use ledger_gui::data::{self, Source}; +use ledger_gui::menu::Job; +use ledger_gui::nav::Route; +use ledger_store::Store; +use ledger_vault::{Tin, Vault, W9File}; +use rust_decimal::Decimal; +use std::io::Read; +use std::path::{Path, PathBuf}; + +const SSN: &str = "620-37-1111"; + +/// This test binary's downloads folder: its own, never the human's. +fn downloads() -> PathBuf { + static DIR: std::sync::OnceLock = std::sync::OnceLock::new(); + DIR.get_or_init(|| { + let d = tempfile::Builder::new() + .prefix("wb-gui-downloads-") + .tempdir() + .unwrap() + .keep(); + ledger_gui::export::set_downloads_dir(d.clone()); + d + }) + .clone() +} + +/// A fresh folder in the downloads folder for one test (tests run side by +/// side): the files saved there are this test's. +fn saved_files() -> Vec { + let mut names: Vec = std::fs::read_dir(downloads()) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + names +} + +fn today() -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 10, 15).unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(s.parse::().unwrap(), Currency::USD) +} + +/// A book in a data root (its folder holds the root's marker), with a +/// checking account, the member's capital, contract labor, a contractor +/// paid past the threshold, a member whose SSN is in the book's own vault +/// (key in its own keys/), a contractor's W-9 there too, and a document. +struct RootBook { + _root: tempfile::TempDir, + book: PathBuf, + member: u64, + payee: u64, + document: u64, +} + +fn root_book() -> RootBook { + let root = tempfile::Builder::new() + .prefix("wb-gui-root-") + .tempdir() + .unwrap(); + std::fs::write(root.path().join(ledger_store::root::MARKER_FILE), b"").unwrap(); + let book = root.path().join("books").join("acme"); + std::fs::create_dir_all(&book).unwrap(); + let mut s = Store::create(&book, Currency::USD).unwrap(); + let import = ledger_config::codec::import( + "[company]\nname = \"Acme LLC\"\n\n[[company.members]]\nname = \"Alex Owner\"\nshare = \"1\"\ncapital_account = \"3000\"\n\n[books]\ncash_accounts = [\"1000\"]\n", + None, + s.ledger().settings(), + "USD", + ) + .unwrap(); + s.adopt_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "test".into(), + decided_on: today(), + citation: None, + note: None, + }, + ) + .unwrap(); + for (code, name, ty) in [ + ("1000", "Checking", AccountType::Asset), + ("3000", "Capital", AccountType::Equity), + ("6100", "Contract labor", AccountType::Expense), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + let member = s.add_party("Alex Owner", &[Role::Member]).unwrap(); + let payee = s.add_party("Jo Contractor", &[Role::Contractor]).unwrap(); + // The vault and its key, the book's own (as the server keeps them). + let key = book + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE); + std::fs::create_dir_all(key.parent().unwrap()).unwrap(); + let v = Vault::open(&book.join(ledger_store::root::VAULT_DIR), &key, true).unwrap(); + let hash = v + .put_tin(member, &Tin::new(SSN, ledger_vault::TinType::Ssn).unwrap()) + .unwrap(); + let w9_hash = v + .put_w9( + payee, + &W9File { + file_name: "jo-w9.pdf".into(), + mime_type: "application/pdf".into(), + bytes: b"%PDF-1.4 Jo's W-9".to_vec(), + }, + ) + .unwrap(); + s.set_party_w9( + payee, + Some(ledger_core::W9Facts { + received: today(), + entity_type: "individual/sole proprietor".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: "2222".into(), + kept_at: "wanderbooks vault".into(), + vault_record: Some(w9_hash), + }), + ) + .unwrap(); + s.set_party_tin( + member, + Some(TinFacts { + tin_type: TinType::Ssn, + last4: "1111".into(), + vault_record: Some(hash), + purged: None, + }), + ) + .unwrap(); + let (document, _, _) = s + .file_document( + "legal", + "operating agreement.pdf", + "application/pdf", + b"%PDF-1.4 OA", + None, + ) + .unwrap(); + let e = s.create_entry(today(), "Opening contribution").unwrap(); + for (code, side) in [("1000", Side::Debit), ("3000", Side::Credit)] { + let id = s.ledger().chart().find_by_code(code).unwrap().id; + s.add_line(e, id, side, usd("5000.00")).unwrap(); + } + s.post_entry(e).unwrap(); + RootBook { + _root: root, + book, + member, + payee, + document, + } +} + +fn load(book: &Path) -> Books { + data::load_once(&Source { + data_dir: book.to_path_buf(), + today: Some(today()), + }) + .unwrap() +} + +#[test] +fn a_book_in_the_root_reads_its_own_vault() { + let f = root_book(); + let b = load(&f.book); + assert_eq!(b.tin_from_vault(f.member).unwrap(), SSN); + let w9 = b.w9_file(f.payee).unwrap(); + assert_eq!( + (w9.file_name.as_str(), w9.bytes.as_slice()), + ("jo-w9.pdf", &b"%PDF-1.4 Jo's W-9"[..]) + ); + let e = b.w9_file(f.member).unwrap_err(); + assert!(e.contains("no W-9 file"), "{e}"); + // Its key is the book's own: one named on the command line is never + // used for it (none is named here, and none is needed). + assert!(ledger_gui::vault_key_file().is_none()); +} + +#[test] +fn a_document_is_saved_as_a_new_copy_from_its_page() { + downloads(); + let f = root_book(); + let b = load(&f.book); + let name = |n: &str| format!("operating agreement{n}.pdf"); + let mut h = Harness::builder() + .with_size(egui::vec2(1200.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Document(f.document))); + h.run_steps(3); + for n in ["", " (1)"] { + h.get_by_label_contains("Save a copy").click(); + h.run_steps(3); + assert!(saved_files().contains(&name(n)), "{:?}", saved_files()); + assert_eq!( + std::fs::read(downloads().join(name(n))).unwrap(), + b"%PDF-1.4 OA" + ); + } + // What came of it shows on the page. + let said = h.query_by_label_contains("Saved ").is_some(); + assert!(said, "the page doesn't say where the copy went"); +} + +#[test] +fn the_menu_makes_the_cpa_package_with_full_tins() { + downloads(); + let f = root_book(); + let b = load(&f.book); + let (path, note) = Job::Package.run(&b).unwrap(); + assert_eq!(path.parent().unwrap(), downloads()); + assert!( + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with("cpa-package-2026-10-15") + ); + assert_eq!(note, None, "every TIN is there"); + let mut zip = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap(); + let names: Vec = (0..zip.len()) + .map(|i| zip.by_index(i).unwrap().name().unwrap().to_string()) + .collect(); + assert_eq!(names[0], "README.txt"); + assert!( + names.contains(&"general-journal.iif".to_string()), + "{names:?}" + ); + assert!( + names.contains(&"documents/legal/operating agreement.pdf".to_string()), + "{names:?}" + ); + let mut readme = String::new(); + zip.by_name("README.txt") + .unwrap() + .read_to_string(&mut readme) + .unwrap(); + assert!(readme.starts_with("Acme LLC"), "{readme}"); + // The member's K-1 row carries their full SSN, from the book's vault. + let mut k1 = String::new(); + zip.by_name("k1-members.csv") + .unwrap() + .read_to_string(&mut k1) + .unwrap(); + assert!( + k1.contains(&format!("Alex Owner,{},", f.member)) && k1.contains(SSN), + "{k1}" + ); + // The books weren't touched: their head is as it was. + assert_eq!(load(&f.book).head, b.head); +} + +#[test] +fn the_menu_makes_the_1099_worksheet_with_tins() { + downloads(); + let f = root_book(); + let b = load(&f.book); + let (path, _) = Job::Worksheet(2026).run(&b).unwrap(); + assert!( + path.file_name() + .unwrap() + .to_string_lossy() + .starts_with("1099-worksheet-2026-with-tins"), + "{}", + path.display() + ); + let csv = std::fs::read_to_string(&path).unwrap(); + assert!(csv.starts_with("vendor_id,name,"), "{csv}"); + assert!(csv.lines().next().unwrap().contains(",tin,"), "{csv}"); +} + +#[test] +fn a_locked_vault_still_makes_the_package_and_says_what_is_missing() { + downloads(); + let f = root_book(); + std::fs::remove_file( + f.book + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE), + ) + .unwrap(); + let b = load(&f.book); + let (_, note) = Job::Package.run(&b).unwrap(); + let note = note.expect("the missing TIN is noted"); + assert!(note.contains("1 TIN(s)"), "{note}"); + // Never a key made by the viewer. + assert!( + !f.book + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE) + .exists() + ); +} + +#[test] +fn a_parties_w9_is_saved_from_its_page() { + downloads(); + let f = root_book(); + let b = load(&f.book); + let mut h = Harness::builder() + .with_size(egui::vec2(1200.0, 1250.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Party(f.payee))); + h.run_steps(3); + let before = saved_files(); + h.get_by_label("Save the W-9").click(); + h.run_steps(3); + // Only this test saves a W-9 (others save into the same folder at the + // same time). + let new: Vec = saved_files() + .into_iter() + .filter(|n| !before.contains(n) && n.starts_with("jo-w9")) + .collect(); + assert_eq!(new.len(), 1, "{new:?}"); + assert!(new[0].starts_with("jo-w9"), "{new:?}"); + assert_eq!( + std::fs::read(downloads().join(&new[0])).unwrap(), + b"%PDF-1.4 Jo's W-9" + ); +} + +/// `wanderbooks gui [BOOK]` (plan 3.6): the book named, else the root's +/// only or first one by id; never a folder that isn't a data root. +#[test] +fn the_viewer_finds_its_book_in_the_root() { + let home = tempfile::Builder::new() + .prefix("wb-gui-home-") + .tempdir() + .unwrap(); + let (root, _) = ledger_store::root::Root::setup(home.path(), None, None).unwrap(); + let r = root.path().to_path_buf(); + // No books yet. + let e = ledger_gui::book_in_root(Some(&r), None).unwrap_err(); + assert!(e.contains("no books yet"), "{e}"); + for id in ["zeta", "acme"] { + root.create_book( + &ledger_store::root::BookId::parse(id).unwrap(), + Currency::USD, + ) + .unwrap(); + } + let dir = |id: &str| root.book_dir(&ledger_store::root::BookId::parse(id).unwrap()); + assert_eq!( + ledger_gui::book_in_root(Some(&r), Some("zeta")).unwrap(), + dir("zeta") + ); + assert_eq!( + ledger_gui::book_in_root(Some(&r), None).unwrap(), + dir("acme"), + "the first by id" + ); + let e = ledger_gui::book_in_root(Some(&r), Some("nope")).unwrap_err(); + assert!(e.contains("no books called \"nope\""), "{e}"); + let e = ledger_gui::book_in_root(Some(&r), Some("../x")).unwrap_err(); + assert!(!e.is_empty()); + // A folder that isn't a data root is never opened as one. + let plain = tempfile::Builder::new() + .prefix("wb-gui-plain-") + .tempdir() + .unwrap(); + let e = ledger_gui::book_in_root(Some(plain.path()), None).unwrap_err(); + assert!(e.contains("no books to show"), "{e}"); +} + +/// The 1099 worksheet the menu makes carries a reportable payee's full +/// TIN from the vault. +#[test] +fn the_menus_1099_worksheet_carries_a_payees_full_tin() { + downloads(); + let f = root_book(); + { + let mut s = Store::open(&f.book).unwrap(); + // A contractor marked for a 1099-NEC, paid past 2026's threshold. + let vendor = s.add_vendor("Pat Payee").unwrap(); + s.set_vendor_1099(vendor, Some(ledger_core::Form1099::Nec), true) + .unwrap(); + let party = s.ledger().party_of_vendor(vendor).unwrap(); + let key = f + .book + .join(ledger_store::root::KEYS_DIR) + .join(ledger_store::root::LEGACY_KEY_FILE); + let v = Vault::open(&f.book.join(ledger_store::root::VAULT_DIR), &key, false).unwrap(); + let hash = v + .put_tin( + party, + &Tin::new("607-97-2222", ledger_vault::TinType::Ssn).unwrap(), + ) + .unwrap(); + s.set_party_tin( + party, + Some(TinFacts { + tin_type: TinType::Ssn, + last4: "2222".into(), + vault_record: Some(hash), + purged: None, + }), + ) + .unwrap(); + let e = s.create_entry(today(), "Design work").unwrap(); + for (code, side) in [("6100", Side::Debit), ("1000", Side::Credit)] { + let id = s.ledger().chart().find_by_code(code).unwrap().id; + s.add_line(e, id, side, usd("2500.00")).unwrap(); + } + s.set_entry_vendor(e, Some(vendor)).unwrap(); + s.post_entry(e).unwrap(); + } + let b = load(&f.book); + let (path, note) = Job::Worksheet(2026).run(&b).unwrap(); + assert_eq!(note, None, "{note:?}"); + let csv = std::fs::read_to_string(&path).unwrap(); + let row = csv + .lines() + .find(|l| l.contains("Pat Payee")) + .unwrap_or_else(|| panic!("{csv}")); + assert!(row.contains("607-97-2222"), "{csv}"); +} + +/// The menu reads settings as the rest of the viewer and the server do: +/// an older books dir that keeps none in its journal, from its file. +#[test] +fn the_menu_reads_an_older_books_dirs_settings_file() { + downloads(); + let dir = tempfile::Builder::new() + .prefix("wb-gui-older-") + .tempdir() + .unwrap(); + let data = dir.path().join("books-data"); + { + let mut s = Store::create(&data, Currency::USD).unwrap(); + for (code, name, ty) in [ + ("1000", "Checking", AccountType::Asset), + ("3000", "Capital", AccountType::Equity), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + } + std::fs::write( + dir.path().join("wanderbooks.toml"), + "[company]\nname = \"File Kept Co\"\n[books]\ncash_accounts = [\"1000\"]\n", + ) + .unwrap(); + let b = load(&data); + assert_eq!(b.settings.company.name, "File Kept Co"); + let (path, _) = Job::Package.run(&b).unwrap(); + let mut zip = zip::ZipArchive::new(std::fs::File::open(&path).unwrap()).unwrap(); + let mut readme = String::new(); + zip.by_name("README.txt") + .unwrap() + .read_to_string(&mut readme) + .unwrap(); + assert!(readme.starts_with("File Kept Co"), "{readme}"); +} + +/// The worksheet says when a reportable payee's TIN isn't in it (the +/// vault locked). +#[test] +fn the_menus_worksheet_says_which_payees_lack_a_tin() { + downloads(); + let f = root_book(); + { + let mut s = Store::open(&f.book).unwrap(); + let vendor = s.add_vendor("Pat Payee").unwrap(); + s.set_vendor_1099(vendor, Some(ledger_core::Form1099::Nec), true) + .unwrap(); + let e = s.create_entry(today(), "Design work").unwrap(); + for (code, side) in [("6100", Side::Debit), ("1000", Side::Credit)] { + let id = s.ledger().chart().find_by_code(code).unwrap().id; + s.add_line(e, id, side, usd("2500.00")).unwrap(); + } + s.set_entry_vendor(e, Some(vendor)).unwrap(); + s.post_entry(e).unwrap(); + } + let b = load(&f.book); + let (_, note) = Job::Worksheet(2026).run(&b).unwrap(); + let note = note.expect("the missing TIN is noted"); + assert!(note.starts_with("1 reportable payee(s)"), "{note}"); +} diff --git a/crates/ledger-gui/tests/kept.rs b/crates/ledger-gui/tests/kept.rs index f3aca5a..4276acc 100644 --- a/crates/ledger-gui/tests/kept.rs +++ b/crates/ledger-gui/tests/kept.rs @@ -73,7 +73,6 @@ fn item(title: &str, due: Option) -> NewOpenItem { fn company(dir: &TempDir) -> Harness<'static, App> { let books = data::load_once(&ledger_gui::Source { data_dir: dir.0.join("books-data"), - books_toml: None, today: Some(today()), }) .unwrap(); diff --git a/crates/ledger-gui/tests/live.rs b/crates/ledger-gui/tests/live.rs index 04f57d4..fab2369 100644 --- a/crates/ledger-gui/tests/live.rs +++ b/crates/ledger-gui/tests/live.rs @@ -1,6 +1,7 @@ //! Live updates: a change written to the books (by the MCP server, in //! practice) reaches the GUI's view model on its own, without a restart, -//! and the changed entry is marked fresh. +//! and the changed entry is marked fresh; a show request reaches it, and +//! while it runs it holds the books' viewer presence. use chrono::NaiveDate; use ledger_core::{AccountType, Currency, Money, Side}; @@ -32,12 +33,15 @@ fn a_change_in_another_process_shows_up_live() { .add_account("3000", "Capital", AccountType::Equity, None, false) .unwrap(); drop(store); + // The server makes the books' ops/ folder when it opens them; no + // viewer has them open yet. + ledger_store::ops::prepare(&data).unwrap(); + assert!(!ledger_store::ops::viewer_open(&data)); let ctx = egui::Context::default(); let loader = Loader::spawn( Source { data_dir: data.clone(), - books_toml: None, today: NaiveDate::from_ymd_opt(2026, 10, 12), }, ctx, @@ -47,6 +51,15 @@ fn a_change_in_another_process_shows_up_live() { _ => panic!("no initial load"), }; assert_eq!(first.kpis.cash, Decimal::ZERO); + // The running viewer holds its presence there (ops INVARIANT 3). + let deadline = Instant::now() + Duration::from_secs(10); + while !ledger_store::ops::viewer_open(&data) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(20)); + } + assert!( + ledger_store::ops::viewer_open(&data), + "the running viewer holds no presence" + ); // The writer (the MCP server's store) posts an entry. let started = Instant::now(); @@ -77,15 +90,15 @@ fn a_change_in_another_process_shows_up_live() { // The agent asks the viewer to show the entry (books.show). let asked = Instant::now(); - ledger_store::viewer::request( + ledger_store::ops::request( &data, - ledger_store::viewer::ViewerTarget::Entry { entry_id: e }, + ledger_store::ops::ViewerTarget::Entry { entry_id: e }, ) .unwrap(); let deadline = Instant::now() + Duration::from_secs(10); let mut shown = false; while Instant::now() < deadline { - if let Some(Update::Show(ledger_store::viewer::ViewerTarget::Entry { entry_id })) = + if let Some(Update::Show(ledger_store::ops::ViewerTarget::Entry { entry_id })) = loader.wait(Duration::from_millis(200)) { assert_eq!(entry_id, e); @@ -95,5 +108,55 @@ fn a_change_in_another_process_shows_up_live() { } assert!(shown, "the show request never reached the viewer"); eprintln!("show request arrived in {:?}", asked.elapsed()); - let _ = std::fs::remove_file(ledger_store::viewer::command_path(&data)); + + // The viewer closed: its presence goes with it, within a poll. + drop(loader); + let deadline = Instant::now() + Duration::from_secs(10); + while ledger_store::ops::viewer_open(&data) && Instant::now() < deadline { + std::thread::sleep(Duration::from_millis(50)); + } + assert!( + !ledger_store::ops::viewer_open(&data), + "a closed viewer still holds its presence" + ); +} + +/// An older books dir that keeps no settings in its journal and has no +/// settings file: one written while the viewer runs is followed, as its +/// server follows it. +#[test] +fn a_settings_file_made_later_is_followed() { + let dir = std::env::temp_dir().join(format!("ledger-gui-live-toml-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + let _cleanup = TestDir(dir.clone()); + let data = dir.join("books-data"); + drop(Store::create(&data, Currency::USD).unwrap()); + let loader = Loader::spawn( + Source { + data_dir: data.clone(), + today: NaiveDate::from_ymd_opt(2026, 10, 12), + }, + egui::Context::default(), + ); + match loader.wait(Duration::from_secs(10)) { + Some(Update::Books(b)) => assert_ne!(b.settings.company.name, "Made Later Co"), + _ => panic!("no initial load"), + } + std::fs::write( + dir.join("wanderbooks.toml"), + "[company]\nname = \"Made Later Co\"\n", + ) + .unwrap(); + let deadline = Instant::now() + Duration::from_secs(10); + loop { + assert!( + Instant::now() < deadline, + "the new settings file was never read" + ); + match loader.wait(Duration::from_millis(200)) { + Some(Update::Books(b)) if b.settings.company.name == "Made Later Co" => break, + Some(Update::Error(e)) => panic!("the settings file didn't load: {e}"), + _ => {} + } + } } diff --git a/crates/ledger-gui/tests/newer_journal.rs b/crates/ledger-gui/tests/newer_journal.rs index 4b4f9c2..d8df45d 100644 --- a/crates/ledger-gui/tests/newer_journal.rs +++ b/crates/ledger-gui/tests/newer_journal.rs @@ -50,7 +50,6 @@ fn books(tag: &str) -> DataDir { fn source(data: &Path) -> Source { Source { data_dir: data.to_path_buf(), - books_toml: None, today: NaiveDate::from_ymd_opt(2026, 10, 16), } } diff --git a/crates/ledger-gui/tests/old_books.rs b/crates/ledger-gui/tests/old_books.rs index e40bff9..01c5fc8 100644 --- a/crates/ledger-gui/tests/old_books.rs +++ b/crates/ledger-gui/tests/old_books.rs @@ -75,17 +75,34 @@ fn copy_books(from: &Path, to: &Path) { } } -/// The settings the viewer reads beside the books: two members (the -/// shares fixtures' parties), the cash account, a fiscal year from July. +/// The settings the old books are viewed with: two members (the shares +/// fixtures' parties), the cash account, a fiscal year from July. +const SETTINGS: &str = "[company]\nname = \"Old Books Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\ + [[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ + [[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ + [books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\n"; + +/// [`SETTINGS`] journaled into the books in `dir`, as the server adopts a +/// settings file, unless they already keep their own (P1's books): the +/// viewer reads settings only from the journal (plan 3.6). fn write_settings(dir: &Path) { - std::fs::write( - dir.join("wanderbooks.toml"), - "[company]\nname = \"Old Books Co\"\nfiscal_year_start = \"07-01\"\ntax_form = \"1065\"\n\ - [[company.members]]\nname = \"Member A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ - [[company.members]]\nname = \"Member B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ - [books]\nreceipt_threshold = \"25.00\"\ncash_accounts = [\"1000\"]\n", - ) - .unwrap(); + let mut store = ledger_store::Store::open(&dir.join("books-data")).unwrap(); + if store.ledger().settings().is_journaled() { + return; + } + let import = + ledger_config::codec::import(SETTINGS, None, store.ledger().settings(), "USD").unwrap(); + store + .adopt_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "the test".into(), + decided_on: d(2026, 10, 17), + citation: None, + note: None, + }, + ) + .unwrap(); } /// The fixtures whose index is `shard` mod `of`, one scratch books @@ -261,7 +278,6 @@ fn every_page_renders(shard: usize, of: usize) { } let source = Source { data_dir: dir.0.join("books-data"), - books_toml: Some(dir.0.join("wanderbooks.toml")), today: Some(today), }; let books = data::load_once(&source) @@ -367,7 +383,6 @@ fn stage(name: &str) -> TempDir { fn load(dir: &TempDir) -> Books { data::load_once(&Source { data_dir: dir.0.join("books-data"), - books_toml: Some(dir.0.join("wanderbooks.toml")), today: Some(d(TODAY.0, TODAY.1, TODAY.2)), }) .unwrap() @@ -420,12 +435,12 @@ fn figures_that_cant_be_computed_are_said() { ); } -/// Books that keep their own settings (written under P1) show them, never -/// the settings file beside them, which here says otherwise (another -/// name, a July fiscal year, a $25 receipt rule): the viewer reads the -/// settings as the server does (plan/served.md P1, Accept). +/// Books that keep their own settings (written under P1) show them, as +/// the server reads them (plan/served.md P1, Accept; 3.6): never the +/// settings the other old books are viewed with (another name, a July +/// fiscal year, a $25 receipt rule). #[test] -fn books_that_keep_their_settings_never_read_the_file() { +fn books_that_keep_their_settings_show_them() { let dir = stage("p1_record"); let b = load(&dir); assert_eq!(b.settings.company.name, "Fixture Labs LLC"); @@ -458,3 +473,61 @@ fn books_that_keep_their_settings_never_read_the_file() { assert_ne!(e.text, line.line_type, "{} has no sentence", line.line_type); } } + +/// Books that keep no settings in their journal are read as their server +/// reads them (plan 3.6): an older books dir from its settings file; a +/// book of the data root never from a file, so with the defaults. +#[test] +fn books_that_keep_no_settings_read_their_file_only_outside_a_root() { + let from = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../ledger-store/tests/fixtures/old-books") + .join("shares_big"); + // An older books dir: its server reads wanderbooks.toml, so the viewer + // does too. + let t = temp("said-file"); + copy_books(&from, &t.0.join("books-data")); + std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap(); + let b = load(&t); + assert_eq!(b.settings.company.name, "Old Books Co"); + assert!(!b.settings.company.members.is_empty()); + // The same books in a data root: no file is read, whatever lies + // beside them; they show the defaults until their settings are in + // their journal. + let t = temp("said-defaults"); + std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap(); + copy_books(&from, &t.0.join("books-data")); + std::fs::write(t.0.join("wanderbooks.toml"), SETTINGS).unwrap(); + let b = load(&t); + assert_eq!( + b.settings, + ledger_config::codec::books_settings(&ledger_core::settings::Settings::default(), "USD") + ); +} + +/// An older books dir with no settings file but a `config.json`: its +/// receipt rule is the server's, from that file (the regression round 2 +/// of slice 6's review found). +#[test] +fn an_older_books_dirs_config_json_sets_its_receipt_rule() { + let from = Path::new(env!("CARGO_MANIFEST_DIR")) + .join("../ledger-store/tests/fixtures/old-books") + .join("shares_big"); + let t = temp("said-config"); + copy_books(&from, &t.0.join("books-data")); + std::fs::write( + t.0.join("books-data/config.json"), + r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#, + ) + .unwrap(); + assert_eq!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO); + // In a data root, no file: the books' own rule (the default). + let t = temp("said-config-root"); + std::fs::write(t.0.join(ledger_store::root::MARKER_FILE), b"").unwrap(); + copy_books(&from, &t.0.join("books-data")); + std::fs::write( + t.0.join("books-data/config.json"), + r#"{"receipt_policy": {"require_receipts_above": "0.00"}}"#, + ) + .unwrap(); + assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO); +} diff --git a/crates/ledger-gui/tests/parties.rs b/crates/ledger-gui/tests/parties.rs index c3375a1..71aea4a 100644 --- a/crates/ledger-gui/tests/parties.rs +++ b/crates/ledger-gui/tests/parties.rs @@ -18,7 +18,7 @@ use ledger_gui::pages::parties::{ }; use ledger_gui::{App, Source, attention, data, palette}; use ledger_store::Store; -use ledger_store::viewer::ViewerTarget; +use ledger_store::ops::ViewerTarget; use rust_decimal::Decimal; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicUsize, Ordering}; @@ -65,7 +65,6 @@ fn today() -> NaiveDate { fn load(dir: &Path) -> Books { data::load_once(&Source { data_dir: dir.join("books-data"), - books_toml: None, today: Some(today()), }) .unwrap() @@ -892,22 +891,25 @@ fn counterparty_books( .unwrap(); let unnamed = post(&mut s, d(2026, 5, 1), "Mystery payment", labor, cash); if let Some(p) = policy { - std::fs::write( - dir.0.join("wanderbooks.toml"), - format!("[books]\nparties = \"{p}\"\n"), - ) - .unwrap(); + journal_settings(&mut s, &format!("[books]\nparties = \"{p}\"\n")); } (dir, s, tagged, transfer, unnamed) } -fn load_with_toml(dir: &Path) -> Books { - data::load_once(&Source { - data_dir: dir.join("books-data"), - books_toml: Some(dir.join("wanderbooks.toml")), - today: Some(today()), - }) - .unwrap() +/// The settings `toml` says, journaled into `s` as the server adopts a +/// settings file: the viewer reads settings only from the journal. +fn journal_settings(s: &mut Store, toml: &str) { + let import = ledger_config::codec::import(toml, None, s.ledger().settings(), "USD").unwrap(); + s.adopt_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "the test".into(), + decided_on: today(), + citation: None, + note: None, + }, + ) + .unwrap(); } #[test] @@ -920,7 +922,7 @@ fn entries_without_a_counterparty_need_attention_unless_off() { }; // Unset is suggest: one note naming just the unmarked entry. let (dir, mut s, _, transfer, unnamed) = counterparty_books("np-suggest", None); - let b = load_with_toml(&dir.0); + let b = load(&dir.0); let a = item(&b).expect("an item"); assert_eq!(a.title, "1 entry names no counterparty"); assert_eq!(a.severity, Severity::Info); @@ -961,17 +963,14 @@ fn entries_without_a_counterparty_need_attention_unless_off() { // Tagging the last one clears it. let p = s.add_party("Mystery Co", &[Role::Contractor]).unwrap(); s.set_entry_party(unnamed, Some(p)).unwrap(); - assert!(item(&load_with_toml(&dir.0)).is_none()); + assert!(item(&load(&dir.0)).is_none()); // Require: a to-do. let (dir, _s, ..) = counterparty_books("np-require", Some("require")); - assert_eq!( - item(&load_with_toml(&dir.0)).unwrap().severity, - Severity::Todo - ); + assert_eq!(item(&load(&dir.0)).unwrap().severity, Severity::Todo); // Off: never mentioned. let (dir, _s, ..) = counterparty_books("np-off", Some("off")); - assert!(item(&load_with_toml(&dir.0)).is_none()); + assert!(item(&load(&dir.0)).is_none()); } // ------------------------------------------------------- party shares and the capital statement @@ -1108,7 +1107,8 @@ fn member_books(tag: &str) -> MemberBooks { ]), ) .unwrap(); - write_member_toml(&dir.0, "cash_accounts = [\"1000\"]\n"); + drop(s); + member_settings(&dir.0, "cash_accounts = [\"1000\"]\n"); MemberBooks { dir, tobin, @@ -1119,17 +1119,17 @@ fn member_books(tag: &str) -> MemberBooks { } } -/// The fixture's wanderbooks.toml, with `books` as its `[books]` table. -fn write_member_toml(dir: &Path, books: &str) { - std::fs::write( - dir.join("wanderbooks.toml"), - format!( +/// The fixture's settings, `books` as their `[books]` table, journaled +/// into its books. +fn member_settings(dir: &Path, books: &str) { + journal_settings( + &mut Store::open(&dir.join("books-data")).unwrap(), + &format!( "[books]\n{books}[company]\nname = \"Fernhill Labs LLC\"\n\ [[company.members]]\nname = \"Tobin Harlow\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ [[company.members]]\nname = \"Morgan Ellis\"\nshare = \"50%\"\ncapital_account = \"3001\"\n" ), - ) - .unwrap(); + ); } /// Post one entry to the fixture's books after the fact. @@ -1147,7 +1147,7 @@ fn post_more(m: &MemberBooks, date: NaiveDate, lines: &[(&str, Side, i64)]) -> E #[test] fn entries_list_every_party_with_its_resolved_share() { let m = member_books("shares-entries"); - let b = load_with_toml(&m.dir.0); + let b = load(&m.dir.0); let e = b.entry(m.reimburse).unwrap(); assert_eq!(e.party_id, None, "two parties: no sole party"); assert_eq!( @@ -1181,7 +1181,7 @@ fn entries_list_every_party_with_its_resolved_share() { #[test] fn a_members_money_is_their_share() { let m = member_books("shares-flow"); - let b = load_with_toml(&m.dir.0); + let b = load(&m.dir.0); let span = (d(2026, 1, 1), today()); for who in [m.tobin, m.mo] { let f = b.party_flow(who, span, true); @@ -1199,7 +1199,7 @@ fn a_members_money_is_their_share() { #[test] fn a_members_page_is_their_capital_statement() { let m = member_books("capital"); - let b = load_with_toml(&m.dir.0); + let b = load(&m.dir.0); let st = b .capital_statement(m.tobin, (d(2026, 1, 1), today())) .expect("a member"); @@ -1249,7 +1249,7 @@ fn a_members_page_is_their_capital_statement() { let mut s = Store::open(&m.dir.0.join("books-data")).unwrap(); let other = s.add_party("Riley Chen", &[Role::Contractor]).unwrap(); drop(s); - let b = load_with_toml(&m.dir.0); + let b = load(&m.dir.0); assert!( b.capital_statement(other, (d(2026, 1, 1), today())) .is_none() @@ -1280,8 +1280,8 @@ fn the_capital_statement_is_the_k1_worksheet_row() { ], ); for (books, fallback) in [("cash_accounts = [\"1000\"]\n", false), ("", true)] { - write_member_toml(&m.dir.0, books); - let b = load_with_toml(&m.dir.0); + member_settings(&m.dir.0, books); + let b = load(&m.dir.0); let chart = b.ledger.chart(); let id = |c: &str| chart.find_by_code(c).unwrap().id; let members = @@ -1368,7 +1368,7 @@ fn the_capital_statement_counts_income_not_yet_closed() { ("4000", Side::Credit, 10_000), ], ); - let b = load_with_toml(&m.dir.0); + let b = load(&m.dir.0); let st = b .capital_statement(m.tobin, (d(2026, 1, 1), today())) .unwrap(); @@ -1425,18 +1425,18 @@ fn owed_to_members_reads_the_financing_accounts_first() { &[("6050", Side::Debit, 4_000), ("2200", Side::Credit, 4_000)], ); let span = (d(2026, 1, 1), today()); - write_member_toml( + member_settings( &m.dir.0, "cash_accounts = [\"1000\"]\nfinancing_accounts = [\"2200\"]\n", ); - let b = load_with_toml(&m.dir.0); + let b = load(&m.dir.0); let st = b.capital_statement(m.mo, span).unwrap(); assert_eq!( (st.due_to_members, st.owed_to_member), (cents(4_000), cents(2_000)) ); - write_member_toml(&m.dir.0, "cash_accounts = [\"1000\"]\n"); - let b = load_with_toml(&m.dir.0); + member_settings(&m.dir.0, "cash_accounts = [\"1000\"]\n"); + let b = load(&m.dir.0); let st = b.capital_statement(m.mo, span).unwrap(); assert_eq!( (st.due_to_members, st.owed_to_member), @@ -1452,8 +1452,8 @@ fn owed_to_members_reads_the_financing_accounts_first() { #[test] fn without_cash_accounts_the_capital_statement_reads_money_as_core_does() { let m = member_books("capital-no-cash"); - write_member_toml(&m.dir.0, ""); - let b = load_with_toml(&m.dir.0); + member_settings(&m.dir.0, ""); + let b = load(&m.dir.0); let id = |c: &str| b.ledger.chart().find_by_code(c).unwrap().id; assert_eq!(b.money_cash_accounts, vec![id("1000"), id("1500")]); assert!( diff --git a/crates/ledger-gui/tests/read_only.rs b/crates/ledger-gui/tests/read_only.rs index 6254e20..6f09ce8 100644 --- a/crates/ledger-gui/tests/read_only.rs +++ b/crates/ledger-gui/tests/read_only.rs @@ -112,7 +112,6 @@ fn the_viewer_never_writes_into_the_books() { ledger_gui::demo::build(&dir, today).unwrap(); let source = Source { data_dir: dir.join("books-data"), - books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), }; set_read_only(&dir, true); diff --git a/crates/ledger-gui/tests/schemes.rs b/crates/ledger-gui/tests/schemes.rs index 46b2640..9f90740 100644 --- a/crates/ledger-gui/tests/schemes.rs +++ b/crates/ledger-gui/tests/schemes.rs @@ -25,7 +25,6 @@ fn demo(tag: &str) -> (TestDir, data::Source) { ledger_gui::demo::build(&dir, today).unwrap(); let source = data::Source { data_dir: dir.join("books-data"), - books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), }; (TestDir(dir), source) diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index 2227ced..b99d4eb 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -1,5 +1,5 @@ //! Screenshots of every screen, for eyes. Set `SHOT_BOOKS` to a books dir -//! (containing `books-data/` and `wanderbooks.toml`) and `SHOT_DIR` to +//! (containing `books-data/`, its settings in its journal) and `SHOT_DIR` to //! where the PNGs go; without them this test does nothing. use egui_kittest::Harness; @@ -43,7 +43,6 @@ fn screenshots() { std::fs::create_dir_all(&out).unwrap(); let source = Source { data_dir: books.join("books-data"), - books_toml: Some(books.join("wanderbooks.toml")), today: fixed_today.or_else(|| { std::env::var("SHOT_BOOKS") .is_err() diff --git a/crates/ledger-gui/tests/show.rs b/crates/ledger-gui/tests/show.rs index e530d6f..5f28b55 100644 --- a/crates/ledger-gui/tests/show.rs +++ b/crates/ledger-gui/tests/show.rs @@ -3,7 +3,7 @@ use egui_kittest::Harness; use ledger_gui::nav::{ReportKind, Route}; use ledger_gui::{App, Source, data}; -use ledger_store::viewer::ViewerTarget; +use ledger_store::ops::ViewerTarget; /// A test's dir, removed when the test ends, pass or panic. struct TestDir(std::path::PathBuf); @@ -23,7 +23,6 @@ fn show_requests_navigate_the_viewer() { ledger_gui::demo::build(&dir, today).unwrap(); let source = Source { data_dir: dir.join("books-data"), - books_toml: Some(dir.join("wanderbooks.toml")), today: Some(today), }; let books = data::load_once(&source).unwrap(); diff --git a/crates/ledger-gui/tests/spec_gui.rs b/crates/ledger-gui/tests/spec_gui.rs index 8ae4d64..0275a5b 100644 --- a/crates/ledger-gui/tests/spec_gui.rs +++ b/crates/ledger-gui/tests/spec_gui.rs @@ -759,24 +759,33 @@ fn usd(d: Decimal) -> Money { Money::new(d, Currency::USD) } -fn write_toml(dir: &Path, threshold: &str, cash: &[&str]) -> PathBuf { +/// The company, a receipt threshold and the cash accounts, journaled into +/// `store` as the server adopts a settings file: the viewer reads settings +/// only from the journal. +fn journal_settings(store: &mut Store, threshold: &str, cash: &[&str]) { let cash: Vec = cash.iter().map(|c| format!("\"{c}\"")).collect(); - let p = dir.join("wanderbooks.toml"); - std::fs::write( - &p, - format!( - "[company]\nname = \"Spec Test LLC\"\n\n[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [{}]\n", - cash.join(", ") - ), - ) - .unwrap(); - p + let toml = format!( + "[company]\nname = \"Spec Test LLC\"\n\n[books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [{}]\n", + cash.join(", ") + ); + let import = + ledger_config::codec::import(&toml, None, store.ledger().settings(), "USD").unwrap(); + store + .adopt_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "the test".into(), + decided_on: today(), + citation: None, + note: None, + }, + ) + .unwrap(); } -fn load(dir: &Path, toml: Option) -> Books { +fn load(dir: &Path) -> Books { load_once(&Source { data_dir: dir.join("books-data"), - books_toml: toml, today: Some(today()), }) .unwrap() @@ -862,12 +871,12 @@ struct Built { fn build_books(plan: &BooksPlan) -> Built { let dir = TempDir::new("books"); let data = dir.0.join("books-data"); - let toml = write_toml(&dir.0, plan.threshold, CASH_CODES); let mut store = Store::create(&data, Currency::USD).unwrap(); let ids: Vec = CHART .iter() .map(|(c, n, t)| store.add_account(c, n, *t, None, false).unwrap()) .collect(); + journal_settings(&mut store, plan.threshold, CASH_CODES); let start = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(); let mut entry_ids = vec![]; let mut posted = vec![]; @@ -937,7 +946,7 @@ fn build_books(plan: &BooksPlan) -> Built { reversal = Some((orig, rev)); } drop(store); - let books = load(&dir.0, Some(toml)); + let books = load(&dir.0); Built { _dir: dir, books, @@ -977,11 +986,8 @@ proptest! { ); } - let policy = ledger_config::effective_receipt_policy( - &built.data.join("config.json"), - &b.settings, - ) - .unwrap(); + // The receipt policy is the books' own, from their journal. + let policy = ledger_config::codec::receipt_policy(ledger.settings()); for &id in &built.entry_ids { let info = b.entry(id); @@ -1082,10 +1088,10 @@ fn add_rent(s: &mut Small, i: u32, post: bool) -> EntryId { } fn finish(s: Small) -> (TempDir, Books) { - let Small { dir, store, .. } = s; + let Small { dir, mut store, .. } = s; + journal_settings(&mut store, "0.00", &["1000"]); drop(store); - let toml = write_toml(&dir.0, "0.00", &["1000"]); - let b = load(&dir.0, Some(toml)); + let b = load(&dir.0); (dir, b) } @@ -1116,7 +1122,7 @@ fn attention_empty_books_have_nothing() { let dir = TempDir::new("att-bare"); drop(Store::create(&dir.0.join("books-data"), Currency::USD).unwrap()); - let b = load(&dir.0, None); + let b = load(&dir.0); assert!(attention::find(&b).is_empty()); } diff --git a/crates/ledger-gui/tests/tin_copy.rs b/crates/ledger-gui/tests/tin_copy.rs index 4e9b743..85a6c5e 100644 --- a/crates/ledger-gui/tests/tin_copy.rs +++ b/crates/ledger-gui/tests/tin_copy.rs @@ -73,7 +73,6 @@ fn fixture(tag: &str) -> Fixture { fn load(f: &Fixture, key: Option<&Path>) -> Books { let mut b = data::load_once(&Source { data_dir: f.books.join("books-data"), - books_toml: None, today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 16).unwrap()), }) .unwrap(); diff --git a/crates/ledger-gui/tests/vault_key_flag.rs b/crates/ledger-gui/tests/vault_key_flag.rs index 93a5c48..4d4a751 100644 --- a/crates/ledger-gui/tests/vault_key_flag.rs +++ b/crates/ledger-gui/tests/vault_key_flag.rs @@ -55,7 +55,6 @@ fn the_key_named_on_the_command_line_wins() { let mut b = data::load_once(&Source { data_dir: books.join("books-data"), - books_toml: None, today: Some(chrono::NaiveDate::from_ymd_opt(2026, 10, 17).unwrap()), }) .unwrap(); diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index 91f6d6e..22b538a 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -89,18 +89,16 @@ fn build(plan: &Plan, tag: &str) -> (TestDir, Source, ledger_core::Ledger) { s.add_line(e, ids[cr], Side::Credit, m).unwrap(); s.post_entry(e).unwrap(); } - std::fs::write( - d.join("wanderbooks.toml"), - format!( + journal_settings( + &mut s, + &format!( "[company]\nname = \"Oracle Co\"\nfiscal_year_start = \"{:02}-01\"\n[books]\ncash_accounts = [\"1000\", \"1010\"]\nreceipt_threshold = \"25.00\"\n", plan.fy_month ), - ) - .unwrap(); + ); let ledger = s.ledger().clone(); let source = Source { data_dir: d.join("books-data"), - books_toml: Some(d.join("wanderbooks.toml")), today: Some(plan.today), }; (d, source, ledger) @@ -261,15 +259,10 @@ fn origins_and_parties() { .attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF receipt", None) .unwrap(); let rev = s.reverse_entry(e, today, "undo").unwrap(); + journal_settings(&mut s, "[books]\nreceipt_threshold = \"0.00\"\n"); drop(s); - std::fs::write( - d.join("wanderbooks.toml"), - "[books]\nreceipt_threshold = \"0.00\"\n", - ) - .unwrap(); let source = Source { data_dir: d.join("books-data"), - books_toml: Some(d.join("wanderbooks.toml")), today: Some(today), }; let b = data::load_once(&source).unwrap(); @@ -326,11 +319,30 @@ fn store_at(tag: &str) -> (TestDir, Store) { (d, s) } +/// The settings `toml` says, journaled into `s` as the server adopts a +/// settings file: the viewer reads settings only from the journal. +fn journal_settings(s: &mut Store, toml: &str) { + let import = ledger_config::codec::import(toml, None, s.ledger().settings(), "USD").unwrap(); + s.adopt_settings( + &import.changes, + &ledger_core::settings::Source { + decided_by: "the test".into(), + decided_on: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), + citation: None, + note: None, + }, + ) + .unwrap(); +} + +/// The books in `d`, with the settings `toml` says journaled (none when +/// it's empty: the defaults). fn source(d: &std::path::Path, toml: &str, today: NaiveDate) -> Source { - std::fs::write(d.join("wanderbooks.toml"), toml).unwrap(); + if !toml.is_empty() { + journal_settings(&mut Store::open(&d.join("books-data")).unwrap(), toml); + } Source { data_dir: d.join("books-data"), - books_toml: Some(d.join("wanderbooks.toml")), today: Some(today), } } @@ -554,7 +566,7 @@ fn indexes_cash_cards_accounts_and_fresh() { let e2 = post(&mut s, today, rent, checking, 999); drop(s); let snap = ledger_store::Snapshot::load(&src.data_dir).unwrap(); - let settings = ledger_config::load(src.books_toml.as_ref().unwrap()).unwrap(); + let settings = ledger_config::codec::books_settings(snap.ledger().settings(), "USD"); let mut verified = std::collections::HashSet::new(); let integrity = ledger_gui::books::Integrity::check(&snap, &mut verified); let b2 = ledger_gui::books::Books::build(&snap, settings, today, Some(seq), integrity).unwrap(); diff --git a/crates/ledger-host/src/commands.rs b/crates/ledger-host/src/commands.rs index 93fbc5c..14c6aab 100644 --- a/crates/ledger-host/src/commands.rs +++ b/crates/ledger-host/src/commands.rs @@ -1486,109 +1486,6 @@ fn arrived_whole(headers: &reqwest::header::HeaderMap, body: &[u8]) -> bool { sent.as_deref() == Some(&::digest(body)[..]) } -/// Save `body` in `folder` as `named`, or `named (1)`, `named (2)`... the -/// first free; `fallback` if the folder takes no such name. Written aside -/// (a hidden file) and linked in whole, so the name never holds part of -/// it; never over another file. The path it went to. -fn save_new( - folder: &std::path::Path, - named: &str, - fallback: &str, - body: &[u8], -) -> Result { - use std::io::Write; - let fail = |what: &std::path::Path, e: std::io::Error| { - format!("can't write {}: {}", what.display(), e.kind()) - }; - let nanos = std::time::SystemTime::now() - .duration_since(std::time::UNIX_EPOCH) - .map_or(0, |d| d.subsec_nanos()); - let aside = folder.join(format!(".wanderbooks-{}-{nanos}.part", std::process::id())); - let mut f = std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&aside) - .map_err(|e| fail(folder, e))?; - if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) { - drop(f); - let _ = std::fs::remove_file(&aside); - return Err(fail(folder, e)); - } - drop(f); - let candidates = |name: &str| { - let (stem, ext) = match name.rsplit_once('.') { - Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), - _ => (name.to_string(), String::new()), - }; - let name = name.to_string(); - (0..1000).map(move |n| { - if n == 0 { - name.clone() - } else { - format!("{stem} ({n}){ext}") - } - }) - }; - let mut outcome = Err(format!("{} has no free name for it", folder.display())); - // Why the last name tried was refused, if not for being taken: what - // the human is told when no name is left (a full disk, not a name). - let mut last: Option = None; - 'names: for name in [named, fallback] { - for candidate in candidates(name) { - let path = folder.join(&candidate); - match std::fs::hard_link(&aside, &path) { - Ok(()) => { - outcome = Ok(path); - break 'names; - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - // A file system with no links (FAT): written in place, never - // over another file, removed if it can't be finished. - Err(e) - if matches!( - e.kind(), - std::io::ErrorKind::Unsupported | std::io::ErrorKind::PermissionDenied - ) => - { - match std::fs::OpenOptions::new() - .write(true) - .create_new(true) - .open(&path) - { - Ok(mut f) => { - if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) { - drop(f); - let _ = std::fs::remove_file(&path); - outcome = Err(fail(&path, e)); - last = None; - } else { - outcome = Ok(path); - } - break 'names; - } - Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} - Err(e) => { - last = Some(e); - continue 'names; - } - } - } - // A name this folder won't take (too long, a character it - // refuses): the next name. - Err(e) => { - last = Some(e); - continue 'names; - } - } - } - } - let _ = std::fs::remove_file(&aside); - match (outcome, last) { - (Err(_), Some(e)) => Err(fail(folder, e)), - (outcome, _) => outcome, - } -} - /// `wanderbooks download --book ID ATTACHMENT --to FOLDER`: save a copy of /// a stored file (one too large for books.get_receipt to return). pub fn download( @@ -1596,8 +1493,9 @@ pub fn download( args: impl IntoIterator, ) -> Result<(), Box> { let usage = || { - eprintln!("usage: {prog} --book ID [--token NAME] ATTACHMENT --to FOLDER"); - eprintln!(" Save a copy of the stored file ATTACHMENT in FOLDER (a new file)."); + eprintln!("usage: {prog} --book ID [--token NAME] ATTACHMENT|EXPORT --to FOLDER"); + eprintln!(" Save a copy of the stored file ATTACHMENT (a number), or of a file the"); + eprintln!(" books made (an EXPORT: ex_ and 32 letters), in FOLDER (a new file)."); }; let Some(a) = file_args(args, &usage)? else { return Ok(()); @@ -1606,13 +1504,19 @@ pub fn download( usage(); return Err(exit(2)); }; - let Some(attachment) = attachment - .to_str() - .filter(|a| a.parse::().is_ok_and(|n| n.to_string() == *a)) - else { - eprintln!("error: an attachment's id is a number"); - return Err(exit(2)); + // An attachment's id (a number as the books write it), or an + // export's: each has its own route. + let route = match attachment.to_str() { + Some(a) if a.parse::().is_ok_and(|n| n.to_string() == a) => "a", + Some(x) if ledger_store::exports::is_id(x) => "x", + _ => { + eprintln!( + "error: that is neither an attachment's id (a number) nor an export's (ex_ and 32 letters)" + ); + return Err(exit(2)); + } }; + let attachment = attachment.to_str().expect("checked above"); let fail = |why: String| -> Box { eprintln!("error: {why}"); exit(1) @@ -1621,7 +1525,7 @@ pub fn download( return Err(fail(format!("{} isn't a folder", to.display()))); } let (status, headers, body) = on_the_socket(&a.common, &a.book, &a.token, |http, base| { - http.get(format!("{base}/a/{attachment}")) + http.get(format!("{base}/{route}/{attachment}")) }) .map_err(fail)?; let path = keep(status, &headers, &body, attachment, &to).map_err(fail)?; @@ -1647,9 +1551,12 @@ fn keep( "the file arrived damaged (its SHA-256 isn't the stored one): nothing was saved".into(), ); } - let fallback = format!("attachment-{attachment}"); + let fallback = match attachment.strip_prefix("ex_") { + Some(_) => format!("export-{attachment}"), + None => format!("attachment-{attachment}"), + }; let named = saved_name(headers).unwrap_or_else(|| fallback.clone()); - save_new(to, &named, &fallback, body) + ledger_store::saving::save_new(to, &named, &fallback, body) } #[cfg(test)] @@ -1790,44 +1697,6 @@ mod tests { ); } - #[test] - fn a_saved_copy_is_new_and_whole_beside_what_is_there() { - let dir = tempfile::tempdir().unwrap(); - let d = dir.path(); - std::fs::write(d.join("r.pdf"), b"theirs").unwrap(); - let first = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap(); - assert_eq!(first, d.join("r (1).pdf")); - let second = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap(); - assert_eq!(second, d.join("r (2).pdf")); - assert_eq!(std::fs::read(d.join("r.pdf")).unwrap(), b"theirs"); - assert_eq!(std::fs::read(&first).unwrap(), b"ours"); - // A name the folder won't take: the fallback. - let long = "x".repeat(300); - assert_eq!( - save_new(d, &long, "attachment-7", b"ours").unwrap(), - d.join("attachment-7") - ); - // Nothing aside is left. - let mut names: Vec = std::fs::read_dir(d) - .unwrap() - .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) - .collect(); - names.sort(); - assert_eq!(names, ["attachment-7", "r (1).pdf", "r (2).pdf", "r.pdf"]); - // A folder that can't be written: refused, nothing made. - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let ro = d.join("ro"); - std::fs::create_dir(&ro).unwrap(); - std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap(); - if std::fs::write(ro.join("probe"), b"").is_err() { - assert!(save_new(&ro, "r.pdf", "attachment-7", b"ours").is_err()); - } - std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap(); - } - } - #[test] fn close_reads_the_books_state_for_what_it_is() { let leases = Ok(Some(HostMsg::Leases { diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index 4d88061..36c2413 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -19,6 +19,9 @@ ledger-store = { path = "../ledger-store" } ledger-import = { path = "../ledger-import" } ledger-config = { path = "../ledger-config" } ledger-vault = { path = "../ledger-vault" } +# The CPA package, the 1099 worksheet, and the reports they share with +# the viewer. +ledger-package = { path = "../ledger-package" } base64 = "0.22" # A download's Repr-Digest (local.rs). sha2 = "0.10" @@ -37,7 +40,6 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "io-util", "io tokio-util = { version = "0.7", features = ["rt", "io", "io-util"] } # A call that panics is recorded before the panic goes on (access.rs). futures-util = "0.3" -zip = "9.0.1" toml = "1" # The vault's TINs, decoded for the filing scan (tin_files.rs), are wiped # from memory when dropped, as the vault's own are. @@ -52,6 +54,8 @@ libc = "0.2" workspace = true [dev-dependencies] +# The tests open the CPA package. +zip = "9.0.1" tokio = { version = "1", features = ["time", "macros", "rt-multi-thread"] } rmcp = { version = "3", features = ["client", "transport-streamable-http-client-reqwest"] } jsonschema = { version = "0.58", default-features = false } diff --git a/crates/ledger-mcp/handbook/1099.md b/crates/ledger-mcp/handbook/1099.md index 4e67e68..6a97a9d 100644 --- a/crates/ledger-mcp/handbook/1099.md +++ b/crates/ledger-mcp/handbook/1099.md @@ -1,5 +1,5 @@ # 1099s - Mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). -- books.vendor_1099 is the year-end worksheet for the CPA. With include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault: hand it over (save_csv_to, a new file outside the books) and never repeat those numbers anywhere else. +- books.vendor_1099 is the year-end worksheet for the CPA. With include_tins: true it carries the full TINs from the vault, for the CPA's copy; csv: true also hands it over as a CSV file (run its download command to save it). - Card payments are reported by the card processor (1099-K) and are left out. diff --git a/crates/ledger-mcp/handbook/cpa-handoff.md b/crates/ledger-mcp/handbook/cpa-handoff.md index 1e51633..a21a52a 100644 --- a/crates/ledger-mcp/handbook/cpa-handoff.md +++ b/crates/ledger-mcp/handbook/cpa-handoff.md @@ -1,5 +1,5 @@ # CPA handoff -- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet), so it needs save_to: a new .zip or a folder outside the books. -- Tell the human where it was saved; never open, read, attach, or copy the file, and never echo its TINs. +- books.export_cpa_package carries every member's and 1099 payee's full TIN from the vault (the K-1 worksheet and the 1099 worksheet). It hands the zip over: run its download command with the folder the human wants it in (books served by `wanderbooks mcp`), then tell them where it is. The viewer's "Export for CPA" makes the same package. +- Never file the package, or a file from it, into the books. - tin_gaps lists each member or payee whose TIN it lacks and why: collect them (books.set_tin, books.set_w9) and export again. diff --git a/crates/ledger-mcp/handbook/files-in-out.md b/crates/ledger-mcp/handbook/files-in-out.md index bd2d06e..c54308b 100644 --- a/crates/ledger-mcp/handbook/files-in-out.md +++ b/crates/ledger-mcp/handbook/files-in-out.md @@ -4,5 +4,5 @@ - an upload, for any file up to 25 MiB (books served by `wanderbooks mcp`; on another connection only inline): books.begin_upload with the file's sha256 (`sha256sum`), size, type, name and purpose; run the command it returns with the file's path; then give its upload_id. Its bytes never pass through you. An upload lasts 15 minutes, is used once, and only by the token that began it. - inline, for a small file (at most 256 KiB): content_base64 with the file's sha256, so a byte lost on the way is caught. - If begin_upload says the bytes are stored already (existing_attachment_id), link that attachment instead of filing a copy. -- Files holding full TINs (books.get_w9, books.vendor_1099 with include_tins, books.export_cpa_package with TINs) are never saved inside the books. 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. +- Files the books make (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 (books served by `wanderbooks mcp`; elsewhere it comes back inline). 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 gives a file of at most 256 KiB inline; a larger one gives the command (wanderbooks download) that saves a copy in a folder you name (only for books served by `wanderbooks mcp`). diff --git a/crates/ledger-mcp/handbook/parties-and-tins.md b/crates/ledger-mcp/handbook/parties-and-tins.md index 63e74aa..40255e0 100644 --- a/crates/ledger-mcp/handbook/parties-and-tins.md +++ b/crates/ledger-mcp/handbook/parties-and-tins.md @@ -7,5 +7,5 @@ - Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under the `[books] parties` setting "suggest" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under "require", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under "off", don't raise it. - Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. - 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), or w9_content_base64 with w9_sha256 for one of at most 256 KiB (never books.file_document or books.attach_receipt: the form shows the TIN). -- Never repeat a TIN back to the human, in chat, a description, note, file name, version-history message, or anywhere else; tools show the last four only. books.get_w9 writes the form out for the human (never open or read it yourself); books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. +- 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, 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. diff --git a/crates/ledger-mcp/src/lib.rs b/crates/ledger-mcp/src/lib.rs index a89ef53..e2602ba 100644 --- a/crates/ledger-mcp/src/lib.rs +++ b/crates/ledger-mcp/src/lib.rs @@ -194,6 +194,12 @@ pub fn run( if let Some(why) = &report.healed { eprintln!("ledger-mcp: rebuilt the database from the journal ({why})"); } + // The viewer's side channel, made as the books open (ledger_store::ops + // INVARIANT 2), so a viewer already open shows its presence before + // the first books.show. Not the books: a failure only says so. + if let Err(e) = ledger_store::ops::prepare(&data_dir) { + eprintln!("ledger-mcp: the viewer's side channel couldn't be made ({e})"); + } // Books that keep their own settings never read a settings file, not // even to start (settings INVARIANT 1); others read it as before. let mut live = if store.ledger().settings().is_journaled() { diff --git a/crates/ledger-mcp/src/local.rs b/crates/ledger-mcp/src/local.rs index 437ccae..0a86855 100644 --- a/crates/ledger-mcp/src/local.rs +++ b/crates/ledger-mcp/src/local.rs @@ -44,16 +44,19 @@ //! dropped. Tokens are made, listed and revoked only through //! the admin socket. //! 9. A file's bytes come in only by `PUT /b/{id}/u/{upload}` and go -//! out only by `GET /b/{id}/a/{attachment}` (plan 4.7), each through -//! the same door as 8 and counted for the close like any request: -//! an upload only from the token that began it, a download only with -//! books:read. Each, refused at the door or not, is recorded in the -//! book's access log as `upload` or `download` with its id, and the +//! out only by `GET /b/{id}/a/{attachment}` (a stored file) and `GET +//! /b/{id}/x/{export}` (a file the books made, D-29) (plan 4.7), each +//! through the same door as 8 and counted for the close like any +//! request: an upload only from the token that began it, a download +//! only with books:read, an export only to the token it was made for +//! (whose tool passed its own scope check; no other is asked). Each, +//! refused at the door or not, is recorded in the book's access log +//! as `upload`, `download` or `export` with its id, and the //! bytes (kept, or read) and their record are one task that runs to //! its end if the client hangs up (a process killed in that task //! excepted); bytes whose record can't be written aren't kept, and -//! the books stop. Lapsed uploads are swept -//! every SWEEP_EVERY. Nothing comes in once the books stopped. +//! the books stop. Lapsed uploads and exports are swept every +//! SWEEP_EVERY. Nothing comes in or goes out once the books stopped. //! ``` use crate::admin::{self, Request, Response}; @@ -108,8 +111,8 @@ type Service = StreamableHttpService; enum Book { /// Open and served. Open { - /// Its MCP service. - service: Service, + /// Its MCP service (boxed: the other variant is a line of text). + service: Box, /// Its store, closed when the server closes (INVARIANT 6). store: Arc, /// Its access log, where requests refused at the door are @@ -117,6 +120,8 @@ enum Book { access: Arc, /// Its files on their way in (`/b/{id}/u/{upload}`). uploads: ledger_store::uploads::Uploads, + /// The files it made for agents (`/b/{id}/x/{export}`). + exports: ledger_store::exports::Exports, }, /// Not opened, with why (INVARIANT 3). Failed(String), @@ -271,6 +276,13 @@ impl Books { ledger_store::uploads::Uploads::open(store.data_dir(), chrono::Utc::now().timestamp()) .map_err(|e| self.hide(e.to_string()))?; let server_uploads = uploads.clone(); + let exports = + ledger_store::exports::Exports::open(store.data_dir(), chrono::Utc::now().timestamp()) + .map_err(|e| self.hide(e.to_string()))?; + let server_exports = exports.clone(); + // The viewer's side channel, made by the server (ops.rs + // INVARIANT 2): a viewer can show its presence from the start. + ledger_store::ops::prepare(store.data_dir()).map_err(|e| self.hide(e.to_string()))?; let live = Arc::new(Mutex::new(live)); let store = SharedStore::new(store); let log = access.clone(); @@ -288,17 +300,19 @@ impl Books { .with_live_settings(live.clone()) .with_layout(layout.clone()) .with_access(log.clone()) - .with_uploads(server_uploads.clone()), + .with_uploads(server_uploads.clone()) + .with_exports(server_exports.clone()), ) }, Arc::new(sessions()), config, ); Ok(Book::Open { - service, + service: Box::new(service), store: shared, access, uploads, + exports, }) } @@ -1158,6 +1172,36 @@ async fn download( axum::extract::State(books): axum::extract::State>, axum::extract::Path((book, attachment)): axum::extract::Path<(String, String)>, request: axum::extract::Request, +) -> axum::response::Response { + fetch(books, book, Wanted::Attachment(attachment), request).await +} + +/// `GET /b/{book}/x/{export}`: a file the books made for an agent +/// (exports.rs; D-29), only to the token it was made for (INVARIANT 9); +/// recorded in the access log (action `export`). +async fn export( + axum::extract::State(books): axum::extract::State>, + axum::extract::Path((book, id)): axum::extract::Path<(String, String)>, + request: axum::extract::Request, +) -> axum::response::Response { + fetch(books, book, Wanted::Export(id), request).await +} + +/// What a GET fetches. +enum Wanted { + /// A stored receipt or document, by its id. + Attachment(String), + /// A file the books made, by its id. + Export(String), +} + +/// A download or an export: the door, the read and its record, the +/// headers (INVARIANT 9). +async fn fetch( + books: Arc, + book: String, + wanted: Wanted, + request: axum::extract::Request, ) -> axum::response::Response { use axum::http::StatusCode; use axum::response::IntoResponse; @@ -1175,58 +1219,86 @@ async fn download( books .last_arrival .store(books.started.elapsed().as_millis() as u64, Ordering::SeqCst); + let (action, subject) = match &wanted { + Wanted::Attachment(id) => ("download", id.clone()), + Wanted::Export(id) => ("export", id.clone()), + }; let asked = Asked { - action: "download", - subject: attachment.clone(), + action, + subject: subject.clone(), }; let (held, token) = match door(&books, &book, &asked, request.headers()).await { Ok(passed) => passed, Err(refused) => return *refused, }; - let Book::Open { access, store, .. } = &held else { + let Book::Open { + access, + store, + exports, + .. + } = &held + else { unreachable!("the door lets only open books through") }; // The read and its record are one blocking task, run to its end even // if the client hangs up, and counted until then. - let (access, store_for) = (access.clone(), store.clone()); + let (access, store_for, exports) = (access.clone(), store.clone(), exports.clone()); let task = tokio::task::spawn_blocking(move || { let _counted = _counted; - // An id as the books write it (no sign, no leading zeros), so the - // log holds no alias of it. - let id = attachment - .parse::() - .ok() - .filter(|n| n.to_string() == attachment); - let found = match (token.scopes.contains(&Scope::Read), id) { + use ledger_store::exports::ExportError; + let not_found = |code, what: &str| { + ( + StatusCode::NOT_FOUND, + code, + format!("there is no such {what}"), + ) + }; + let found = if store_for.is_stopped() { // Stopped after an internal error: as for uploads. - _ if store_for.is_stopped() => Err(( + Err(( StatusCode::SERVICE_UNAVAILABLE, "stopped", crate::server::STOPPED.to_string(), - )), - (false, _) => Err(( + )) + // An export is bound to the token it was made for, whose tool + // passed its own scope check; a stored file needs books:read. + } else if matches!(wanted, Wanted::Attachment(_)) && !token.scopes.contains(&Scope::Read) { + Err(( StatusCode::FORBIDDEN, "insufficient_scope", format!( "this agent's token can't do that: a download needs books:read, and its token ({:?}) hasn't it", token.name ), - )), - (true, None) => Err(( - StatusCode::NOT_FOUND, - "no_such_attachment", - "there is no such attachment".to_string(), - )), - (true, Some(id)) => match store_for.attachment_file(id) { - Ok(Some(file)) => Ok(file), - Ok(None) => Err(( - StatusCode::NOT_FOUND, - "no_such_attachment", - "there is no such attachment".to_string(), - )), - // Stopped, or the stored file damaged: not a missing one. - Err(why) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", why)), - }, + )) + } else { + match &wanted { + Wanted::Export(id) => { + match exports.get(id, &token.id, chrono::Utc::now().timestamp()) { + Ok((x, bytes)) => Ok((x.file_name, x.mime, bytes)), + Err(e @ ExportError::Unknown(_)) => { + Err((StatusCode::NOT_FOUND, "no_such_export", e.to_string())) + } + Err(e @ ExportError::NotYours) => { + Err((StatusCode::FORBIDDEN, "not_yours", e.to_string())) + } + Err(e) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", e.to_string())), + } + } + // An id as the books write it (no sign, no leading zeros), + // so the log holds no alias of it. + Wanted::Attachment(a) => { + match a.parse::().ok().filter(|n| n.to_string() == *a) { + None => Err(not_found("no_such_attachment", "attachment")), + Some(id) => match store_for.attachment_file(id) { + Ok(Some(file)) => Ok(file), + Ok(None) => Err(not_found("no_such_attachment", "attachment")), + // The stored file damaged: not a missing one. + Err(why) => Err((StatusCode::INTERNAL_SERVER_ERROR, "failed", why)), + }, + } + } + } }; let record = Access { principal: Principal { @@ -1236,8 +1308,8 @@ async fn download( }, client: None, request_id: None, - action: "download".into(), - subject: attachment.clone(), + action: action.into(), + subject, args_digest: None, result: match &found { Ok(_) => Outcome::Ok, @@ -1346,7 +1418,7 @@ async fn mcp( let Book::Open { service, .. } = &held else { unreachable!("the door lets only open books through") }; - let service = service.clone(); + let service = (**service).clone(); let principal = |t: &Token| Principal { id: t.id.clone(), kind: "agent".into(), @@ -1690,17 +1762,22 @@ async fn serve( tokio::select! { () = sweep_stop.cancelled() => break, _ = tick.tick() => { - let uploads: Vec<_> = sweeping + let held: Vec<_> = sweeping .held() .values() .filter_map(|b| match b { - Book::Open { uploads, .. } => Some(uploads.clone()), + Book::Open { uploads, exports, .. } => { + Some((uploads.clone(), exports.clone())) + } Book::Failed(_) => None, }) .collect(); let now = chrono::Utc::now().timestamp(); let _ = tokio::task::spawn_blocking(move || { - uploads.iter().for_each(|u| u.sweep(now)); + for (u, x) in &held { + u.sweep(now); + x.sweep(now); + } }) .await; } @@ -1712,6 +1789,7 @@ async fn serve( .route("/b/{book}/mcp", axum::routing::any(mcp)) .route("/b/{book}/u/{upload}", axum::routing::put(upload)) .route("/b/{book}/a/{attachment}", axum::routing::get(download)) + .route("/b/{book}/x/{export}", axum::routing::get(export)) .fallback(not_found) .with_state(books.clone()); // The HTTP side keeps answering (refusing, once closing) until the diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index dda5798..4f2f348 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -46,6 +46,7 @@ use ledger_core::{ depreciation_schedule, reports, }; use ledger_import::ReportMeta; +use ledger_package::{csv_escape, form_1099_label, money, parse_date, tin_kind, vault_tin}; use ledger_store::Store; use rmcp::handler::server::wrapper::{Json, Parameters}; use rmcp::model::{ @@ -74,6 +75,9 @@ pub struct BooksServer { /// The book's uploads (books served from the data root; none over /// stdio, where no bytes can be sent but inline). uploads: Option, + /// The files the books made for agents, waiting to be fetched (books + /// served from the data root; over stdio a file comes back inline). + exports: Option, } /// Where a book's own files live around its data dir. @@ -224,6 +228,16 @@ impl SharedStore { } } +/// The book's id, as the commands given to agents name it: its folder's +/// name in the data root. +fn book_name(store: &Store) -> String { + store + .data_dir() + .file_name() + .map(|n| n.to_string_lossy().to_string()) + .unwrap_or_default() +} + /// A filed file's name and type: those given, else the upload's own. fn named( file_name: Option, @@ -255,9 +269,6 @@ const KEY_IN_BOOKS: &str = "the TIN vault's key file is inside the books' folder /// its records. const VAULT_IN_HISTORY: &str = "the TIN vault can't store anything here yet: the books' folder is kept under version history that doesn't leave the vault out, so its records would be kept forever. Ask the human to leave the vault folder out of that history, then try again"; -/// Why a file with a full TIN isn't written inside the books. -const INSIDE_BOOKS: &str = "that path is inside the books' folder, whose files are kept forever: a file with a full TIN goes elsewhere, such as the human's documents folder"; - /// The store, locked for one stretch of synchronous work. Not `Send`, so /// the compiler refuses any tool future that would hold it across an /// `.await` (tool futures must be `Send`): the lock is never held while a @@ -488,41 +499,28 @@ fn suggest_parties(store: &Store, entry: &JournalEntry) -> Result ledger_core::PaymentAccounts { - let ids = |codes: &[String]| { - codes - .iter() - .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) - .collect() - }; - ledger_core::PaymentAccounts { - cash: ids(&roles.cash), - financing: ids(&roles.financing), +/// The settings as ledger_package reads them: the company, and the +/// `[books]` cash, financing, and investing roles. +fn package_settings(settings: &Settings) -> ledger_package::Settings<'_> { + ledger_package::Settings { + company: &settings.company, + cash_accounts: &settings.roles.cash, + financing_accounts: &settings.roles.financing, + investing_accounts: &settings.roles.investing, } } /// The 1099 worksheet for `year`, the one every tool and the CPA package -/// use: payments out of [`payment_accounts`], an entry's shares resolved -/// with the settings' members (as [`share_context`] checks them). +/// use (ledger_package::vendor_1099_report): payments out of the cash and +/// financing roles, an entry's shares resolved with the settings' members +/// (as [`share_context`] checks them). fn vendor_1099_report( ledger: &ledger_core::Ledger, settings: &Settings, year: i32, threshold: Option, ) -> Result { - ledger_core::vendor_1099_report_with_members( - ledger, - year, - threshold, - &payment_accounts(ledger, &settings.roles), - &settings.company.party_members(ledger), - ) + ledger_package::vendor_1099_report(ledger, &package_settings(settings), year, threshold) } impl BooksServer { @@ -599,6 +597,7 @@ impl BooksServer { layout: Arc::new(Layout::BooksDir), access: None, uploads: None, + exports: None, } } @@ -608,6 +607,12 @@ impl BooksServer { self } + /// Hand the files the books make to agents through `exports`. + pub fn with_exports(mut self, exports: ledger_store::exports::Exports) -> Self { + self.exports = Some(exports); + self + } + /// Check every call against its caller's token, and record it in /// `log` (access.rs). pub fn with_access(mut self, log: Arc) -> Self { @@ -721,45 +726,26 @@ impl BooksServer { financing: &[String], investing: &[String], ) -> Result { - let or = |given: &'_ [String], default: &'_ [String]| -> Vec { - if given.is_empty() { - default.to_vec() - } else { - given.to_vec() - } - }; - cash_flow_statement( + ledger_package::cash_flow_for( ledger, + &package_settings(&self.settings()), start, end, - &or(cash, &self.settings().roles.cash), - &or(financing, &self.settings().roles.financing), - &or(investing, &self.settings().roles.investing), + cash, + financing, + investing, + today(), ) } /// Header and provenance for a rendered report. fn report_meta(&self, store: &Store) -> ReportMeta { - ReportMeta { - company: self.settings().company.name.clone(), - basis: self.settings().company.basis.clone(), - prepared_on: today(), - journal_seq: store.journal_seq(), - } + ledger_package::report_meta(&self.settings().company, today(), store.journal_seq()) } /// Members as reports name them (empty unless a multi-member LLC). fn report_members(&self) -> Vec { - self.settings() - .company - .members - .iter() - .map(|m| ledger_import::Member { - name: m.name.clone(), - capital_code: m.capital_account.clone(), - distribution_code: m.distribution_account.clone(), - }) - .collect() + ledger_package::report_members(&self.settings().company) } /// Members with their capital accounts resolved in the chart, for the @@ -771,40 +757,7 @@ impl BooksServer { self.setting_label("company.members", "[[company.members]] in the settings") )); } - self.settings() - .company - .members - .iter() - .map(|m| { - let account = ledger - .chart() - .find_by_code(&m.capital_account) - .ok_or_else(|| { - format!( - "member {}: unknown capital account {}", - m.name, m.capital_account - ) - })?; - let distribution_account = match &m.distribution_account { - Some(code) => Some( - ledger - .chart() - .find_by_code(code) - .ok_or_else(|| { - format!("member {}: unknown distribution account {code}", m.name) - })? - .id, - ), - None => None, - }; - Ok(ledger_core::MemberCapital { - name: m.name.clone(), - capital_account: account.id, - distribution_account, - share: m.share, - }) - }) - .collect() + ledger_package::member_capitals(ledger, &self.settings().company.members) } /// Where a period close goes, from wanderbooks.toml: members' capital @@ -858,37 +811,7 @@ impl BooksServer { ledger: &Ledger, as_of: NaiveDate, ) -> Result { - let (fy_start, _) = self.settings().company.fiscal_year_containing(as_of); - let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None) - .map_err(|e| e.to_string())? - .net_income(); - // A close takes everything unclosed through its end, earlier years - // included, so this year's unclosed part is the income from the day - // after the last close (never before the year's start) through - // as_of. (Counting the closing entries dated this year instead - // charged this year with closing last year's income: found by - // year_earnings_split_closed_from_unclosed, 2026-10-14.) - let last_close = ledger - .list_periods() - .iter() - .filter(|p| p.status == PeriodStatus::Closed && p.end <= as_of) - .map(|p| p.end) - .max(); - let from = match last_close.and_then(|e| e.succ_opt()) { - Some(after) if after > fy_start => after, - _ => fy_start, - }; - let unclosed = if from <= as_of { - ledger_core::profit_and_loss_for_period(ledger, from, as_of, None) - .map_err(|e| e.to_string())? - .net_income() - } else { - Decimal::ZERO - }; - Ok(ledger_import::YearEarnings { - net_income_ytd, - unclosed, - }) + ledger_package::year_earnings(ledger, &self.settings().company, as_of) } /// Cash and card accounts due for reconciliation @@ -992,13 +915,6 @@ impl BooksServer { .collect() } - /// Where a file holding full TINs may be written: a new file outside - /// the books (outside_books). (It can't be filed back in unnoticed: - /// tin_files.rs records it.) - fn tin_file_dest(&self, store: &Store, path: &str) -> Result { - outside_books(&self.layout.books_dir(store), path) - } - /// The signed W-9 a call gives (an upload, or inline), for the vault: /// a PDF, PNG, or JPEG by its own first bytes, whatever it is called. fn w9_file( @@ -1070,8 +986,12 @@ impl BooksServer { /// INVARIANT 2), before it is written out: every session, and every /// later server for these books, refuses it. fn wrote_tins(&self, store: &Store, bytes: &[u8]) -> Result<(), String> { - // The record goes in the vault's folder: in the data root, made - // private first, as the vault itself makes it. + tin_files::record(&self.tin_record_dir(store)?, bytes) + } + + /// Where records of files with TINs go: the vault's folder, in the + /// data root made private first, as the vault itself makes it. + fn tin_record_dir(&self, store: &Store) -> Result { if let Layout::Root(_) = *self.layout { ledger_store::root::private_book_folder( store.data_dir(), @@ -1079,7 +999,7 @@ impl BooksServer { ) .map_err(|e| format!("the TIN vault can't be written: {e}"))?; } - tin_files::record(&self.vault_dir(store), bytes) + Ok(self.vault_dir(store)) } /// Open the TIN vault with the key file in force (`[vault] key_file`, @@ -1209,28 +1129,6 @@ impl BooksServer { // Helpers // --------------------------------------------------------------------------- -/// A date: exactly YYYY-MM-DD, a real day in the calendar the books keep -/// (0001-01-01 through 9999-12-31, `ledger_core::calendar`). No sign, no -/// space, no five-digit year: every date past here is one date arithmetic -/// reaches (P0 review round 3, S1). -fn parse_date(s: &str) -> Result { - let shaped = s.len() == 10 - && s.bytes().enumerate().all(|(i, b)| { - if i == 4 || i == 7 { - b == b'-' - } else { - b.is_ascii_digit() - } - }); - shaped - .then(|| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) - .flatten() - .filter(|d| ledger_core::calendar::in_calendar(*d)) - .ok_or_else(|| { - format!("bad date {s:?}: expected YYYY-MM-DD, a real day from 0001-01-01 to 9999-12-31") - }) -} - /// A decimal string, exactly `-?digits[.digits]` (INVARIANT 3). fn parse_amount(s: &str) -> Result { ledger_config::decimal(s) @@ -1970,92 +1868,6 @@ fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool { resolved(path).starts_with(resolved(dir)) } -/// Where a tool may write a file holding a full TIN: a new file, in a -/// folder that exists, outside the books dir (which git commits). -fn outside_books(books: &std::path::Path, path: &str) -> Result { - let p = std::path::PathBuf::from(path.trim()); - if path.trim().is_empty() { - return Err("give a path to write to".into()); - } - if is_inside(books, &p) { - return Err(INSIDE_BOOKS.to_string()); - } - match p.parent() { - Some(d) if d.as_os_str().is_empty() || d.is_dir() => {} - _ => return Err(NO_FOLDER.to_string()), - } - if p.exists() { - return Err(EXISTS.to_string()); - } - Ok(p) -} - -/// A path to write to whose folder doesn't exist. -const NO_FOLDER: &str = "the folder for that path doesn't exist: name a file in a folder that does"; - -/// A path to write to that is taken. -const EXISTS: &str = "a file is there already: name a new file"; - -/// Where `books.export_cpa_package` writes its zip: `to` itself when it -/// names a `.zip` file, or `file_name` in `to` when it is an existing -/// folder. A new file, in a folder that exists; with TINs, outside the -/// books dir (which git commits, `exports/` included). -fn package_dest( - books: &std::path::Path, - to: &str, - file_name: &str, - tins: bool, -) -> Result { - let raw = std::path::PathBuf::from(to.trim()); - if to.trim().is_empty() { - return Err("give save_to: a new .zip path or an existing folder".into()); - } - let dest = if raw.is_dir() { - raw.join(file_name) - } else if raw - .extension() - .is_some_and(|e| e.eq_ignore_ascii_case("zip")) - { - raw - } else { - return Err(format!( - "save_to {} is neither an existing folder nor a .zip file", - raw.display() - )); - }; - if tins { - return outside_books(books, &dest.to_string_lossy()).map_err(|e| { - format!("{e} (the CPA package holds full TINs; save it outside the books)") - }); - } - match dest.parent() { - Some(d) if d.as_os_str().is_empty() || d.is_dir() => {} - _ => return Err(NO_FOLDER.to_string()), - } - if dest.exists() { - return Err(EXISTS.to_string()); - } - Ok(dest) -} - -/// Write a new file readable by its owner only (0600 on Unix). -fn write_private_new(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { - use std::io::Write; - let mut opts = std::fs::OpenOptions::new(); - opts.write(true).create_new(true); - #[cfg(unix)] - { - use std::os::unix::fs::OpenOptionsExt; - opts.mode(0o600); - } - let mut f = opts - .open(path) - .map_err(|e| format!("couldn't create {}: {e}", path.display()))?; - f.write_all(bytes) - .and_then(|()| f.sync_all()) - .map_err(|e| format!("couldn't write {}: {e}", path.display())) -} - /// A TIN type from a tool: ssn, ein, or itin. fn parse_tin_type(s: &str) -> Result { match s.trim().to_lowercase().as_str() { @@ -2066,93 +1878,6 @@ fn parse_tin_type(s: &str) -> Result { } } -/// A TIN type's wire name. -fn tin_type_key(t: ledger_vault::TinType) -> &'static str { - match t { - ledger_vault::TinType::Ssn => "ssn", - ledger_vault::TinType::Ein => "ein", - ledger_vault::TinType::Itin => "itin", - } -} - -/// The books' name for a vault TIN's type. -fn tin_kind(t: ledger_vault::TinType) -> ledger_core::TinType { - match t { - ledger_vault::TinType::Ssn => ledger_core::TinType::Ssn, - ledger_vault::TinType::Ein => ledger_core::TinType::Ein, - ledger_vault::TinType::Itin => ledger_core::TinType::Itin, - } -} - -/// The full TIN the vault holds for a party, checked against what the -/// books record of it; else why not, in words for a worksheet's note (the -/// note never holds more than a last four). -/// -/// ```text -/// GOAL: a worksheet for the CPA carries a TIN only when the vault and the -/// books agree on it, and says why when it can't. -/// INVARIANTS: -/// 1. The party's TIN facts, when on file, have the vault TIN's type and -/// last four, and the record's hash when they name one. -/// 2. Its W-9 facts, when on file, have the vault TIN's last four; with -/// no TIN facts, the W-9 facts' vault record is the record's hash. -/// 3. A vault that can't be opened or read, or holds no TIN for the -/// party, is a note, never an error (a package never fails on it). -/// ``` -fn vault_tin( - ledger: &Ledger, - vault: &Result, - party: ledger_core::PartyId, -) -> Result { - let vault = vault.as_ref().map_err(String::clone)?; - let t = vault - .tin(party) - .map_err(|e| e.to_string())? - .ok_or_else(|| { - "TIN not in the vault: record it with books.set_tin (or books.set_w9 tin, from the W-9)" - .to_string() - })?; - let hash = vault.record_hash(party).ok().flatten(); - let p = ledger.get_party(party); - let (facts, w9) = ( - p.and_then(|p| p.tin.as_ref()), - p.and_then(|p| p.w9.as_ref()), - ); - if facts.is_none() && w9.is_none() { - return Err( - "the vault holds a TIN but the books have no record of it: record it again with books.set_tin" - .into(), - ); - } - if let Some(f) = facts { - if f.last4 != t.last4() || f.tin_type != tin_kind(t.kind) { - return Err(format!( - "the vault's TIN ({}, ending {}) isn't the one the books record ({}, ending {}): record the right one with books.set_tin", - tin_type_key(t.kind), - t.last4(), - f.tin_type.key(), - f.last4 - )); - } - if f.vault_record.is_some() && f.vault_record != hash { - return Err("the vault record isn't the one the books recorded: record the TIN again with books.set_tin".into()); - } - } - if let Some(f) = w9 { - if f.tin_last4 != t.last4() { - return Err(format!( - "the vault's TIN ends in {}, the W-9 facts say {}: check the W-9 and fix with books.set_w9", - t.last4(), - f.tin_last4 - )); - } - if facts.is_none() && f.vault_record.is_some() && f.vault_record != hash { - return Err("the vault record isn't the one the books recorded: check the W-9 and record it again with books.set_w9 tin".into()); - } - } - Ok(t) -} - /// Parse role names (`Role::key`), refusing any unknown one. fn parse_roles(roles: &[String]) -> Result, String> { roles @@ -2533,10 +2258,6 @@ fn this_year() -> i32 { chrono::Datelike::year(&today()) } -fn form_1099_label(form: Option) -> Option { - form.map(|f| f.label().to_string()) -} - /// Resolve a class by id or name; a number that is one class's id and /// another's name is refused (INVARIANT 6). fn resolve_class(store: &Store, class: &str) -> Result { @@ -2556,29 +2277,6 @@ fn resolve_class(store: &Store, class: &str) -> Result { } } -/// A money amount as a decimal string with at least two places: "0.00", -/// "207.68", "1000.00". Padding only, never rounding: an exact amount with -/// more places (an FX-converted line) keeps them. Never "-0". -fn money(n: Decimal) -> String { - let mut n = if n.is_zero() { Decimal::ZERO } else { n }; - if n.scale() < 2 { - n.rescale(2); - } - n.to_string() -} - -/// A member's share as the K-1 worksheet prints it: at least two places, -/// however it was written ("50%", "0.5" and "0.500" all print "0.50"; a -/// share of a third keeps its places). The printed text depends on the -/// share, never on its spelling. -fn share_text(n: Decimal) -> String { - let mut n = n.normalize(); - if n.scale() < 2 { - n.rescale(2); - } - n.to_string() -} - /// Preview lines with resulting balances for raw (account, side, amount) lines. fn preview_lines_for( store: &Store, @@ -2648,52 +2346,6 @@ fn preview_receipts(store: &Store, entry_id: u64) -> Result, .collect()) } -/// Build the cash flow statement from account codes, as the exports need it: -/// `start` defaults to the beginning of time, `end` to today, and at least -/// one cash account is required (the statement reconciles to them). -fn cash_flow_statement( - ledger: &Ledger, - start: Option<&str>, - end: Option<&str>, - cash_codes: &[String], - financing_codes: &[String], - investing_codes: &[String], -) -> Result { - let resolve = |codes: &[String], what: &str| -> Result, String> { - codes - .iter() - .map(|code| { - ledger - .chart() - .find_by_code(code) - .map(|a| a.id) - .ok_or_else(|| format!("unknown {what} account code: {code}")) - }) - .collect() - }; - let cash_accounts = resolve(cash_codes, "cash")?; - if cash_accounts.is_empty() { - return Err( - "cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)" - .to_string(), - ); - } - let financing_accounts = resolve(financing_codes, "financing")?; - let investing_accounts = resolve(investing_codes, "investing")?; - let (start, end) = statement_range(ledger, start, end)?; - ledger_core::cash_flow( - ledger, - &ledger_core::CashFlowParams { - start, - end, - cash_accounts, - financing_accounts, - investing_accounts, - }, - ) - .map_err(|e| e.to_string()) -} - /// Net movement of one account over entries in `[from, to]`, normal side. fn movement_in(ledger: &Ledger, account: AccountId, from: NaiveDate, to: NaiveDate) -> Decimal { let movement: Decimal = ledger @@ -2779,22 +2431,7 @@ fn statement_range( start: Option<&str>, end: Option<&str>, ) -> Result<(NaiveDate, NaiveDate), String> { - let end = match end { - Some(s) => parse_date(s)?, - None => today(), - }; - let start = match start { - Some(s) => parse_date(s)?, - None => ledger - .posted_entries() - .map(|e| e.date()) - .min() - .map_or(end, |first| first.min(end)), - }; - if end < start { - return Err("end is before start".to_string()); - } - Ok((start, end)) + ledger_package::statement_range(ledger, start, end, today()) } fn schedule_out(ledger: &Ledger, s: &ledger_core::AmortizationSchedule) -> ScheduleOut { @@ -2858,8 +2495,7 @@ fn pnl_statement( end: Option<&str>, class_id: Option, ) -> Result { - let (start, end) = statement_range(ledger, start, end)?; - ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string()) + ledger_package::pnl_statement(ledger, start, end, class_id, today()) } /// Balance sheet as of a date (default today). @@ -2867,11 +2503,7 @@ fn bs_statement( ledger: &Ledger, as_of: Option<&str>, ) -> Result { - let as_of = match as_of { - Some(s) => parse_date(s)?, - None => today(), - }; - ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string()) + ledger_package::bs_statement(ledger, as_of, today()) } fn lines_out(lines: &[ledger_core::StatementLine]) -> Vec { @@ -4117,7 +3749,7 @@ impl BooksServer { annotations(read_only_hint = true, open_world_hint = false) )] async fn show(&self, params: Parameters) -> Result, String> { - use ledger_store::viewer::{ViewerTarget, request}; + use ledger_store::ops::{ViewerTarget, request, viewer_open}; let p = params.0; let store = self.lock()?; let ledger = store.ledger(); @@ -4300,11 +3932,19 @@ impl BooksServer { ) }; request(store.data_dir(), target).map_err(|e| format!("couldn't reach the viewer: {e}"))?; + // Whether a viewer has these books open (ledger_store::ops + // INVARIANT 3): it shows it now; else it will when opened soon. + let open = viewer_open(store.data_dir()); Ok(Json(ShowOut { shown: shown.clone(), - note: format!( - "Sent to the viewer: {shown}. If it isn't open, ask the human to open the viewer (within a couple of minutes, it opens right there)." - ), + viewer_open: open, + note: if open { + format!("The viewer shows {shown}.") + } else { + format!( + "No viewer has these books open: ask the human to open it (opened within a couple of minutes, it shows {shown})." + ) + }, })) } @@ -5021,7 +4661,7 @@ impl BooksServer { #[tool( name = "books.vendor_1099", title = "1099 worksheet", - description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to the [books] cash and financing accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded; on an entry naming several parties, only the vendor's resolved share of that payment), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 in 2026, indexed for inflation after that), which are missing a W-9, and each payee's TIN last four. include_tins: true adds each payee's full TIN from the TIN vault (or why it can't): only when the human asks for the CPA's worksheet, and never repeat those TINs anywhere else; save_csv_to also writes the worksheet as CSV to a new file outside the books dir. For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and tell the human, who can report it upstream so the rule is updated for everyone. For the CPA to confirm.", + description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to the [books] cash and financing accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded; on an entry naming several parties, only the vendor's resolved share of that payment), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 in 2026, indexed for inflation after that), which are missing a W-9, and each payee's TIN last four. include_tins: true adds each payee's full TIN from the TIN vault (or why it can't), for the CPA's worksheet; csv: true also hands it over as a CSV file (run its download command to save it). For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and tell the human, who can report it upstream so the rule is updated for everyone. For the CPA to confirm.", annotations( read_only_hint = false, destructive_hint = false, @@ -5035,138 +4675,61 @@ impl BooksServer { let p = params.0; let threshold = p.threshold.as_deref().map(parse_amount).transpose()?; let store = self.lock()?; - // A year past what wanderbooks knows is refused with guidance - // (research or ask, pass `threshold`, then update the rule). - let r = vendor_1099_report(store.ledger(), &self.settings(), p.year, threshold) - .map_err(|e| e.to_string())?; - let missing_w9 = r - .vendors - .iter() - .filter(|v| v.reportable && !v.tin_on_file) - .map(|v| v.name.clone()) - .collect(); - let untagged = store - .ledger() - .posted_entries() - .filter(|e| { - chrono::Datelike::year(&e.date()) == p.year - && store.ledger().entry_vendor(e.id()).is_none() - }) - .count(); - let mut notes = vec![ - "Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(), - "Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.".to_string(), - ]; - if p.year > 2026 { - notes.push(format!( - "The $2,000 threshold is indexed for inflation after 2026; confirm the {} amount and pass threshold to override.", - p.year - )); - } - if untagged > 0 { - notes.push(format!( - "{untagged} posted entries in {} have no vendor tag; review any that paid contractors or rent.", - p.year - )); - } - if !r.unresolved.is_empty() { - notes.push(format!( - "Entries {} name several parties whose shares don't resolve, so each counts in full toward its vendor: fix the shares with books.set_entry_party.", - r.unresolved - .iter() - .map(|id| id.to_string()) - .collect::>() - .join(", ") - )); - } let ledger = store.ledger(); // With include_tins, the vault is opened once (never creating a // key: a locked vault becomes a note on each line). let vault = p.include_tins.then(|| self.open_vault(&store, false)); - let mut vendors = Vec::new(); - for v in r.vendors { - let party_id = ledger.party_of_vendor(v.vendor_id); - let tin_last4 = party_id.and_then(|id| ledger.get_party(id)).and_then(|p| { - p.w9.as_ref() - .map(|f| f.tin_last4.clone()) - .or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) - }); - let mut line = Vendor1099LineOut { - vendor_id: v.vendor_id, - name: v.name, - form_1099: form_1099_label(v.form), - tin_on_file: v.tin_on_file, - paid: money(v.paid), - reportable: v.reportable, - party_id, - tin_last4, - tin: None, - tin_type: None, - tin_note: None, - }; - if let Some(vault) = &vault { - match party_id.map(|pid| vault_tin(ledger, vault, pid)) { - None => line.tin_note = Some("no party for this vendor".into()), - Some(Err(why)) => line.tin_note = Some(why), - Some(Ok(t)) => { - line.tin = Some(t.formatted()); - line.tin_type = Some(tin_type_key(t.kind).to_string()); - } - } - } - vendors.push(line); - } + let tins = vault + .as_ref() + .map(|vault| move |id| vault_tin(ledger, vault, id)); + // A year past what wanderbooks knows is refused with guidance + // (research or ask, pass `threshold`, then update the rule). + let w = ledger_package::vendor_1099_worksheet( + ledger, + &package_settings(&self.settings()), + p.year, + threshold, + tins.as_ref().map(|f| f as ledger_package::TinSource<'_>), + )?; let warning = p.include_tins.then(|| { - "This worksheet holds full TINs: for the CPA only. Don't paste it into email, chat, notes, or the books; delete any saved copy once the CPA has it." - .to_string() + "This worksheet holds full TINs, for the CPA; keep it out of the books.".to_string() }); - let saved_csv = match &p.save_csv_to { - None => None, - Some(path) => { - let dest = if p.include_tins { - self.tin_file_dest(&store, path)? - } else { - outside_books(&self.layout.books_dir(&store), path)? - }; - let mut csv = - String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4"); - if p.include_tins { - csv.push_str(",tin,tin_type,tin_note"); - } - csv.push('\n'); - for l in &vendors { - let mut cells = vec![ - l.vendor_id.to_string(), - csv_escape(&l.name), - l.form_1099.clone().unwrap_or_default(), - l.paid.clone(), - l.reportable.to_string(), - l.tin_on_file.to_string(), - l.tin_last4.clone().unwrap_or_default(), - ]; - if p.include_tins { - cells.push(l.tin.clone().unwrap_or_default()); - cells.push(l.tin_type.clone().unwrap_or_default()); - cells.push(csv_escape(l.tin_note.as_deref().unwrap_or_default())); - } - csv.push_str(&cells.join(",")); - csv.push('\n'); - } + let csv_file = match p.csv { + false => None, + true => { + let csv = ledger_package::vendor_1099_csv(&w, p.include_tins); if p.include_tins { self.wrote_tins(&store, csv.as_bytes())?; } - write_private_new(&dest, csv.as_bytes())?; - Some(dest.display().to_string()) + let name = ledger_package::vendor_1099_csv_name(w.year, p.include_tins); + Some(self.hand_over(&book_name(&store), &name, "text/csv", csv.into_bytes())?) } }; + let vendors = w + .lines + .into_iter() + .map(|l| Vendor1099LineOut { + vendor_id: l.vendor_id, + name: l.name, + form_1099: l.form_1099, + tin_on_file: l.tin_on_file, + paid: money(l.paid), + reportable: l.reportable, + party_id: l.party_id, + tin_last4: l.tin_last4, + tin: l.tin, + tin_type: l.tin_type, + tin_note: l.tin_note, + }) + .collect(); Ok(Json(Vendor1099Out { - year: r.year, - threshold: money(r.threshold), + year: w.year, + threshold: money(w.threshold), vendors, - missing_w9, - notes, + missing_w9: w.missing_w9, + notes: w.notes, warning, - saved_csv, + csv: csv_file, })) } @@ -5898,36 +5461,8 @@ impl BooksServer { /// the rest follow their wanderbooks type (other current asset, other /// current liability, equity, income, expense). fn export_iif(&self) -> Result { - use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif}; let store = self.lock()?; - let ledger = store.ledger(); - let fixed: std::collections::HashSet = ledger - .registered_assets() - .flat_map(|a| [a.cost_account, a.accum_depr_account]) - .collect(); - let receivables = ledger.role_account(&ledger_core::RECEIVABLES); - let payables = ledger.role_account(&ledger_core::PAYABLES); - let accounts = iif_accounts(ledger, |a| { - if self.settings().roles.cash.contains(&a.code) { - Qb::Bank - } else if Some(a.id) == receivables { - Qb::AccountsReceivable - } else if Some(a.id) == payables { - Qb::AccountsPayable - } else if fixed.contains(&a.id) { - Qb::FixedAsset - } else { - match a.account_type { - AccountType::Asset => Qb::OtherCurrentAsset, - AccountType::Liability => Qb::OtherCurrentLiability, - AccountType::Equity => Qb::Equity, - AccountType::Revenue => Qb::Income, - AccountType::Expense => Qb::Expense, - } - } - }); - let entries: Vec<_> = ledger.posted_entries().collect(); - journal_to_iif(&accounts, &entries).map_err(|e| e.to_string()) + ledger_package::export_iif(store.ledger(), &self.settings().roles.cash) } // ------------------------------------------------------------------ @@ -7377,22 +6912,22 @@ impl BooksServer { /// ```text /// GOAL: hand the human (for the CPA) the signed W-9 the vault keeps, /// without it passing through the books or the conversation. - /// SOURCE: docs/parties-and-attachments.md "The TIN vault". + /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; D-29. /// INVARIANTS: - /// 1. The file is written only to the path given, outside the books - /// dir, as a new file readable by its owner only; the output is - /// the path, never the contents. + /// 1. The file is handed over (tools/files.rs INVARIANT 6: an export + /// for the caller's token, or inline over stdio), never written + /// to a path; a name kept before names were checked is made + /// plain, never refused. It is recorded (tin_files.rs) first, so + /// it is never filed back into the books. /// 2. Nothing is journaled. /// SCENARIOS: - /// 1. IF the path is inside the books dir, exists, or its folder - /// doesn't: SHOULD refuse. - /// 2. IF the vault is locked or holds no W-9 file for the party: + /// 1. IF the vault is locked or holds no W-9 file for the party: /// SHOULD refuse, saying which. /// ``` #[tool( name = "books.get_w9", title = "Write out a W-9", - description = "Write a party's signed W-9 file from the TIN vault to a local path (save_to: a new file outside the books dir, since the form shows the full TIN) and return the path, never the contents. Give the human the path; never open, read, or attach the file yourself. Nothing is journaled.", + description = "A party's signed W-9 file from the TIN vault, for the CPA or the human: run the download command it returns to save it in a folder (books served by `wanderbooks mcp`; over stdio it comes back inline). Never file it into the books (it shows the full TIN). Nothing is journaled.", annotations( read_only_hint = false, destructive_hint = false, @@ -7403,7 +6938,6 @@ impl BooksServer { let p = params.0; let store = self.lock()?; let id = resolve_party(&store, &p.party)?; - let dest = self.tin_file_dest(&store, &p.save_to)?; let file = self .open_vault(&store, false)? .w9(id) @@ -7414,16 +6948,15 @@ impl BooksServer { ) })?; self.wrote_tins(&store, &file.bytes)?; - write_private_new(&dest, &file.bytes)?; + let handed = self.hand_over( + &book_name(&store), + &file.file_name, + &file.mime_type, + file.bytes, + )?; Ok(Json(GetW9Out { party_id: id, - path: dest.display().to_string(), - file_name: file.file_name, - size_bytes: file.bytes.len() as u64, - note: format!( - "Saved {} (readable by you only). It shows the full TIN: give it to the CPA, keep it out of the books, email, and chat, and delete it when done.", - dest.display() - ), + file: handed, })) } @@ -8430,10 +7963,11 @@ impl BooksServer { /// SOURCE: docs/mcp-interface.md; B-26; DECISION (owner, /// 2026-10-15): "it needs to be in the damn CPA package." /// INVARIANTS: - /// 1. With include_tins (the default) the zip is only ever written to - /// save_to, a new file outside the books dir (0600), and the output - /// is its path, never its bytes; without save_to it is refused. - /// include_tins false: last fours only, base64 unless save_to. + /// 1. The zip is handed over (tools/files.rs INVARIANT 6: an export + /// the caller fetches, or inline over stdio), never written to a + /// path; with include_tins (the default) it and each file in it + /// holding a TIN are recorded (tin_files.rs), so none is filed back + /// in. include_tins false: last fours only. /// 2. With members, k1-members.csv has one row per [[company.members]] /// member: name, party, share, capital account, TIN and type, and /// the K-1 worksheet's capital figures (ledger_core::k1) for the @@ -8447,7 +7981,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, 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 there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; 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); 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, so the package is saved, never returned: save_to (a new .zip path or an existing folder, outside the books dir) is required, and the output gives only its path; tell the human where it is and never open, attach, or copy it. 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). include_tins: false builds it with last fours only, returned as base64 unless save_to is given. 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: the profit and loss, balance sheet, cash flow, 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 there are fixed assets; the general ledger (by account) and general journal (entry by entry) as XLSX; the journal as IIF (imports into QuickBooks Desktop); the 1099 worksheet as CSV; 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); 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 (books served by `wanderbooks mcp`; over stdio it comes back inline). 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.", annotations( read_only_hint = false, destructive_hint = false, @@ -8458,538 +7992,65 @@ impl BooksServer { &self, params: Parameters, ) -> Result, String> { - use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf}; let p = params.0; let as_of = match &p.as_of { Some(s) => parse_date(s)?, None => today(), }; - // A package with full TINs is only ever a file outside the books, - // never tool output (INVARIANT 1). - if p.include_tins && p.save_to.is_none() { - return Err( - "the CPA package carries every member's and 1099 payee's full TIN (include_tins, the default), so it is saved, never returned: pass save_to, a new .zip path or an existing folder outside the books dir, then tell the human where it is. (include_tins: false builds one with last fours only, returned as base64.)" - .into(), - ); - } - let file_name = format!("cpa-package-{as_of}.zip"); let store = self.lock()?; - let dest = p - .save_to - .as_deref() - .map(|to| { - package_dest( - &self.layout.books_dir(&store), - to, - &file_name, - // In the data root nothing is ever written, TINs or not. - p.include_tins || matches!(*self.layout, Layout::Root(_)), - ) - }) - .transpose()?; - let meta = self.report_meta(&store); - let ledger = store.ledger(); + let book = book_name(&store); // The vault, opened once and never creating a key: locked, each // TIN row says so and the package is built anyway (INVARIANT 3). let vault = p.include_tins.then(|| self.open_vault(&store, false)); - let mut tin_gaps: Vec = Vec::new(); - - // Fiscal year to date, per wanderbooks.toml's fiscal_year_start. - let (fy_start, _) = self.settings().company.fiscal_year_containing(as_of); - let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string()); - let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); - let pdf = - |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string()); - let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?; - let members = self.report_members(); - - // The financial statements, each as PDF and XLSX. - let mut statements: Vec<(&str, ledger_import::Statement)> = vec![ - ( - "profit-and-loss", - report::profit_and_loss(&pnl_statement( - ledger, - Some(&fy_start), - Some(&as_of_s), - None, - )?), - ), - ( - "balance-sheet", - report::balance_sheet( - &bs_statement(ledger, Some(&as_of_s))?, - self.year_earnings(ledger, as_of)?, - &members, - ), - ), - ( - "cash-flow", - report::cash_flow( - &self.cash_flow_for( - ledger, - Some(&fy_start), - Some(&as_of_s), - &p.cash_accounts, - &p.financing_accounts, - &p.investing_accounts, - )?, - &members, - ), - ), - ]; - let mut k1_csv: Option<(String, Vec)> = None; - if !self.settings().company.members.is_empty() { - let capitals = self.member_capitals(ledger)?; - let me = ledger_core::members_equity(ledger, fy, as_of, &capitals) - .map_err(|e| e.to_string())?; - statements.push(("members-equity", report::members_equity(&me))); - // The K-1 worksheet: each member's capital account for the - // year (Schedule K-1 Item L) and their TIN (INVARIANT 2). - let cash_codes = if p.cash_accounts.is_empty() { - &self.settings().roles.cash - } else { - &p.cash_accounts - }; - let cash: Vec = cash_codes - .iter() - .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) - .collect(); - k1_csv = Some( - match ledger_core::k1::k1_worksheet(ledger, fy, as_of, &capitals, &cash) { - Ok(rows) => { - let mut w = String::from("member,party_id,share,capital_account,"); - w.push_str(if p.include_tins { - "tin,tin_type,tin_note," - } else { - "tin_last4,tin_type," - }); - w.push_str("beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n"); - for (row, m) in rows.iter().zip(&self.settings().company.members) { - let party = ledger_core::k1::member_party(ledger, &m.name); - let facts = party - .and_then(|id| ledger.get_party(id)) - .and_then(|p| p.tin.clone()); - let mut cells = vec![ - csv_escape(&m.name), - party.map(|id| id.to_string()).unwrap_or_default(), - share_text(m.share), - m.capital_account.clone(), - ]; - match &vault { - Some(vault) => { - let got = match party { - None => Err(format!( - "TIN not in the vault: no party named {:?} with the member role (books.add_party with role member, or books.set_party_roles, then books.set_tin)", - m.name - )), - Some(id) => vault_tin(ledger, vault, id), - }; - match got { - Ok(t) => { - cells.push(t.formatted()); - cells.push(tin_type_key(t.kind).into()); - cells.push(String::new()); - } - Err(why) => { - tin_gaps.push(format!("member {}: {why}", m.name)); - cells.push(String::new()); - cells.push( - facts - .map(|f| f.tin_type.key().to_string()) - .unwrap_or_default(), - ); - cells.push(csv_escape(&why)); - } - } - } - None => { - cells.push( - facts.as_ref().map(|f| f.last4.clone()).unwrap_or_default(), - ); - cells.push( - facts - .map(|f| f.tin_type.key().to_string()) - .unwrap_or_default(), - ); - } - } - for v in [ - row.beginning, - row.contributions_cash, - row.contributions_property, - row.net_income, - row.distributions, - row.other, - row.ending, - ] { - cells.push(money(v)); - } - w.push_str(&cells.join(",")); - w.push('\n'); - } - // The members together: ties to the 1065's Schedule L - // capital and the statement of members' equity. Not - // a K-1 (each member gets their own). - if rows.len() > 1 { - let sum = |f: fn(&ledger_core::k1::K1Row) -> Decimal| { - rows.iter().map(f).sum::() - }; - let share: Decimal = self - .settings() - .company - .members - .iter() - .map(|m| m.share) - .sum(); - let mut cells = vec![ - csv_escape("Total, all members (not a K-1)"), - String::new(), - share_text(share), - String::new(), - String::new(), - String::new(), - ]; - if p.include_tins && vault.is_some() { - cells.push(String::new()); - } - for v in [ - sum(|r| r.beginning), - sum(|r| r.contributions_cash), - sum(|r| r.contributions_property), - sum(|r| r.net_income), - sum(|r| r.distributions), - sum(|r| r.other), - sum(|r| r.ending), - ] { - cells.push(money(v)); - } - w.push_str(&cells.join(",")); - w.push('\n'); - } - ("k1-members.csv".into(), w.into_bytes()) - } - Err(e) => ( - "k1-members-NOT-INCLUDED.txt".into(), - format!("{e}\n").into_bytes(), - ), - }, - ); - } - statements.push(( - "tax-summary", - report::tax_summary( - &ledger_core::tax_summary(ledger, fy, as_of, self.settings().company.tax_form) - .map_err(|e| e.to_string())?, - ), - )); - statements.push(( - "trial-balance", - report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of), - )); - let mut files: Vec<(String, Vec)> = Vec::new(); - for (name, st) in &statements { - files.push((format!("{name}.pdf"), pdf(st)?)); - files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?)); - } - // The fixed asset register, when there are fixed assets. - if ledger.registered_assets().next().is_some() { - let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?; - files.push(( - "fixed-asset-register.pdf".into(), - ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string())?, - )); - files.push(( - "fixed-asset-register.xlsx".into(), - ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string())?, - )); - } - // The books behind them: by account, and entry by entry. - let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?; - files.push(( - "general-ledger.xlsx".into(), - xlsx(XlsxReport::GeneralLedger(&gl))?, - )); - files.push(( - "general-journal.xlsx".into(), - xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?, - )); - // 1099 worksheet for the calendar year of as_of. - let year = chrono::Datelike::year(&as_of); - match vendor_1099_report(ledger, &self.settings(), year, None) { - Ok(r1099) => { - let mut w = String::from("vendor,form,w9_on_file,paid,reportable"); - if vault.is_some() { - w.push_str(",party_id,tin_last4,tin,tin_type,tin_note"); - } - w.push('\n'); - for v in &r1099.vendors { - w.push_str(&format!( - "\"{}\",{},{},{},{}", - v.name.replace('"', "\"\""), - v.form.map(|f| f.label()).unwrap_or(""), - v.tin_on_file, - money(v.paid), - v.reportable - )); - // The payee's full TIN from the vault (INVARIANT 2). - if let Some(vault) = &vault { - let party = ledger.party_of_vendor(v.vendor_id); - let last4 = party.and_then(|id| ledger.get_party(id)).and_then(|p| { - p.w9.as_ref() - .map(|f| f.tin_last4.clone()) - .or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) - }); - let got = match party { - None => Err("TIN not in the vault: no party for this vendor".into()), - Some(id) => vault_tin(ledger, vault, id), - }; - let (tin, kind, note) = match got { - Ok(t) => ( - t.formatted(), - tin_type_key(t.kind).to_string(), - String::new(), - ), - Err(why) => { - if v.reportable { - tin_gaps.push(format!("1099 payee {}: {why}", v.name)); - } - (String::new(), String::new(), why) - } - }; - w.push_str(&format!( - ",{},{},{tin},{kind},{}", - party.map(|id| id.to_string()).unwrap_or_default(), - last4.unwrap_or_default(), - csv_escape(¬e) - )); - } - w.push('\n'); - } - files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes())); - } - // The threshold isn't known for this year: never guess it - // (rules.md B-19); say so in the package instead. - Err(e) => files.push(( - format!("1099-worksheet-{year}-NOT-INCLUDED.txt"), - format!("{e}\n").into_bytes(), - )), - } - // The evidence: every current file behind the year's posted - // entries, filed on them or linked to them (a file linked to an - // entry counts as its receipt), one copy per entry with a row in - // the index; and the company's documents, the files behind no - // entry. - let mut receipt_count = 0; - if p.include_receipts { - let clean = |s: &str| -> String { - s.chars() - .map(|c| { - if c.is_alphanumeric() || ".-_ ".contains(c) { - c - } else { - '_' - } - }) - .collect() - }; - let mut index = String::from("entry_id,date,description,amount,file,sha256\n"); - for a in store.list_attachments(None).map_err(|e| e.to_string())? { - if a.withdrawn_at.is_some() { - continue; - } - let mut behind: Vec = a.entry_id.into_iter().collect(); - for sub in store.attachment_links(a.id).map_err(|e| e.to_string())? { - if let ledger_store::Subject::Entry(e) = sub - && !behind.contains(&e) - { - behind.push(e); - } - } - behind.sort_unstable(); - let bytes = store.attachment_bytes(a.id).map_err(|e| e.to_string())?; - if behind.is_empty() { - if a.category != "receipts" { - files.push(( - format!("documents/{}/{}", clean(&a.category), clean(&a.file_name)), - bytes, - )); - } - continue; - } - for e in behind.iter().filter_map(|&e| ledger.get_entry(e)) { - if e.status() != ledger_core::EntryStatus::Posted - || e.date() < fy - || e.date() > as_of - { - continue; - } - let name = format!( - "receipts/{}-entry-{}-{}", - e.date(), - e.id(), - clean(&a.file_name) - ); - let amount: Decimal = e - .lines() - .iter() - .filter(|l| l.side == Side::Debit) - .map(|l| l.amount.amount) - .sum(); - index.push_str(&format!( - "{},{},\"{}\",{},{},{}\n", - e.id(), - e.date(), - e.description().replace('"', "\"\""), - money(amount), - name, - a.blob_sha - )); - files.push((name, bytes.clone())); - receipt_count += 1; - } - } - files.push(("receipts/index.csv".into(), index.into_bytes())); - } - if let Some(k1) = k1_csv { - files.push(k1); - } - let seq = store.journal_seq(); - let vault_dir = self.vault_dir(&store); + let ledger = store.ledger(); + let tins = vault + .as_ref() + .map(|vault| move |id| vault_tin(ledger, vault, id)); + // Built from the books under the lock, written nowhere + // (ledger_package INVARIANT 1). + let package = ledger_package::build_package( + &*store, + &package_settings(&self.settings()), + &ledger_package::PackageRequest { + as_of, + prepared_on: today(), + cash_accounts: &p.cash_accounts, + financing_accounts: &p.financing_accounts, + investing_accounts: &p.investing_accounts, + include_receipts: p.include_receipts, + tins: tins.as_ref().map(|f| f as ledger_package::TinSource<'_>), + }, + )?; + let vault_dir = if p.include_tins { + self.tin_record_dir(&store)? + } else { + self.vault_dir(&store) + }; drop(store); - files.push(( - "general-journal.iif".into(), - self.export_iif()?.into_bytes(), - )); - let readme = format!( - "{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold{payee_tins}.\n{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n", - name = self.settings().company.name, - basis = self.settings().company.basis, - members = if self.settings().company.members.is_empty() { - "" - } else { - "members' equity, " - }, - payee_tins = if p.include_tins { - ", with each payee's TIN" - } else { - "" - }, - k1 = if self.settings().company.members.is_empty() { - String::new() - } else { - { - let mut k = format!( - "k1-members.csv: each member's capital account for the year (Schedule K-1 Item L: beginning, contributions in cash and in property, share of income, distributions, ending), with their {}; the last row totals the members (not a K-1) to tie to Schedule L and the statement of members' equity.\n", - if p.include_tins { - "taxpayer ID" - } else { - "taxpayer ID's last four" - } - ); - let members = &self.settings().company.members; - for (i, m) in members.iter().enumerate() { - if let Some(j) = &m.files_jointly_with - && members[..i].iter().all(|o| o.name != *j) - { - k.push_str(&format!( - "{} and {j} file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).\n", - m.name - )); - } - } - k - } - }, - tins = if p.include_tins { - let mut t = String::from( - "\nThis package holds full taxpayer identification numbers: keep it out of email and shared folders, and delete copies once filed.\n", - ); - if !tin_gaps.is_empty() { - t.push_str("TINs not included:\n"); - for g in &tin_gaps { - t.push_str(&format!("- {g}\n")); - } - } - t - } else { - String::new() - }, - receipts = if p.include_receipts { - format!( - "receipts/: the {receipt_count} receipts behind the period's entries; index.csv ties each to its entry, date, amount, and SHA-256.\ndocuments/: the company's formation, legal, and tax documents.\n" - ) - } else { - String::new() - }, - ); - files.insert(0, ("README.txt".into(), readme.into_bytes())); - // Zip it. - let mut zip_buf = Vec::new(); - { - let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); - let opts = zip::write::SimpleFileOptions::default(); - for (name, bytes) in &files { - zip.start_file(name.as_str(), opts) - .map_err(|e| e.to_string())?; - use std::io::Write; - zip.write_all(bytes).map_err(|e| e.to_string())?; + if p.include_tins { + // The zip and each file in it that holds a TIN (the K-1 and + // 1099 worksheets): one given back to be filed is refused + // (tin_files.rs INVARIANT 1). + for (name, bytes) in &package.files { + let filed = name.starts_with("receipts/") || name.starts_with("documents/"); + if !filed && ledger_core::parties::has_full_tin(&String::from_utf8_lossy(bytes)) { + tin_files::record(&vault_dir, bytes)?; + } } - zip.finish().map_err(|e| e.to_string())?; + tin_files::record(&vault_dir, &package.zip)?; } - - let shown = |path: &std::path::Path| path.display().to_string(); - let (content_base64, saved_to, note) = match &dest { - Some(path) => { - if p.include_tins { - // The zip and each file in it that holds a TIN (the - // K-1 and 1099 worksheets): one extracted is refused - // too (tin_files.rs INVARIANT 1). - for (name, bytes) in &files { - let filed = name.starts_with("receipts/") || name.starts_with("documents/"); - if !filed - && ledger_core::parties::has_full_tin(&String::from_utf8_lossy(bytes)) - { - tin_files::record(&vault_dir, bytes)?; - } - } - tin_files::record(&vault_dir, &zip_buf)?; - } - write_private_new(path, &zip_buf)?; - let note = if p.include_tins { - format!( - "Saved the CPA package to {} (readable by you only). It holds full TINs: tell the human where it is, never open, attach, or copy it into the books, and have them delete it once the CPA has it.{}", - shown(path), - if tin_gaps.is_empty() { - String::new() - } else { - format!( - " {} TIN(s) are missing (tin_gaps): collect them with books.set_tin or books.set_w9 and export again.", - tin_gaps.len() - ) - } - ) - } else { - format!( - "Saved the CPA package to {} (last fours only).", - shown(path) - ) - }; - (None, Some(shown(path)), note) - } - None => ( - Some(base64::Engine::encode( - &base64::engine::general_purpose::STANDARD, - &zip_buf, - )), - None, - "The package, without full TINs (include_tins false).".to_string(), + let tin_gaps = package.tin_gaps; + let note = match (p.include_tins, tin_gaps.len()) { + (false, _) => "The package, with last fours only.".to_string(), + (true, 0) => "The package, with full TINs, for the CPA.".to_string(), + (true, n) => format!( + "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 file = self.hand_over(&book, &package.file_name, "application/zip", package.zip)?; Ok(Json(ExportCpaPackageOut { - file_name, - content_base64, - saved_to, + file, tins_included: p.include_tins, tin_gaps, note, @@ -9488,14 +8549,6 @@ impl BooksServer { } } -fn csv_escape(s: &str) -> String { - if s.contains([',', '"', '\n']) { - format!("\"{}\"", s.replace('"', "\"\"")) - } else { - s.to_string() - } -} - // --------------------------------------------------------------------------- // ServerHandler // --------------------------------------------------------------------------- @@ -10110,40 +9163,6 @@ mod tests { } } - /// A date is exactly YYYY-MM-DD, a real day from 0001-01-01 to - /// 9999-12-31 (ledger_core::calendar): no sign, no space, no fifth - /// year digit, no year 0 (P0 review round 3, S1). - #[test] - fn dates_are_four_digit_years_and_real_days() { - assert_eq!(parse_date("2026-02-28"), Ok(d(2026, 2, 28))); - assert_eq!(parse_date("2028-02-29"), Ok(d(2028, 2, 29))); - assert_eq!(parse_date("0001-01-01"), Ok(d(1, 1, 1))); - assert_eq!(parse_date("9999-12-31"), Ok(d(9999, 12, 31))); - for bad in [ - "0000-01-01", - "0000-00-00", - "2026-02-29", - "2026-02-30", - "2026-13-01", - "2026-1-05", - "2026-01-5", - " 2026-01-05", - "2026-01-05 ", - "+2026-01-05", - "-0001-01-01", - "+262142-12-31", - "-262143-01-01", - "99999-12-31", - "2026/01/05", - "2026-01-05T00:00", - "2026-01-05", - "", - ] { - let err = parse_date(bad).expect_err(bad); - assert!(err.contains("0001-01-01 to 9999-12-31"), "{bad:?}: {err}"); - } - } - /// Settings naming a code are found: cash/financing/investing roles, /// close_into, and each member's capital and distributions accounts. #[test] @@ -10333,32 +9352,6 @@ mod tests { ); } - #[test] - fn money_always_has_cents_and_never_rounds() { - let m = |s: &str| money(Decimal::from_str(s).unwrap()); - assert_eq!(money(Decimal::ZERO), "0.00"); - assert_eq!(m("-0"), "0.00"); - assert_eq!(m("1000"), "1000.00"); - assert_eq!(m("5.8"), "5.80"); - assert_eq!(m("-207.68"), "-207.68"); - assert_eq!(m("12.3456"), "12.3456"); - } - - /// A share prints by its value, never its spelling: at least two - /// places, a third keeping its own. - #[test] - fn a_share_prints_the_same_however_it_was_written() { - let t = |s: &str| share_text(Decimal::from_str(s).unwrap()); - for half in ["0.5", "0.50", "0.500000"] { - assert_eq!(t(half), "0.50", "{half}"); - } - assert_eq!(t("1"), "1.00"); - assert_eq!(t("1.0"), "1.00"); - assert_eq!(t("0.25"), "0.25"); - assert_eq!(t("0.3333"), "0.3333"); - assert_eq!(t("0.125"), "0.125"); - } - /// Every tool says what it is and what it does to the books (plan /// 4.9, "Annotations (P0)"): a title and readOnlyHint on all; a /// mutating tool also says whether it is destructive. None asks the @@ -10618,9 +9611,6 @@ mod tests { ("STOPPED", STOPPED), ("KEY_IN_BOOKS", KEY_IN_BOOKS), ("VAULT_IN_HISTORY", VAULT_IN_HISTORY), - ("INSIDE_BOOKS", INSIDE_BOOKS), - ("NO_FOLDER", NO_FOLDER), - ("EXISTS", EXISTS), ("tin_files::TIN_FILE", tin_files::TIN_FILE), ("tin_files::TIN_HELD", tin_files::TIN_HELD), ("tin_files::TIN_PROBE", tin_files::TIN_PROBE), @@ -10702,9 +9692,7 @@ mod tests { /// What `tool_text_lint` finds in one text beyond its fixed list: a /// helper command however it ends (`just` as a word before a /// backtick, a line break, a tab or the end), and where the books are - /// ("beside the books"; "books dir" except in the tools and refusals - /// that say a file with a TIN is saved outside it, save_to and - /// save_csv_to). + /// ("beside the books", "books dir"). fn helper_or_location(what: &str, text: &str) -> Vec { let lower = text.to_lowercase(); let mut found = Vec::new(); @@ -10721,16 +9709,7 @@ mod tests { "{what} says where the books are: \"beside the books\"" )); } - let saves = [ - "books.vendor_1099", - "books.get_w9", - "books.export_cpa_package", - ] - .iter() - .any(|t| what == format!("tool {t}")) - || lower.contains("save_to") - || lower.contains("save_csv_to"); - if lower.contains("books dir") && !saves { + if lower.contains("books dir") { found.push(format!("{what} says where the books are: \"books dir\"")); } found @@ -10752,29 +9731,26 @@ mod tests { ("justly", 0), ("the file beside the books", 1), ("in the books dir", 1), - ("save_to: a path outside the books dir", 0), + ("a path outside the books dir", 1), ("the books directory", 1), ("bookshelf", 0), ] { assert_eq!(helper_or_location("text", text).len(), n, "{text:?}"); } - assert!(helper_or_location("tool books.get_w9", "outside the books dir").is_empty()); + assert_eq!( + helper_or_location("tool books.get_w9", "outside the books dir").len(), + 1 + ); assert_eq!( helper_or_location("tool books.chart", "outside the books dir").len(), 1 ); } - /// No tool takes a path on the server's computer (plan 4.7, D-28): - /// files come by upload or inline. The three outputs with full TINs - /// keep theirs until the viewer takes them over (P2 slice 6). + /// No tool takes a path on the server's computer (plan 4.7, D-28, + /// D-29): files come in by upload or inline, and go out as exports. #[test] fn no_tool_takes_a_path() { - const UNTIL_THE_VIEWER: &[(&str, &str)] = &[ - ("books.export_cpa_package", "save_to"), - ("books.vendor_1099", "save_csv_to"), - ("books.get_w9", "save_to"), - ]; fn names(v: &serde_json::Value, out: &mut Vec) { match v { serde_json::Value::Object(map) => { @@ -10818,7 +9794,7 @@ mod tests { || lower == "to" || lower == "from" || lower == "source"; - if pathlike && !UNTIL_THE_VIEWER.contains(&(&*t.name, p.as_str())) { + if pathlike { found.push(format!("{} {p}", t.name)); } } diff --git a/crates/ledger-mcp/src/tools/files.rs b/crates/ledger-mcp/src/tools/files.rs index 4704dfb..66ed5ae 100644 --- a/crates/ledger-mcp/src/tools/files.rs +++ b/crates/ledger-mcp/src/tools/files.rs @@ -24,6 +24,10 @@ //! refusal leaves it for the right one. //! 5. Inline text longer than INLINE_MAX bytes' base64 is refused //! before it is decoded. +//! 6. A file the books make for an agent (D-29) is handed over as an +//! export the caller's token fetches with `wanderbooks download` +//! (books served from the data root), or over stdio inline; never +//! by a path. //! ``` use super::*; @@ -114,7 +118,83 @@ pub struct BeginUploadOut { pub existing_attachment_id: Option, } +/// `name` made a name the checks take: control and invisible characters +/// and separators dropped or made `_`, cut to fit; `file` if nothing is +/// left. +fn plain_file_name(name: &str) -> String { + let mut out: String = name + .chars() + .filter(|c| !ledger_store::uploads::is_hidden_char(*c)) + .map(|c| { + if c.is_control() || c == '/' || c == '\\' { + '_' + } else { + c + } + }) + .collect(); + while out.len() > ledger_store::uploads::MAX_NAME_BYTES { + out.pop(); + } + let out = out.trim().to_string(); + if ledger_store::uploads::file_name_problem(&out).is_none() { + out + } else { + "file".to_string() + } +} + impl BooksServer { + /// Hand `bytes` over as `file_name` (INVARIANT 6): kept as an export + /// for the caller's token, or inline over stdio. + pub(crate) fn hand_over( + &self, + book: &str, + file_name: &str, + mime: &str, + bytes: Vec, + ) -> Result { + // A name kept before names were checked (a W-9 sealed earlier) is + // made plain rather than refused: the file is the point. + let file_name = match ledger_store::uploads::file_name_problem(file_name) { + None => file_name.to_string(), + Some(_) => plain_file_name(file_name), + }; + let mut out = HandedFile { + file_name: file_name.clone(), + mime_type: mime.to_string(), + size_bytes: bytes.len() as u64, + sha256: ledger_store::journal::sha256_hex(&bytes), + download: None, + expires_at: None, + content_base64: None, + }; + let (Some(exports), Some(token)) = (&self.exports, self.caller_token()) else { + out.content_base64 = Some(base64::Engine::encode( + &base64::engine::general_purpose::STANDARD, + &bytes, + )); + return Ok(out); + }; + let kept = exports + .put( + &token, + &file_name, + mime, + &bytes, + chrono::Utc::now().timestamp(), + ) + .map_err(|e| e.to_string())?; + out.download = Some(format!( + "wanderbooks download --book {book}{} {} --to ", + token_flag(), + kept.id + )); + out.expires_at = chrono::DateTime::from_timestamp(kept.expires, 0) + .map(|t| t.to_rfc3339_opts(chrono::SecondsFormat::Secs, true)); + Ok(out) + } + /// The token of the call running on this thread (its id), if any. fn caller_token(&self) -> Option { CALLER.with(|c| c.borrow().as_ref().map(|(id, _)| id.clone())) @@ -264,3 +344,37 @@ impl BooksServer { })) } } + +#[cfg(test)] +mod tests { + use super::plain_file_name; + + /// A name kept before names were checked is handed over cleaned, never + /// refused (a W-9 sealed earlier, say). + #[test] + fn an_old_name_is_made_plain() { + assert_eq!( + plain_file_name("W9 \u{2714}\u{FE0F}.pdf"), + "W9 \u{2714}.pdf" + ); + assert_eq!(plain_file_name("W-9\u{200E}.pdf"), "W-9.pdf"); + assert_eq!(plain_file_name("a/b\\c\n.pdf"), "a_b_c_.pdf"); + assert_eq!(plain_file_name("\u{200B}"), "file"); + assert_eq!(plain_file_name(".."), "file"); + let long = "x".repeat(400); + let cut = plain_file_name(&long); + assert!(cut.len() <= ledger_store::uploads::MAX_NAME_BYTES); + for n in [ + "W9 \u{2714}\u{FE0F}.pdf", + "a/b\\c\n.pdf", + &long, + "\u{202E}fdp.exe", + ] { + assert_eq!( + ledger_store::uploads::file_name_problem(&plain_file_name(n)), + None, + "{n:?}" + ); + } + } +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index c0ddf22..41cb566 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -362,15 +362,14 @@ pub struct Vendor1099Params { /// through 2025, $2,000 from 2026). pub threshold: Option, /// True: each line carries its payee's full TIN, read from the TIN - /// vault (or a note saying why not). The output then holds full TINs: - /// for the CPA only, when the human asks for it. Default false (last - /// four digits only). + /// vault (or a note saying why not). Default false (last four digits + /// only). #[serde(default)] pub include_tins: bool, - /// Also write the worksheet as CSV to this local path (TINs included - /// only with include_tins). Never inside the books dir, and with TINs - /// an existing file is refused. - pub save_csv_to: Option, + /// True: also hand the worksheet over as a CSV file (TINs included + /// with include_tins), to save with its download command. + #[serde(default)] + pub csv: bool, } /// One vendor's year. @@ -415,11 +414,10 @@ pub struct Vendor1099Out { pub missing_w9: Vec, /// Caveats for the human and CPA. pub notes: Vec, - /// With include_tins: this output holds full TINs. For the CPA only; - /// never paste it anywhere else. + /// With include_tins: this output holds full TINs, for the CPA. pub warning: Option, - /// The CSV written with save_csv_to, if any. - pub saved_csv: Option, + /// With csv: the worksheet as a CSV file, handed over. + pub csv: Option, } /// Parameters for `books.verify`. @@ -492,6 +490,8 @@ pub struct ShowParams { pub struct ShowOut { /// What the viewer was asked to show. pub shown: String, + /// Whether a viewer has these books open now. + pub viewer_open: bool, /// Where this goes and what to tell the human. pub note: String, } @@ -2604,28 +2604,16 @@ pub struct ExportCpaPackageParams { pub include_receipts: bool, /// Carry every member's and 1099 payee's full TIN from the TIN vault /// in the K-1 and 1099 worksheets (default true: the CPA needs them). - /// Then `save_to` is required, outside the books dir, and the package - /// never comes back in the output. False: last fours only, and the - /// zip comes back as base64 unless `save_to` is given. + /// False: last fours only. #[serde(default = "default_true")] pub include_tins: bool, - /// Where to write the package: a new `.zip` file, or an existing - /// folder (the zip goes in it as `cpa-package-.zip`). Written - /// readable by its owner only. With TINs, never inside the books dir. - pub save_to: Option, } /// Output of `books.export_cpa_package`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ExportCpaPackageOut { - /// File name, e.g. "cpa-package-2026-10-11.zip". - pub file_name: String, - /// The zip, base64-encoded: only when it isn't saved (`save_to` - /// absent, which needs include_tins false). - pub content_base64: Option, - /// The path the zip was written to, when saved. Tell the human; never - /// open it, attach it, or copy it into the books. - pub saved_to: Option, + /// The zip: run its `download` to save it. + pub file: HandedFile, /// Whether the worksheets carry full TINs from the vault. pub tins_included: bool, /// With TINs: each member or 1099 payee whose TIN the package lacks, @@ -2822,24 +2810,39 @@ pub struct TinOut { pub struct GetW9Params { /// The party: id or name. pub party: String, - /// Where to write the W-9 file: a new local file outside the books - /// (the file shows the full TIN). An existing file is refused. - pub save_to: String, } -/// Result of `books.get_w9`: where the file went, never its contents. +/// A file the books made for an agent, handed over (tools/files.rs +/// INVARIANT 6; D-29). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct HandedFile { + /// Its name, e.g. "cpa-package-2026-10-11.zip". + pub file_name: String, + /// Its MIME type. + pub mime_type: String, + /// Its size in bytes. + pub size_bytes: u64, + /// Its SHA-256, hex. + pub sha256: String, + /// The command that saves it in a folder you name (run it; books + /// served by `wanderbooks mcp`). Kept 15 minutes. + #[serde(skip_serializing_if = "Option::is_none")] + pub download: Option, + /// When it lapses (UTC), with `download`. + #[serde(skip_serializing_if = "Option::is_none")] + pub expires_at: Option, + /// Over stdio: its bytes, base64. + #[serde(skip_serializing_if = "Option::is_none")] + pub content_base64: Option, +} + +/// Result of `books.get_w9`: the W-9 file, handed over. #[derive(Debug, Clone, Serialize, JsonSchema)] pub struct GetW9Out { /// The party. pub party_id: u64, - /// The path written. - pub path: String, - /// The file's original name. - pub file_name: String, - /// Its size in bytes. - pub size_bytes: u64, - /// What to tell the human. - pub note: String, + /// The file: run its `download` to save it. + pub file: HandedFile, } /// Parameters for `books.purge_tin`. diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 54642be..6d01823 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -798,20 +798,17 @@ fn init(mcp: &mut Mcp) -> Value { } /// Outputs that carry full TINs (books.get_w9, books.vendor_1099 with -/// include_tins, books.export_cpa_package with TINs) are never written -/// inside the books, and wherever they are written (a downloads folder -/// too), each is refused by its bytes when given back to be filed: a -/// TIN-bearing file can't be filed back in as a receipt or document and -/// read out through books.get_receipt (S7 of the P0 review, 2026-10-16). +/// include_tins, books.export_cpa_package with TINs) come back as files +/// handed over (D-29: no tool takes a path), and each is refused by its +/// bytes when given back to be filed: a TIN-bearing file can't be filed +/// back in as a receipt or document and read out through +/// books.get_receipt (S7 of the P0 review, 2026-10-16). #[test] -fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() { +fn tin_bearing_files_are_refused_when_given_back_to_be_filed() { let dir = test_dir("tin-files"); let books = dir.join("books"); - let downloads = dir.join("downloads"); let config = dir.join("config"); - for d in [&downloads, &config] { - std::fs::create_dir_all(d).unwrap(); - } + std::fs::create_dir_all(&config).unwrap(); let w9 = b"%PDF-1.4 W-9 TIN 291-12-2846"; let mut mcp = Mcp::spawn_env( &books.join("books-data"), @@ -833,78 +830,44 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() { "tin": "291-12-2846", "tin_type": "ssn", "w9_content_base64": base64_like(w9), "w9_sha256": sha(w9)}), ); - // Inside the books: refused, and nothing written there. - let before = std::fs::read_dir(&books).unwrap().count(); - let in_books = |name: &str| books.join(name).to_str().unwrap().to_string(); - for (tool, args) in [ - ( - "books.get_w9", - json!({"party": "Acme", "save_to": in_books("copy.pdf")}), - ), - ( - "books.vendor_1099", - json!({"year": 2026, "include_tins": true, "save_csv_to": in_books("1099.csv")}), - ), - ( - "books.export_cpa_package", - json!({"cash_accounts": ["1000"], "save_to": in_books("cpa.zip")}), - ), - ( - "books.export_cpa_package", - json!({"cash_accounts": ["1000"], "save_to": books.to_str().unwrap()}), - ), - ] { - let why = mcp.call_err(tool, args); - assert!(why.contains("inside the books"), "{tool}: {why}"); - } - assert_eq!( - std::fs::read_dir(&books).unwrap().count(), - before, - "wrote into the books" - ); - // Written to a downloads folder, each is refused by its bytes, by - // every way a file is filed. - let in_downloads = |name: &str| downloads.join(name).to_str().unwrap().to_string(); - mcp.call( - "books.get_w9", - json!({"party": "Acme", "save_to": in_downloads("copy.pdf")}), - ); - mcp.call( + // Each comes back inline over stdio. + let got = mcp.call("books.get_w9", json!({"party": "Acme"})); + let w9_back = handed(&got["file"]); + assert_eq!(w9_back, w9, "the W-9 file, as stored"); + let ws = mcp.call( "books.vendor_1099", - json!({"year": 2026, "include_tins": true, "save_csv_to": in_downloads("1099.csv")}), + json!({"year": 2026, "include_tins": true, "csv": true}), ); - mcp.call( + assert_eq!(ws["csv"]["file_name"], "1099-worksheet-2026-with-tins.csv"); + let pkg = mcp.call( "books.export_cpa_package", - json!({"cash_accounts": ["1000"], "save_to": in_downloads("cpa.zip")}), + json!({"cash_accounts": ["1000"]}), ); - let written: Vec = std::fs::read_dir(&downloads) - .unwrap() - .map(|e| e.unwrap().path()) - .collect(); - assert_eq!(written.len(), 3, "{written:?}"); + assert_eq!(pkg["tins_included"], true); + let handed_over = [w9_back, handed(&ws["csv"]), handed(&pkg["file"])]; + // Given back, each is refused by its bytes, by every way a file is + // filed. let journal = std::fs::read(books.join("books-data").join(ledger_store::JOURNAL_FILE)).unwrap(); - for path in &written { - let bytes = std::fs::read(path).unwrap(); + for (n, bytes) in handed_over.iter().enumerate() { for (tool, args) in [ ( "books.attach_receipt", json!({"file_name": "receipt.pdf", "mime_type": "application/pdf", - "content_base64": base64_like(&bytes), - "sha256": sha(&bytes)}), + "content_base64": base64_like(bytes), + "sha256": sha(bytes)}), ), ( "books.file_document", json!({"category": "statement", "file_name": "statement.csv", "mime_type": "text/csv", - "content_base64": base64_like(&bytes), - "sha256": sha(&bytes)}), + "content_base64": base64_like(bytes), + "sha256": sha(bytes)}), ), ] { let why = mcp.call_err(tool, args); assert!( why.contains("wrote with full TINs"), - "{tool} {}: {why}", - path.display() + "{tool} file {n}: {why}" ); } } @@ -913,10 +876,16 @@ fn tin_bearing_files_never_land_where_they_could_be_filed_back_in() { journal, "a refusal stored something" ); - // Without TINs, the 1099 worksheet may go anywhere a file may. + // Without TINs, the 1099 worksheet is an ordinary file: it may be + // filed like any other. + let plain = mcp.call("books.vendor_1099", json!({"year": 2026, "csv": true})); + assert!(plain["warning"].is_null(), "{plain}"); + assert_eq!(plain["csv"]["file_name"], "1099-worksheet-2026.csv"); + let bytes = handed(&plain["csv"]); mcp.call( - "books.vendor_1099", - json!({"year": 2026, "save_csv_to": in_downloads("plain.csv")}), + "books.file_document", + json!({"category": "statement", "file_name": "plain.csv", "mime_type": "text/csv", + "content_base64": base64_like(&bytes), "sha256": sha(&bytes)}), ); } @@ -1185,6 +1154,21 @@ fn base64_like(bytes: &[u8]) -> String { out } +/// The bytes of a file a tool handed over (a `HandedFile`): over stdio +/// they come inline, with no download command, and match the size and +/// SHA-256 it states. +fn handed(file: &Value) -> Vec { + use base64::Engine; + assert!(file.get("download").is_none(), "{file}"); + assert!(file.get("expires_at").is_none(), "{file}"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(file["content_base64"].as_str().expect("content_base64")) + .unwrap(); + assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); + assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}"); + bytes +} + #[test] fn mcp_import_export_over_stdio() { let dir = test_dir("import_export"); @@ -1306,7 +1290,10 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() { "books.export_cpa_package", json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_tins": false}), ); - let zip_bytes = b64.decode(pkg["content_base64"].as_str().unwrap()).unwrap(); + assert_eq!(pkg["file"]["file_name"], "cpa-package-2026-10-31.zip"); + assert_eq!(pkg["file"]["mime_type"], "application/zip"); + assert_eq!(pkg["tins_included"], false); + let zip_bytes = handed(&pkg["file"]); let mut zip = zip::ZipArchive::new(std::io::Cursor::new(zip_bytes)).unwrap(); let mut names: Vec = (0..zip.len()) .map(|i| zip.by_index(i).unwrap().name().unwrap().to_string()) @@ -1358,11 +1345,7 @@ fn mcp_statement_pdfs_and_cpa_package_over_stdio() { "books.export_cpa_package", json!({"as_of": "2026-10-31", "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}), ); - let light = zip::ZipArchive::new(std::io::Cursor::new( - b64.decode(light["content_base64"].as_str().unwrap()) - .unwrap(), - )) - .unwrap(); + let light = zip::ZipArchive::new(std::io::Cursor::new(handed(&light["file"]))).unwrap(); assert_eq!(light.len(), rest.len()); } @@ -2040,19 +2023,28 @@ fn mcp_vendor_1099_over_stdio() { assert_eq!(r["threshold"], "2100.00"); let vendors = mcp.call("books.list_vendors", json!({})); assert_eq!(vendors["vendors"][0]["tin_on_file"], true); - // Show the entry in the viewer: a request outside the books. + // The server made the books' ops/ as they opened: a viewer open from + // before the first books.show is seen by it. + let viewer = ledger_store::ops::Presence::hold(&dir).expect("ops/ made at start"); + // Show the entry in the viewer: a request in the books' ops/, never + // journaled. let shown = mcp.call("books.show", json!({"entry_id": id})); assert!(shown["shown"].as_str().unwrap().contains(&format!("#{id}"))); - let cmd = ledger_store::viewer::read(&dir).expect("request written"); + assert_eq!(shown["viewer_open"], true); + let cmd = ledger_store::ops::read(&dir).expect("request written"); assert_eq!( cmd.target, - ledger_store::viewer::ViewerTarget::Entry { entry_id: id } + ledger_store::ops::ViewerTarget::Entry { entry_id: id } ); + // With no viewer, the note says to open one. + drop(viewer); + let shown = mcp.call("books.show", json!({"page": "cash"})); + assert_eq!(shown["viewer_open"], false); + assert!(shown["note"].as_str().unwrap().contains("No viewer")); let err = mcp.call_err("books.show", json!({"entry_id": 9999})); assert!(err.contains("unknown entry"), "{err}"); let err = mcp.call_err("books.show", json!({"page": "cash", "search": "x"})); assert!(err.contains("exactly one"), "{err}"); - let _ = std::fs::remove_file(ledger_store::viewer::command_path(&dir)); // The books check out end to end, receipt included. let v = mcp.call("books.verify", json!({})); assert_eq!(v["status"], "ok"); diff --git a/crates/ledger-mcp/tests/oracle_mcp.rs b/crates/ledger-mcp/tests/oracle_mcp.rs index 6aad501..dd18697 100644 --- a/crates/ledger-mcp/tests/oracle_mcp.rs +++ b/crates/ledger-mcp/tests/oracle_mcp.rs @@ -16,7 +16,7 @@ use ledger_core::{ Money, NewAsset, Side, depreciation_schedule, reports, }; use ledger_store::Store; -use ledger_store::viewer::{ViewerCommand, ViewerTarget}; +use ledger_store::ops::{ViewerCommand, ViewerTarget}; use rust_decimal::Decimal; use serde_json::{Value, json}; use std::collections::{BTreeMap, HashMap}; @@ -235,17 +235,15 @@ impl Mcp { out["content"].as_str().unwrap().to_string() } - /// The viewer command the last `books.show` wrote. + /// The viewer command the last `books.show` wrote, in the books' own + /// `ops/` (nothing in the runtime folder). fn viewer_command(&self) -> ViewerCommand { - let dir = self.run.join("wanderbooks"); - let files: Vec<_> = std::fs::read_dir(&dir) - .unwrap() - .filter_map(|e| e.ok()) - .map(|e| e.path()) - .filter(|p| p.extension().is_some_and(|x| x == "json")) - .collect(); - assert_eq!(files.len(), 1, "one viewer command file: {files:?}"); - serde_json::from_slice(&std::fs::read(&files[0]).unwrap()).unwrap() + let books = self.run.parent().unwrap(); + assert!( + !self.run.join("wanderbooks").exists(), + "nothing written in the runtime folder" + ); + ledger_store::ops::read(&data_dir(books)).expect("a show request") } } @@ -265,6 +263,18 @@ fn sha256(bytes: &[u8]) -> String { ledger_store::journal::sha256_hex(bytes) } +/// The bytes of a file a tool handed over (a `HandedFile`): over stdio +/// they come inline, with no download command, and match the size and +/// SHA-256 it states. +fn handed(file: &Value) -> Vec { + assert!(file.get("download").is_none(), "{file}"); + assert!(file.get("expires_at").is_none(), "{file}"); + let bytes = b64d(file["content_base64"].as_str().expect("content_base64")); + assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); + assert_eq!(file["sha256"], json!(sha256(&bytes)), "{file}"); + bytes +} + fn d(s: &str) -> chrono::NaiveDate { chrono::NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() } @@ -2835,8 +2845,9 @@ fn cpa_package_holds_the_year_and_its_evidence() { "books.export_cpa_package", json!({"as_of": END, "cash_accounts": ["1000"], "include_tins": false}), ); - assert_eq!(out["file_name"], format!("cpa-package-{END}.zip")); - let files = unzip(&b64d(out["content_base64"].as_str().unwrap())); + assert_eq!(out["file"]["file_name"], format!("cpa-package-{END}.zip")); + assert_eq!(out["file"]["mime_type"], "application/zip"); + let files = unzip(&handed(&out["file"])); let names: Vec<&str> = files.keys().map(String::as_str).collect(); let mut want = vec![ @@ -2944,7 +2955,7 @@ fn cpa_package_holds_the_year_and_its_evidence() { "books.export_cpa_package", json!({"as_of": END, "cash_accounts": ["1000"], "include_receipts": false, "include_tins": false}), ); - let files = unzip(&b64d(out["content_base64"].as_str().unwrap())); + let files = unzip(&handed(&out["file"])); assert!( !files .keys() diff --git a/crates/ledger-mcp/tests/spec_attachments.rs b/crates/ledger-mcp/tests/spec_attachments.rs index 3331ce3..f2a6ab6 100644 --- a/crates/ledger-mcp/tests/spec_attachments.rs +++ b/crates/ledger-mcp/tests/spec_attachments.rs @@ -1305,9 +1305,14 @@ fn the_cpa_package_carries_receipts_linked_to_entries() { "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ); + // The zip comes back inline over stdio, as stated (D-29). + let file = &pkg["file"]; + assert!(file.get("download").is_none(), "{file}"); let bytes = base64::engine::general_purpose::STANDARD - .decode(pkg["content_base64"].as_str().unwrap()) + .decode(file["content_base64"].as_str().unwrap()) .unwrap(); + assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); + assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}"); let mut zip = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let names: Vec = (0..zip.len()) .map(|i| zip.by_index(i).unwrap().name().unwrap().to_string()) @@ -1394,13 +1399,12 @@ fn show_opens_a_reconciliation() { .unwrap(); let out = m.read_only("books.show", json!({"reconciliation": rid})); assert!(out["shown"].as_str().unwrap().contains("1000"), "{out}"); - let run = m.data.parent().unwrap().join("run/wanderbooks"); - let cmd: Value = std::fs::read_dir(&run) - .unwrap() - .filter_map(|e| std::fs::read(e.unwrap().path()).ok()) - .find_map(|b| serde_json::from_slice(&b).ok()) - .expect("a viewer request"); - assert_eq!(cmd["target"], json!({"show": "reconciliation", "id": rid})); + // The request is in the books' own ops/ (ledger_store::ops). + let cmd = ledger_store::ops::read(&m.data).expect("a viewer request"); + assert_eq!( + serde_json::to_value(&cmd.target).unwrap(), + json!({"show": "reconciliation", "id": rid}) + ); m.refuse("books.show", json!({"reconciliation": 999})); m.refuse( "books.show", diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 87d82f9..0cccaa4 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -252,9 +252,6 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child { .into_iter() .flatten(), ) - // books.show writes a viewer request here; keep it out of the - // user's real runtime dir. - .env("XDG_RUNTIME_DIR", &run) // The TIN vault's default key file is under XDG_CONFIG_HOME: never // the user's real one (the happy path names its own anyway). .env("XDG_CONFIG_HOME", outside(dir).join("config")) @@ -334,6 +331,11 @@ impl Mcp { for e in std::fs::read_dir(dir).into_iter().flatten().flatten() { let p = e.path(); let name = p.file_name().unwrap().to_string_lossy().into_owned(); + // The viewer's side channel (books.show's requests) is not + // the books: never journaled (ledger_store::ops). + if p.is_dir() && name == ledger_store::ops::OPS_DIR { + continue; + } if p.is_dir() { walk(&p, out); } else if name.ends_with("-wal") || name.ends_with("-shm") { @@ -812,11 +814,7 @@ fn happy_path(name: &str) -> Mcp { "tin": "79-9714321", "tin_type": "ein", "w9_content_base64": b64(w9_pdf), "w9_sha256": sha(w9_pdf)}), ); - m.ok( - "books.get_w9", - json!({"party": "Landlord", - "save_to": outside(&dir).join("landlord-w9-copy.pdf").to_str().unwrap()}), - ); + m.ok("books.get_w9", json!({"party": "Landlord"})); m.ok("books.parties", json!({"role": "landlord", "year": 2026})); // Owner contribution: draft, lines, receipt, preview, post. @@ -1234,10 +1232,10 @@ fn happy_path(name: &str) -> Mcp { m.ok("books.digest", json!({"since": "2026-01-01"})); m.ok("books.search_entries", json!({"text": "rent", "limit": 10})); m.ok("books.vendor_1099", json!({"year": 2026})); + m.ok("books.vendor_1099", json!({"year": 2026, "csv": true})); m.ok( "books.vendor_1099", - json!({"year": 2026, "include_tins": true, - "save_csv_to": outside(&dir).join("1099.csv").to_str().unwrap()}), + json!({"year": 2026, "include_tins": true, "csv": true}), ); m.ok( "books.purge_tin", @@ -1279,8 +1277,7 @@ fn happy_path(name: &str) -> Mcp { ); m.ok( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "include_receipts": false, - "save_to": outside(&dir).join("cpa.zip").to_str().unwrap()}), + json!({"as_of": "2026-12-31", "include_receipts": false}), ); m.books_dir = Some(dir); diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-mcp/tests/spec_vault.rs index a6f2840..009d0a9 100644 --- a/crates/ledger-mcp/tests/spec_vault.rs +++ b/crates/ledger-mcp/tests/spec_vault.rs @@ -9,8 +9,8 @@ //! SQLite projection, the viewer, or any tool output, except the 1099 //! worksheet for the CPA when asked (`books.vendor_1099` with //! `include_tins`); and the vault holds everyone's, members included, for -//! the CPA package, which carries them only in a zip saved outside the -//! books dir (DECISION, owner, 2026-10-15: "In the vault, that's the +//! the CPA package, which carries them only in the zip it hands over, +//! never kept in the books dir (DECISION, owner, 2026-10-15: "In the vault, that's the //! WHOLE reason we built it. And it needs to be in the damn CPA //! package."). The harness is `tests/spec_parties.rs`'s, with a //! transcript of every response and the server's stderr kept, and the vault @@ -444,8 +444,8 @@ const MEMBER_SSN: &str = "241-40-4915"; const MEMBER_SSN_DIGITS: &str = "241404915"; /// Every file in a zip, by name. -fn unzip(path: &Path) -> HashMap> { - let mut z = zip::ZipArchive::new(std::fs::File::open(path).unwrap()).unwrap(); +fn unzip(bytes: &[u8]) -> HashMap> { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); let mut out = HashMap::new(); for i in 0..z.len() { let mut f = z.by_index(i).unwrap(); @@ -456,6 +456,61 @@ fn unzip(path: &Path) -> HashMap> { out } +/// The bytes of a file a tool handed over (a `HandedFile`, D-29): over +/// stdio they come inline, with no download command, and match the size +/// and SHA-256 it states. +fn handed(file: &Value) -> Vec { + use base64::Engine as _; + assert!(file.get("download").is_none(), "{file}"); + assert!(file.get("expires_at").is_none(), "{file}"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(file["content_base64"].as_str().expect("content_base64")) + .unwrap(); + assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); + assert_eq!(file["sha256"], json!(sha(&bytes)), "{file}"); + bytes +} + +/// `v` with every file's inline bytes (`content_base64`) taken out, each +/// pushed to `files` decoded, with its `file_name`: what a tool said, apart +/// from the files it handed over. +fn take_files(v: &Value, files: &mut Vec<(String, Vec)>) -> Value { + use base64::Engine as _; + match v { + Value::Object(o) => { + if let Some(b) = o.get("content_base64").and_then(Value::as_str) { + let name = o["file_name"].as_str().unwrap_or_default().to_string(); + let bytes = base64::engine::general_purpose::STANDARD + .decode(b) + .unwrap_or_else(|e| panic!("{name}: {e}")); + files.push((name, bytes)); + } + Value::Object( + o.iter() + .filter(|(k, _)| k.as_str() != "content_base64") + .map(|(k, v)| (k.clone(), take_files(v, files))) + .collect(), + ) + } + Value::Array(a) => Value::Array(a.iter().map(|v| take_files(v, files)).collect()), + other => other.clone(), + } +} + +/// `v` without the bytes of the files it hands over. +fn said(v: &Value) -> String { + take_files(v, &mut Vec::new()).to_string() +} + +/// The paths of every file under `dir` but the vault's: what a tool +/// wrote, compared before and after. +fn paths_outside_vault(m: &Mcp, dir: &Path) -> std::collections::BTreeSet { + files_under(dir) + .into_iter() + .filter(|f| !f.starts_with(m.vault())) + .collect() +} + /// A CSV's rows (header first) as maps from column name to cell, cells /// split on commas outside double quotes. fn csv_rows(bytes: &[u8]) -> Vec> { @@ -784,56 +839,25 @@ fn the_w9_file_goes_to_the_vault_never_the_documents() { docs["receipts"].as_array().unwrap().is_empty(), "not a document: {docs}" ); - // Read back to a path outside the books: the bytes, owner-only. - let to = m.outside().join("copy.pdf"); - let out = m.ok( - "books.get_w9", - json!({"party": "Acme Design", "save_to": to.to_str().unwrap()}), + // Read back: the bytes, handed over (inline over stdio), named for + // their type; nothing written anywhere, and the output shows no TIN + // but in the file itself. + let (books_before, outside_before) = ( + paths_outside_vault(&m, &m.books_dir()), + files_under(&m.outside()), ); - assert_eq!(out["path"], json!(to.display().to_string())); - // Given inline, the file is named for its type. - assert_eq!(out["file_name"], "w9.pdf"); - assert_eq!(std::fs::read(&to).unwrap(), src); - assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS)); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&to).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600); - } - let books = m.books_dir(); - for save_to in [ - to.clone(), // exists - books.join("w9.pdf"), // in the books dir - m.data.join("w9.pdf"), // in the data dir - books.join("vault/../w9.pdf"), // in it, by a detour - m.outside().join("no/such/dir/w9.pdf"), // no folder - ] { - m.refuse( - "books.get_w9", - json!({"party": "Acme Design", "save_to": save_to.to_str().unwrap()}), - ); - } - // Each refused as such, never as a failed write. - for (save_to, said) in [ - (to.clone(), "a file is there already"), - ( - m.outside().join("no/such/dir/w9.pdf"), - "the folder for that path doesn't exist", - ), - ] { - let why = m.refuse( - "books.get_w9", - json!({"party": "Acme Design", "save_to": save_to.to_str().unwrap()}), - ); - assert!(why.contains(said), "{why}"); - } + let out = m.ok("books.get_w9", json!({"party": "Acme Design"})); + assert_eq!(out["party_id"], json!(acme)); + assert_eq!(out["file"]["file_name"], "w9.pdf"); + assert_eq!(out["file"]["mime_type"], "application/pdf"); + assert_eq!(handed(&out["file"]), src); + assert!(!holds_tin(said(&out).as_bytes(), SSN_DIGITS), "{out}"); + assert_eq!(paths_outside_vault(&m, &m.books_dir()), books_before); + assert_eq!(files_under(&m.outside()), outside_before); // A party with no W-9 file in the vault. m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"})); - m.refuse( - "books.get_w9", - json!({"party": "Rent Co", "save_to": m.outside().join("rent.pdf").to_str().unwrap()}), - ); + let why = m.refuse("books.get_w9", json!({"party": "Rent Co"})); + assert!(why.contains("holds no W-9 file"), "{why}"); // A W-9 file alone (the TIN typed in later) records the vault too. m.add_party("Solo", &["contractor"]); let solo = m.w9_file("000"); @@ -870,13 +894,9 @@ fn every_w9_file_type_is_taken() { panic!("{name} refused") }; assert_eq!(p["w9"]["in_vault"], true, "{name}"); - let to = m.outside().join(format!("copy-{n}")); - let out = m.ok( - "books.get_w9", - json!({"party": party, "save_to": to.to_str().unwrap()}), - ); - assert_eq!(out["file_name"], *name); - assert_eq!(std::fs::read(&to).unwrap(), *bytes); + let out = m.ok("books.get_w9", json!({"party": party})); + assert_eq!(out["file"]["file_name"], *name); + assert_eq!(handed(&out["file"]), *bytes); } // Its own name, when given; given alone, a name is refused. m.add_party("Payee N", &["contractor"]); @@ -888,12 +908,9 @@ fn every_w9_file_type_is_taken() { ) else { panic!("named W-9 refused") }; - let to = m.outside().join("copy-named"); - let out = m.ok( - "books.get_w9", - json!({"party": "Payee N", "save_to": to.to_str().unwrap()}), - ); - assert_eq!(out["file_name"], "payee-n-w9.pdf"); + let out = m.ok("books.get_w9", json!({"party": "Payee N"})); + assert_eq!(out["file"]["file_name"], "payee-n-w9.pdf"); + assert_eq!(handed(&out["file"]), bytes); let why = m .w9( "Payee N", @@ -931,10 +948,7 @@ fn a_locked_vault_says_so_and_never_guesses() { m.pay("Acme Design", "700.00"); let key = std::fs::read(m.key()).unwrap(); std::fs::remove_file(m.key()).unwrap(); - let why = m.refuse( - "books.get_w9", - json!({"party": "Acme Design", "save_to": m.outside().join("x.pdf").to_str().unwrap()}), - ); + let why = m.refuse("books.get_w9", json!({"party": "Acme Design"})); assert!(why.contains("locked"), "{why}"); let v = m.read_only( "books.vendor_1099", @@ -1043,33 +1057,39 @@ fn the_1099_worksheet_carries_full_tins_only_when_asked() { "{rent}" ); assert_eq!(rent["tin_last4"], "9105"); - // The CSV goes to a new file outside the books. - let csv = m.outside().join("1099-2026.csv"); + assert_eq!( + full["warning"], + "This worksheet holds full TINs, for the CPA; keep it out of the books." + ); + // No CSV unless asked. + assert!(plain["csv"].is_null() && full["csv"].is_null(), "{full}"); + // Asked, the CSV is handed over: with TINs, named for them. let out = m.read_only( "books.vendor_1099", - json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}), + json!({"year": 2026, "include_tins": true, "csv": true}), ); - assert_eq!(out["saved_csv"], json!(csv.display().to_string())); - let text = std::fs::read_to_string(&csv).unwrap(); + assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026-with-tins.csv"); + assert_eq!(out["csv"]["mime_type"], "text/csv"); + let text = String::from_utf8(handed(&out["csv"])).unwrap(); assert!(text.contains(SSN), "{text}"); assert!(text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin")); - let without = m.outside().join("1099-plain.csv"); - m.read_only( - "books.vendor_1099", - json!({"year": 2026, "save_csv_to": without.to_str().unwrap()}), + let rows = csv_rows(text.as_bytes()); + let acme = rows.iter().find(|r| r["name"] == "Acme Design").unwrap(); + assert_eq!(acme["tin"], SSN); + assert_eq!(acme["tin_type"], "ssn"); + let rent = rows.iter().find(|r| r["name"] == "Rent Co").unwrap(); + assert_eq!(rent["tin"], ""); + assert!(rent["tin_note"].contains("not in the vault"), "{rent:?}"); + // Without TINs: last fours only, and no TIN columns. + let out = m.read_only("books.vendor_1099", json!({"year": 2026, "csv": true})); + assert_eq!(out["csv"]["file_name"], "1099-worksheet-2026.csv"); + let bytes = handed(&out["csv"]); + assert!(!holds_tin(&bytes, SSN_DIGITS)); + let text = String::from_utf8(bytes).unwrap(); + assert!( + text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n"), + "{text}" ); - assert!(!holds_tin(&std::fs::read(&without).unwrap(), SSN_DIGITS)); - for to in [ - csv.clone(), - m.books_dir().join("1099.csv"), - m.data.join("1099.csv"), - ] { - m.refuse( - "books.vendor_1099", - json!({"year": 2026, "include_tins": true, "save_csv_to": to.to_str().unwrap()}), - ); - } - assert!(!m.books_dir().join("1099.csv").exists()); } // ------------------------------------------------- books.purge_tin @@ -1244,22 +1264,13 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ), - ( - "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "save_to": m.outside().join("cpa.zip").to_str().unwrap()}), - ), ("books.party", json!({"party": "Mo"})), ("books.parties", json!({"role": "member"})), ( "books.purge_tin", json!({"party": "Rent Co", "reason": "preview only"}), ), - ( - "books.get_w9", - json!({"party": "Acme Design", - "save_to": m.outside().join("for-cpa.pdf").to_str().unwrap()}), - ), + ("books.get_w9", json!({"party": "Acme Design"})), ( "books.update_party", json!({"party": "Acme Design", "notes": "W-9 in the vault"}), @@ -1276,33 +1287,64 @@ fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { "the worksheet, asked" ); assert!(holds_tin(full.to_string().as_bytes(), EIN_DIGITS)); - let csv = m.outside().join("w.csv"); - m.read_only( + let csv = m.read_only( "books.vendor_1099", - json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}), + json!({"year": 2026, "include_tins": true, "csv": true}), ); - // Every response but the worksheet's with include_tins. - let leaks: Vec<&(String, String)> = m - .transcript - .iter() - .filter(|(tool, resp)| { - let worksheet = - tool == "books.vendor_1099" && resp.contains("\"warning\":\"This worksheet"); - !worksheet - && (holds_tin(resp.as_bytes(), SSN_DIGITS) - || holds_tin(resp.as_bytes(), EIN_DIGITS) - || holds_tin(resp.as_bytes(), MEMBER_SSN_DIGITS)) - }) - .collect(); + let csv = handed(&csv["csv"]); + assert!(holds_tin(&csv, SSN_DIGITS) && holds_tin(&csv, EIN_DIGITS)); + // The package, TINs included (the default). + let pkg = m.read_only( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), + ); + assert_eq!(pkg["tins_included"], true); + let pkg = unzip(&handed(&pkg["file"])); + let holds_any = |b: &[u8]| { + holds_tin(b, SSN_DIGITS) || holds_tin(b, EIN_DIGITS) || holds_tin(b, MEMBER_SSN_DIGITS) + }; + // Every response but the worksheet's with include_tins, and every + // file handed over but those that carry TINs for the CPA by design + // (D-29): the W-9 file, the worksheet CSV with TINs, the package with + // them (a zip's files read out of it). + let (mut leaks, mut scanned): (Vec, Vec) = (Vec::new(), Vec::new()); + for (tool, resp) in &m.transcript { + let mut files = Vec::new(); + let rest = take_files(&serde_json::from_str(resp).unwrap(), &mut files).to_string(); + let worksheet = + tool == "books.vendor_1099" && rest.contains("\"warning\":\"This worksheet"); + if !worksheet && holds_any(rest.as_bytes()) { + leaks.push(format!("{tool}: {rest}")); + } + for (name, bytes) in files { + let by_design = match tool.as_str() { + "books.get_w9" => name == "w9.pdf", + "books.vendor_1099" => name == "1099-worksheet-2026-with-tins.csv", + "books.export_cpa_package" => rest.contains("\"tins_included\":true"), + _ => false, + }; + let inside = if name.ends_with(".zip") { + unzip(&bytes).into_values().any(|b| holds_any(&b)) + } else { + holds_any(&bytes) + }; + if inside && !by_design { + leaks.push(format!("{tool}: the file {name}")); + } + scanned.push(name); + } + } assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}"); + // The handed-over files were scanned: two packages (without TINs, + // with them), the W-9 file, the worksheet CSV. + assert_eq!(scanned.len(), 4, "{scanned:?}"); assert!(m.transcript.len() > 40, "the session used the books"); // The server's log. let log = std::fs::read(m.outside().join("stderr.log")).unwrap(); assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS)); assert!(!holds_tin(&log, MEMBER_SSN_DIGITS)); - // The package saved outside the books carries them all: the member's - // in the K-1 worksheet, the payees' in the 1099 worksheet. - let pkg = unzip(&m.outside().join("cpa.zip")); + // The package carries them all: the member's in the K-1 worksheet, + // the payees' in the 1099 worksheet. assert!(holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS)); let w1099 = &pkg["1099-worksheet-2026.csv"]; assert!(holds_tin(w1099, SSN_DIGITS) && holds_tin(w1099, EIN_DIGITS)); @@ -1653,25 +1695,22 @@ fn k1_books(name: &str) -> Mcp { /// The package carries the K-1 worksheet (one row per member: party, /// share, capital account, TIN, and the hand-worked capital figures) and -/// the 1099 worksheet with full TINs, saved outside the books only; a -/// missing TIN is a gap, never a failure. +/// the 1099 worksheet with full TINs, handed over and never kept in the +/// books; a missing TIN is a gap, never a failure. #[test] fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { let mut m = k1_books("k1"); - let to = m.outside().join("cpa-2026.zip"); let out = m.read_only( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "save_to": to.to_str().unwrap()}), + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); - assert_eq!(out["saved_to"], json!(to.display().to_string())); - assert!(out["content_base64"].is_null(), "never the bytes: {out}"); + assert_eq!(out["file"]["file_name"], "cpa-package-2026-12-31.zip"); + assert_eq!(out["file"]["mime_type"], "application/zip"); assert_eq!(out["tins_included"], true); + let note = out["note"].as_str().unwrap(); assert!( - out["note"] - .as_str() - .unwrap() - .contains(&to.display().to_string()) + note.contains("full TINs") && note.contains("1 TIN(s) are missing (tin_gaps)"), + "{note}" ); let gaps: Vec<&str> = out["tin_gaps"] .as_array() @@ -1681,15 +1720,9 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { .collect(); assert_eq!(gaps.len(), 1, "{gaps:?}"); assert!(gaps[0].contains("Mo Example") && gaps[0].contains("TIN not in the vault")); - assert!(!holds_tin(out.to_string().as_bytes(), MEMBER_SSN_DIGITS)); - assert!(!holds_tin(out.to_string().as_bytes(), EIN_DIGITS)); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let mode = std::fs::metadata(&to).unwrap().permissions().mode(); - assert_eq!(mode & 0o777, 0o600, "the package is the owner's only"); - } - let pkg = unzip(&to); + assert!(!holds_tin(said(&out).as_bytes(), MEMBER_SSN_DIGITS)); + assert!(!holds_tin(said(&out).as_bytes(), EIN_DIGITS)); + let pkg = unzip(&handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); assert_eq!(k1.len(), 3, "two members and their total"); let (tobin, mo) = (&k1[0], &k1[1]); @@ -1794,38 +1827,15 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { assert!(!f.to_string_lossy().ends_with(".zip"), "{}", f.display()); } - // A folder: the zip goes in it, by its name. - let folder = m.outside().join("for-casey"); - std::fs::create_dir_all(&folder).unwrap(); - let out = m.read_only( - "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "save_to": folder.to_str().unwrap()}), - ); - assert_eq!( - out["saved_to"], - json!( - folder - .join("cpa-package-2026-12-31.zip") - .display() - .to_string() - ) - ); - - // Without TINs: last fours only, returned as base64. + // Without TINs: last fours only. let out = m.read_only( "books.export_cpa_package", json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": false}), ); assert_eq!(out["tins_included"], false); - let zip = base64::Engine::decode( - &base64::engine::general_purpose::STANDARD, - out["content_base64"].as_str().unwrap(), - ) - .unwrap(); - let plain_path = m.outside().join("plain.zip"); - std::fs::write(&plain_path, &zip).unwrap(); - let plain = unzip(&plain_path); + assert_eq!(out["note"], "The package, with last fours only."); + assert!(out["tin_gaps"].as_array().unwrap().is_empty(), "{out}"); + let plain = unzip(&handed(&out["file"])); let k1 = csv_rows(&plain["k1-members.csv"]); assert_eq!(k1[0]["tin_last4"], "4915"); assert!(!k1[0].contains_key("tin")); @@ -1840,87 +1850,32 @@ fn the_cpa_package_carries_the_k1_and_1099_worksheets_with_tins() { } } -/// With TINs the package is never returned and never lands in the books: -/// no save_to, a path in the books dir or its exports/, an existing file, -/// or a path that is neither a folder nor a .zip is refused. +/// The package, with TINs or without, is handed over (inline over stdio, +/// D-29) and never written: no file appears in the books dir outside the +/// vault, or anywhere else. #[test] -fn a_package_with_tins_goes_only_outside_the_books() { - let mut m = k1_books("k1-where"); - let why = m.refuse( - "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), - ); - assert!(why.contains("save_to"), "{why}"); +fn a_package_is_handed_over_and_never_written() { + let mut m = k1_books("k1-handed"); let books = m.books_dir(); - std::fs::create_dir_all(books.join("exports")).unwrap(); - let existing = m.outside().join("taken.zip"); - std::fs::write(&existing, b"x").unwrap(); - for to in [ - books.join("cpa.zip"), - books.join("exports"), - books.join("exports/cpa.zip"), - m.data.join("cpa.zip"), - books.join("vault/../cpa.zip"), - existing.clone(), - m.outside().join("cpa.txt"), - m.outside().join("no/such/dir/cpa.zip"), - ] { - m.refuse( + let (books_before, outside_before) = + (paths_outside_vault(&m, &books), files_under(&m.outside())); + for tins in [true, false] { + let out = m.read_only( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "save_to": to.to_str().unwrap()}), + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], "include_tins": tins}), + ); + assert_eq!(out["tins_included"], tins); + assert_eq!(out["file"]["file_name"], "cpa-package-2026-12-31.zip"); + let pkg = unzip(&handed(&out["file"])); + assert!(pkg.contains_key("README.txt"), "include_tins {tins}"); + assert_eq!( + holds_tin(&pkg["k1-members.csv"], MEMBER_SSN_DIGITS), + tins, + "include_tins {tins}" ); } - assert_eq!(std::fs::read(&existing).unwrap(), b"x"); - assert!( - std::fs::read_dir(books.join("exports")) - .unwrap() - .next() - .is_none() - ); - assert!(!books.join("cpa.zip").exists()); -} - -/// A package goes to a new file in a folder that exists, with or without -/// TINs: a missing folder and a taken file are each refused as such (not -/// as a failed write); without TINs, a new .zip path or an existing folder -/// takes it. -#[test] -fn a_package_goes_only_to_a_new_file_in_a_folder_that_exists() { - let mut m = k1_books("k1-dest"); - let missing = m.outside().join("no/such/dir/cpa.zip"); - let taken = m.outside().join("taken.zip"); - std::fs::write(&taken, b"x").unwrap(); - for tins in [true, false] { - for (to, said) in [ - (&missing, "the folder for that path doesn't exist"), - (&taken, "a file is there already"), - ] { - let why = m.refuse( - "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "include_tins": tins, "save_to": to.to_str().unwrap()}), - ); - assert!(why.contains(said), "include_tins {tins}: {why}"); - } - } - assert_eq!(std::fs::read(&taken).unwrap(), b"x"); - let to = m.outside().join("plain.zip"); - m.read_only( - "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "include_tins": false, "save_to": to.to_str().unwrap()}), - ); - assert!(unzip(&to).contains_key("README.txt")); - let folder = m.outside().join("packages"); - std::fs::create_dir_all(&folder).unwrap(); - m.read_only( - "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "include_tins": false, "save_to": folder.to_str().unwrap()}), - ); - let saved: Vec<_> = std::fs::read_dir(&folder).unwrap().collect(); - assert_eq!(saved.len(), 1, "one package in the folder"); + assert_eq!(paths_outside_vault(&m, &books), books_before); + assert_eq!(files_under(&m.outside()), outside_before); } /// A locked vault never fails the package: every TIN row says why, and @@ -1929,12 +1884,11 @@ fn a_package_goes_only_to_a_new_file_in_a_folder_that_exists() { fn a_locked_vault_still_builds_the_package() { let mut m = k1_books("k1-locked"); std::fs::remove_file(m.key()).unwrap(); - let to = m.outside().join("cpa.zip"); let out = m.read_only( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "save_to": to.to_str().unwrap()}), + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); + assert_eq!(out["tins_included"], true); let gaps = out["tin_gaps"].as_array().unwrap(); assert_eq!(gaps.len(), 3, "two members and Acme: {gaps:?}"); assert!( @@ -1942,7 +1896,7 @@ fn a_locked_vault_still_builds_the_package() { "{gaps:?}" ); assert!(!m.key().exists(), "reading never makes a key"); - let pkg = unzip(&to); + let pkg = unzip(&handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); let members: Vec<_> = k1 .iter() @@ -2049,15 +2003,13 @@ fn a_k1_row_never_carries_another_partys_tin() { "KE Consulting LLC", json!({"tin": "12-3456789", "tin_type": "ein"}), ); - let to = m.outside().join("cpa-lookalike.zip"); let out = m.read_only( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "save_to": to.to_str().unwrap()}), + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); let gaps = out["tin_gaps"].to_string(); assert!(gaps.contains("member Mo Example"), "{gaps}"); - let pkg = unzip(&to); + let pkg = unzip(&handed(&out["file"])); let k1 = csv_rows(&pkg["k1-members.csv"]); let mo = k1.iter().find(|r| r["member"] == "Mo Example").unwrap(); assert_eq!(mo["tin"], "", "{mo:?}"); @@ -2077,13 +2029,11 @@ fn joint_filers_are_noted_in_the_package() { let s = m.read_only("books.settings", json!({})); assert_eq!(s["members"][0]["files_jointly_with"], "Mo Example"); assert_eq!(s["members"][1]["files_jointly_with"], "Tobin Example"); - let to = m.outside().join("joint.zip"); - m.read_only( + let out = m.read_only( "books.export_cpa_package", - json!({"as_of": "2026-12-31", "cash_accounts": ["1000"], - "save_to": to.to_str().unwrap()}), + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), ); - let pkg = unzip(&to); + let pkg = unzip(&handed(&out["file"])); let readme = String::from_utf8(pkg["README.txt"].clone()).unwrap(); let note = "Tobin Example and Mo Example file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II)."; assert_eq!(readme.matches(note).count(), 1, "once per pair: {readme}"); diff --git a/crates/ledger-mcp/tests/tin_files.rs b/crates/ledger-mcp/tests/tin_files.rs index d9cb8a5..e606fad 100644 --- a/crates/ledger-mcp/tests/tin_files.rs +++ b/crates/ledger-mcp/tests/tin_files.rs @@ -185,6 +185,24 @@ fn inline(bytes: &[u8]) -> (String, String) { ) } +/// The bytes of a file a tool handed over (a `HandedFile`): inline, as on +/// books served from a --data-dir, matching the size and SHA-256 it +/// states. +fn handed(file: &Value) -> Vec { + use base64::Engine; + assert!(file.get("download").is_none(), "{file}"); + let bytes = base64::engine::general_purpose::STANDARD + .decode(file["content_base64"].as_str().expect("content_base64")) + .unwrap(); + assert_eq!(file["size_bytes"], json!(bytes.len()), "{file}"); + assert_eq!( + file["sha256"], + json!(ledger_store::journal::sha256_hex(&bytes)), + "{file}" + ); + bytes +} + /// Every way the file at `path` (named `name`) could enter the books is /// refused with `why`, and nothing is stored: the refusals, tool by tool. async fn refused_everywhere( @@ -281,28 +299,28 @@ async fn a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart() // A bank statement filed in the books: the package carries it, and it // is no TIN file (its routing numbers are not TINs). accepted_everywhere(&one, "statement.csv", BANK_CSV).await; - // What the server writes with TINs, from the first session. + // What the server hands over with TINs, from the first session, saved + // outside the books as the human would save it. let copy = b.out.join("w9-copy.pdf"); - ok( - &one, - "books.get_w9", - json!({"party": "Acme", "save_to": copy.to_str().unwrap()}), - ) - .await; + let got = ok(&one, "books.get_w9", json!({"party": "Acme"})).await; + std::fs::write(©, handed(&got["file"])).unwrap(); let csv = b.out.join("1099.csv"); - ok( + let got = ok( &one, "books.vendor_1099", - json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}), + json!({"year": 2026, "include_tins": true, "csv": true}), ) .await; + std::fs::write(&csv, handed(&got["csv"])).unwrap(); let zip = b.out.join("cpa.zip"); - ok( + let got = ok( &one, "books.export_cpa_package", - json!({"cash_accounts": ["1000"], "save_to": zip.to_str().unwrap(), "as_of": "2026-12-31"}), + json!({"cash_accounts": ["1000"], "as_of": "2026-12-31"}), ) .await; + assert_eq!(got["tins_included"], true, "{got}"); + std::fs::write(&zip, handed(&got["file"])).unwrap(); let k1 = b.out.join("k1-members.csv"); { let mut archive = zip::ZipArchive::new(std::fs::File::open(&zip).unwrap()).unwrap(); diff --git a/crates/ledger-package/Cargo.toml b/crates/ledger-package/Cargo.toml new file mode 100644 index 0000000..e993f3f --- /dev/null +++ b/crates/ledger-package/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "ledger-package" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "wanderbooks' CPA handoff package and the reports and worksheets it shares with the server: built from the books read-only, by the server or the viewer." + +[dependencies] +ledger-core = { path = "../ledger-core" } +ledger-store = { path = "../ledger-store" } +ledger-import = { path = "../ledger-import" } +ledger-vault = { path = "../ledger-vault" } +chrono = "0.4" +rust_decimal = "1" +zip = "9.0.1" + +[lints] +workspace = true + +[dev-dependencies] +tempfile = "3" diff --git a/crates/ledger-package/src/books.rs b/crates/ledger-package/src/books.rs new file mode 100644 index 0000000..483efe0 --- /dev/null +++ b/crates/ledger-package/src/books.rs @@ -0,0 +1,87 @@ +//! [`PackageBooks`]: what the package reads of the books, from the +//! server's [`Store`] or the viewer's [`Snapshot`] alike. +//! +//! ```text +//! GOAL: one package builder over either view of the books, and no way +//! for it to write to them. +//! SOURCE: ledger_store::snapshot (a Snapshot reads attachments and their +//! bytes "the same as through Store"). +//! INVARIANTS: +//! 1. Every method only reads. +//! 2. A Store and a Snapshot of the same journal answer every method +//! alike: the same ledger, journal seq, attachments (withdrawn ones +//! included), entry links, and bytes. +//! ``` + +use ledger_core::Ledger; +use ledger_store::{Attachment, Snapshot, Store, StoreError, Subject}; + +/// The books, read-only, as the CPA package reads them. +pub trait PackageBooks { + /// The ledger every figure comes from. + fn ledger(&self) -> &Ledger; + + /// Sequence number of the last journal line: the journal state the + /// package reflects. + fn journal_seq(&self) -> u64; + + /// Every attachment (receipts and documents), withdrawn ones + /// included, in id order. + fn all_attachments(&self) -> Result, StoreError>; + + /// The subjects attachment `attachment_id` is linked to beyond the + /// entry it is filed on (an implementation may list that entry too: + /// the package takes the union). + fn links_of(&self, attachment_id: u64) -> Result, StoreError>; + + /// An attachment's bytes, verified against its hash. + fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError>; +} + +impl PackageBooks for Store { + fn ledger(&self) -> &Ledger { + Store::ledger(self) + } + + fn journal_seq(&self) -> u64 { + Store::journal_seq(self) + } + + fn all_attachments(&self) -> Result, StoreError> { + Store::list_attachments(self, None) + } + + fn links_of(&self, attachment_id: u64) -> Result, StoreError> { + Store::attachment_links(self, attachment_id) + } + + fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError> { + Store::attachment_bytes(self, attachment_id) + } +} + +impl PackageBooks for Snapshot { + fn ledger(&self) -> &Ledger { + Snapshot::ledger(self) + } + + fn journal_seq(&self) -> u64 { + Snapshot::journal_seq(self) + } + + fn all_attachments(&self) -> Result, StoreError> { + Snapshot::attachments(self, None) + } + + fn links_of(&self, attachment_id: u64) -> Result, StoreError> { + Ok(Snapshot::attachment_links(self)? + .into_iter() + .filter(|(id, _)| *id == attachment_id) + .map(|(_, subject)| subject) + .collect()) + } + + fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError> { + Snapshot::attachment_bytes(self, attachment_id) + } +} diff --git a/crates/ledger-package/src/iif.rs b/crates/ledger-package/src/iif.rs new file mode 100644 index 0000000..ef677f5 --- /dev/null +++ b/crates/ledger-package/src/iif.rs @@ -0,0 +1,53 @@ +//! The journal as IIF, for QuickBooks Desktop: `books.export` format iif +//! and the package's `general-journal.iif`. +//! +//! ```text +//! GOAL: the CPA imports every posted entry into QuickBooks Desktop, each +//! account declared with the type QuickBooks needs. +//! SOURCE: ledger_import::iif (the IIF format and account types). +//! INVARIANTS: +//! 1. The cash accounts are BANK; the receivable and payable control +//! accounts AR and AP; a registered asset's cost and accumulated +//! depreciation FIXASSET; every other account follows its type. +//! 2. Every posted entry, and only those. +//! ``` + +use ledger_core::{AccountId, AccountType, Ledger}; + +/// Render the journal as IIF text via ledger-import, declaring every +/// account with its QuickBooks type: the cash accounts (`cash_codes`, the +/// books' `cash_accounts`) are BANK, the receivable and payable control +/// accounts AR and AP, a registered asset's cost and accumulated +/// depreciation FIXASSET, and the rest follow their wanderbooks type +/// (other current asset, other current liability, equity, income, +/// expense). +pub fn export_iif(ledger: &Ledger, cash_codes: &[String]) -> Result { + use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif}; + let fixed: std::collections::HashSet = ledger + .registered_assets() + .flat_map(|a| [a.cost_account, a.accum_depr_account]) + .collect(); + let receivables = ledger.role_account(&ledger_core::RECEIVABLES); + let payables = ledger.role_account(&ledger_core::PAYABLES); + let accounts = iif_accounts(ledger, |a| { + if cash_codes.contains(&a.code) { + Qb::Bank + } else if Some(a.id) == receivables { + Qb::AccountsReceivable + } else if Some(a.id) == payables { + Qb::AccountsPayable + } else if fixed.contains(&a.id) { + Qb::FixedAsset + } else { + match a.account_type { + AccountType::Asset => Qb::OtherCurrentAsset, + AccountType::Liability => Qb::OtherCurrentLiability, + AccountType::Equity => Qb::Equity, + AccountType::Revenue => Qb::Income, + AccountType::Expense => Qb::Expense, + } + } + }); + let entries: Vec<_> = ledger.posted_entries().collect(); + journal_to_iif(&accounts, &entries).map_err(|e| e.to_string()) +} diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs new file mode 100644 index 0000000..2f39a6f --- /dev/null +++ b/crates/ledger-package/src/lib.rs @@ -0,0 +1,46 @@ +//! The CPA handoff package, and the reports, worksheets, and text helpers +//! the server's tools and the viewer both make: built from the books +//! read-only, through [`PackageBooks`] (the server's +//! [`ledger_store::Store`], the viewer's [`ledger_store::Snapshot`]). +//! +//! ```text +//! GOAL: the server (books.export_cpa_package, books.vendor_1099) and the +//! viewer make the same package and the same 1099 worksheet, byte +//! for byte, from one piece of code, and neither can change the +//! books making them. +//! SOURCE: the books.export_cpa_package and books.vendor_1099 contracts +//! (ledger-mcp server.rs; docs/mcp-interface.md), moved here +//! unchanged so the viewer, which reads the books only through a +//! Snapshot and doesn't depend on ledger-mcp, builds them too. +//! INVARIANTS: +//! 1. Read-only: nothing here writes anywhere (the books, the vault, a +//! file). The builders return bytes; recording a file that holds +//! full TINs (ledger-mcp tin_files.rs) and handing it over are the +//! caller's. +//! 2. Full TINs come only from the TIN source the caller gives +//! ([`TinSource`]); without one, a worksheet carries last fours only. +//! 3. The same books, settings, and request give the same bytes, +//! whichever [`PackageBooks`] they are read through. +//! ``` + +mod books; +mod iif; +mod package; +mod reports; +mod text; +mod tins; +mod vendor_1099; + +pub use books::PackageBooks; +pub use iif::export_iif; +pub use package::{Package, PackageRequest, build_package, package_file_name}; +pub use reports::{ + Settings, bs_statement, cash_flow_for, cash_flow_statement, member_capitals, pnl_statement, + report_members, report_meta, statement_range, year_earnings, +}; +pub use text::{csv_escape, money, parse_date, share_text}; +pub use tins::{TinSource, tin_kind, tin_type_key, vault_tin}; +pub use vendor_1099::{ + Vendor1099Worksheet, Vendor1099WorksheetLine, form_1099_label, payment_accounts, + vendor_1099_csv, vendor_1099_csv_name, vendor_1099_report, vendor_1099_worksheet, +}; diff --git a/crates/ledger-package/src/package.rs b/crates/ledger-package/src/package.rs new file mode 100644 index 0000000..091389a --- /dev/null +++ b/crates/ledger-package/src/package.rs @@ -0,0 +1,615 @@ +//! The CPA handoff package: every statement, ledger, and worksheet the CPA +//! needs for the return, with the evidence behind the year's entries, in +//! one zip. +//! +//! ```text +//! GOAL: everything the CPA needs for the return in one zip, the K-1s' +//! and 1099s' TINs included when asked, built the same by the +//! server and the viewer. +//! SOURCE: docs/mcp-interface.md; B-26; DECISION (owner, 2026-10-15): +//! "it needs to be in the damn CPA package." +//! INVARIANTS: +//! 1. The statements (profit and loss, balance sheet, cash flow, members' +//! equity with members, tax summary, trial balance) each as PDF and +//! XLSX, for the fiscal year containing as_of, through as_of; the +//! fixed asset register when there are fixed assets; the general +//! ledger and journal (XLSX); the journal as IIF; the 1099 worksheet +//! for as_of's calendar year (or why it can't be made); with members, +//! the K-1 worksheet; a README first. +//! 2. With members, k1-members.csv has one row per member: name, party, +//! share, capital account, TIN and type (or last four without a TIN +//! source), and the K-1 worksheet's capital figures (ledger_core::k1) +//! for the fiscal year to date; the members' total row with more than +//! one. The 1099 worksheet adds each payee's party, last four, TIN, +//! and type with a TIN source. +//! 3. A TIN the source can't give never fails the package: the row says +//! why, and so do the README and tin_gaps (members, and 1099 payees +//! that are reportable). +//! 4. With include_receipts: every current file behind the year's +//! posted entries, filed on them or linked to them, once per entry, +//! each with a row in receipts/index.csv; and the company's +//! documents, the files behind no entry (loose receipts aside). +//! Withdrawn files never. +//! 5. Read-only (crate INVARIANT 1): the package is returned, its files +//! and the zip, and written nowhere. +//! ``` + +use chrono::NaiveDate; +use ledger_core::{AccountId, Side, reports}; +use rust_decimal::Decimal; + +use crate::books::PackageBooks; +use crate::iif::export_iif; +use crate::reports::{ + Settings, bs_statement, cash_flow_for, member_capitals, pnl_statement, report_members, + report_meta, year_earnings, +}; +use crate::text::{csv_escape, money, share_text}; +use crate::tins::{TinSource, tin_type_key}; +use crate::vendor_1099::vendor_1099_report; + +/// What the package is built for. +#[derive(Clone, Copy)] +pub struct PackageRequest<'a> { + /// The day it is as of: the balance sheet's date, the end of every + /// period report (the fiscal year to date). + pub as_of: NaiveDate, + /// The day it is prepared, for the reports' headers. + pub prepared_on: NaiveDate, + /// Cash account codes the cash flow statement reconciles to (empty: + /// the settings' cash accounts). + pub cash_accounts: &'a [String], + /// Financing account codes (empty: the settings' financing accounts). + pub financing_accounts: &'a [String], + /// Investing account codes (empty: the settings' investing accounts). + pub investing_accounts: &'a [String], + /// Whether the receipts and the company's documents go in + /// (INVARIANT 4). + pub include_receipts: bool, + /// Where the worksheets' full TINs come from: none, last fours only. + pub tins: Option>, +} + +/// A built package. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Package { + /// The zip's name: `cpa-package-.zip`. + pub file_name: String, + /// Every file in the zip, in its order: (name in the zip, bytes). + pub files: Vec<(String, Vec)>, + /// The zip. + pub zip: Vec, + /// With a TIN source: each member or reportable 1099 payee whose TIN + /// the package lacks, and why (INVARIANT 3). + pub tin_gaps: Vec, + /// The receipts in receipts/ (one per entry they are behind). + pub receipt_count: usize, +} + +/// The package's file name for `as_of`. +pub fn package_file_name(as_of: NaiveDate) -> String { + format!("cpa-package-{as_of}.zip") +} + +/// Build the CPA package from `books` (INVARIANTS 1-5). +pub fn build_package( + books: &B, + settings: &Settings<'_>, + req: &PackageRequest<'_>, +) -> Result { + use ledger_import::{XlsxReport, report, report_to_xlsx, statement_pdf}; + let company = settings.company; + let (as_of, today) = (req.as_of, req.prepared_on); + let meta = report_meta(company, req.prepared_on, books.journal_seq()); + let ledger = books.ledger(); + let tins = req.tins; + let mut tin_gaps: Vec = Vec::new(); + + // Fiscal year to date, per the company's fiscal_year_start. + let (fy_start, _) = company.fiscal_year_containing(as_of); + let (fy_start, as_of_s) = (fy_start.to_string(), as_of.to_string()); + let xlsx = |r: XlsxReport<'_>| report_to_xlsx(&r, &meta).map_err(|e| e.to_string()); + let pdf = |st: &ledger_import::Statement| statement_pdf(st, &meta).map_err(|e| e.to_string()); + let fy = NaiveDate::parse_from_str(&fy_start, "%Y-%m-%d").map_err(|e| e.to_string())?; + let members = report_members(company); + + // The financial statements, each as PDF and XLSX. + let mut statements: Vec<(&str, ledger_import::Statement)> = vec![ + ( + "profit-and-loss", + report::profit_and_loss(&pnl_statement( + ledger, + Some(&fy_start), + Some(&as_of_s), + None, + today, + )?), + ), + ( + "balance-sheet", + report::balance_sheet( + &bs_statement(ledger, Some(&as_of_s), today)?, + year_earnings(ledger, company, as_of)?, + &members, + ), + ), + ( + "cash-flow", + report::cash_flow( + &cash_flow_for( + ledger, + settings, + Some(&fy_start), + Some(&as_of_s), + req.cash_accounts, + req.financing_accounts, + req.investing_accounts, + today, + )?, + &members, + ), + ), + ]; + let mut k1_csv: Option<(String, Vec)> = None; + if !company.members.is_empty() { + let capitals = member_capitals(ledger, &company.members)?; + let me = + ledger_core::members_equity(ledger, fy, as_of, &capitals).map_err(|e| e.to_string())?; + statements.push(("members-equity", report::members_equity(&me))); + // The K-1 worksheet: each member's capital account for the + // year (Schedule K-1 Item L) and their TIN (INVARIANT 2). + let cash_codes = if req.cash_accounts.is_empty() { + settings.cash_accounts + } else { + req.cash_accounts + }; + let cash: Vec = cash_codes + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect(); + k1_csv = Some( + match ledger_core::k1::k1_worksheet(ledger, fy, as_of, &capitals, &cash) + .map_err(|e| e.to_string()) + .and_then(|rows| { + // A row per member, in their order, or none: a row is + // never paired with another member. + if rows.len() == company.members.len() { + Ok(rows) + } else { + Err(format!( + "the K-1 worksheet has {} rows for {} members", + rows.len(), + company.members.len() + )) + } + }) { + Ok(rows) => { + let mut w = String::from("member,party_id,share,capital_account,"); + w.push_str(if tins.is_some() { + "tin,tin_type,tin_note," + } else { + "tin_last4,tin_type," + }); + w.push_str("beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n"); + for (row, m) in rows.iter().zip(&company.members) { + let party = ledger_core::k1::member_party(ledger, &m.name); + let facts = party + .and_then(|id| ledger.get_party(id)) + .and_then(|p| p.tin.clone()); + let mut cells = vec![ + csv_escape(&m.name), + party.map(|id| id.to_string()).unwrap_or_default(), + share_text(m.share), + m.capital_account.clone(), + ]; + match tins { + Some(tins) => { + let got = match party { + None => Err(format!( + "TIN not in the vault: no party named {:?} with the member role (books.add_party with role member, or books.set_party_roles, then books.set_tin)", + m.name + )), + Some(id) => tins(id), + }; + match got { + Ok(t) => { + cells.push(t.formatted()); + cells.push(tin_type_key(t.kind).into()); + cells.push(String::new()); + } + Err(why) => { + tin_gaps.push(format!("member {}: {why}", m.name)); + cells.push(String::new()); + cells.push( + facts + .map(|f| f.tin_type.key().to_string()) + .unwrap_or_default(), + ); + cells.push(csv_escape(&why)); + } + } + } + None => { + cells.push( + facts.as_ref().map(|f| f.last4.clone()).unwrap_or_default(), + ); + cells.push( + facts + .map(|f| f.tin_type.key().to_string()) + .unwrap_or_default(), + ); + } + } + for v in [ + row.beginning, + row.contributions_cash, + row.contributions_property, + row.net_income, + row.distributions, + row.other, + row.ending, + ] { + cells.push(money(v)); + } + w.push_str(&cells.join(",")); + w.push('\n'); + } + // The members together: ties to the 1065's Schedule L + // capital and the statement of members' equity. Not + // a K-1 (each member gets their own). + if rows.len() > 1 { + let sum = |f: fn(&ledger_core::k1::K1Row) -> Decimal| { + rows.iter().map(f).sum::() + }; + let share: Decimal = company.members.iter().map(|m| m.share).sum(); + let mut cells = vec![ + csv_escape("Total, all members (not a K-1)"), + String::new(), + share_text(share), + String::new(), + String::new(), + String::new(), + ]; + if tins.is_some() { + cells.push(String::new()); + } + for v in [ + sum(|r| r.beginning), + sum(|r| r.contributions_cash), + sum(|r| r.contributions_property), + sum(|r| r.net_income), + sum(|r| r.distributions), + sum(|r| r.other), + sum(|r| r.ending), + ] { + cells.push(money(v)); + } + w.push_str(&cells.join(",")); + w.push('\n'); + } + ("k1-members.csv".into(), w.into_bytes()) + } + Err(e) => ( + "k1-members-NOT-INCLUDED.txt".into(), + format!("{e}\n").into_bytes(), + ), + }, + ); + } + statements.push(( + "tax-summary", + report::tax_summary( + &ledger_core::tax_summary(ledger, fy, as_of, company.tax_form) + .map_err(|e| e.to_string())?, + ), + )); + statements.push(( + "trial-balance", + report::trial_balance(&reports::trial_balance_as_of(ledger, as_of), as_of), + )); + let mut files: Vec<(String, Vec)> = Vec::new(); + for (name, st) in &statements { + files.push((format!("{name}.pdf"), pdf(st)?)); + files.push((format!("{name}.xlsx"), xlsx(XlsxReport::Statement(st))?)); + } + // The fixed asset register, when there are fixed assets. + if ledger.registered_assets().next().is_some() { + let r = ledger_core::fixed_asset_register(ledger, as_of).map_err(|e| e.to_string())?; + files.push(( + "fixed-asset-register.pdf".into(), + ledger_import::asset_register_pdf(&r, &meta).map_err(|e| e.to_string())?, + )); + files.push(( + "fixed-asset-register.xlsx".into(), + ledger_import::asset_register_xlsx(&r, &meta).map_err(|e| e.to_string())?, + )); + } + // The books behind them: by account, and entry by entry. + let gl = ledger_core::general_ledger(ledger, fy, as_of).map_err(|e| e.to_string())?; + files.push(( + "general-ledger.xlsx".into(), + xlsx(XlsxReport::GeneralLedger(&gl))?, + )); + files.push(( + "general-journal.xlsx".into(), + xlsx(XlsxReport::GeneralJournal(ledger, fy, as_of))?, + )); + // 1099 worksheet for the calendar year of as_of. + let year = chrono::Datelike::year(&as_of); + let mut worksheet_1099 = false; + match vendor_1099_report(ledger, settings, year, None) { + Ok(r1099) => { + let mut w = String::from("vendor,form,w9_on_file,paid,reportable"); + if tins.is_some() { + w.push_str(",party_id,tin_last4,tin,tin_type,tin_note"); + } + w.push('\n'); + for v in &r1099.vendors { + w.push_str(&format!( + "\"{}\",{},{},{},{}", + v.name.replace('"', "\"\""), + v.form.map(|f| f.label()).unwrap_or(""), + v.tin_on_file, + money(v.paid), + v.reportable + )); + // The payee's full TIN from the source (INVARIANT 2). + if let Some(tins) = tins { + let party = ledger.party_of_vendor(v.vendor_id); + let last4 = party.and_then(|id| ledger.get_party(id)).and_then(|p| { + p.w9.as_ref() + .map(|f| f.tin_last4.clone()) + .or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) + }); + let got = match party { + None => Err("TIN not in the vault: no party for this vendor".into()), + Some(id) => tins(id), + }; + let (tin, kind, note) = match got { + Ok(t) => ( + t.formatted(), + tin_type_key(t.kind).to_string(), + String::new(), + ), + Err(why) => { + if v.reportable { + tin_gaps.push(format!("1099 payee {}: {why}", v.name)); + } + (String::new(), String::new(), why) + } + }; + w.push_str(&format!( + ",{},{},{tin},{kind},{}", + party.map(|id| id.to_string()).unwrap_or_default(), + last4.unwrap_or_default(), + csv_escape(¬e) + )); + } + w.push('\n'); + } + files.push((format!("1099-worksheet-{year}.csv"), w.into_bytes())); + worksheet_1099 = true; + } + // The threshold isn't known for this year: never guess it + // (rules.md B-19); say so in the package instead. + Err(e) => files.push(( + format!("1099-worksheet-{year}-NOT-INCLUDED.txt"), + format!("{e}\n").into_bytes(), + )), + } + // The evidence: every current file behind the year's posted + // entries, filed on them or linked to them (a file linked to an + // entry counts as its receipt), one copy per entry with a row in + // the index; and the company's documents, the files behind no + // entry (INVARIANT 4). + let mut receipt_count = 0; + let mut document_count = 0; + // Each file's name in the zip is its own: a second `r.pdf` is + // `r (2).pdf` (compared ignoring case, so it unpacks the same on every + // system), and the index names the file as it is stored. + let mut used: std::collections::HashSet = + files.iter().map(|(n, _)| n.to_lowercase()).collect(); + let mut unique = |name: String| -> String { + let (stem, ext) = match name.rsplit_once('.') { + Some((s, e)) if !s.ends_with('/') && !s.is_empty() => (s.to_string(), format!(".{e}")), + _ => (name.clone(), String::new()), + }; + let mut n = 1; + let mut candidate = name; + while !used.insert(candidate.to_lowercase()) { + n += 1; + candidate = format!("{stem} ({n}){ext}"); + } + candidate + }; + if req.include_receipts { + let clean = |s: &str| -> String { + s.chars() + .map(|c| { + if c.is_alphanumeric() || ".-_ ".contains(c) { + c + } else { + '_' + } + }) + .collect() + }; + let mut index = String::from("entry_id,date,description,amount,file,sha256\n"); + for a in books.all_attachments().map_err(|e| e.to_string())? { + if a.withdrawn_at.is_some() { + continue; + } + let mut behind: Vec = a.entry_id.into_iter().collect(); + for sub in books.links_of(a.id).map_err(|e| e.to_string())? { + if let ledger_store::Subject::Entry(e) = sub + && !behind.contains(&e) + { + behind.push(e); + } + } + behind.sort_unstable(); + let bytes = books.attachment_bytes(a.id).map_err(|e| e.to_string())?; + if behind.is_empty() { + if a.category != "receipts" { + files.push(( + unique(format!( + "documents/{}/{}", + clean(&a.category), + clean(&a.file_name) + )), + bytes, + )); + document_count += 1; + } + continue; + } + for e in behind.iter().filter_map(|&e| ledger.get_entry(e)) { + if e.status() != ledger_core::EntryStatus::Posted + || e.date() < fy + || e.date() > as_of + { + continue; + } + let name = unique(format!( + "receipts/{}-entry-{}-{}", + e.date(), + e.id(), + clean(&a.file_name) + )); + let amount: Decimal = e + .lines() + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.amount.amount) + .sum(); + index.push_str(&format!( + "{},{},\"{}\",{},{},{}\n", + e.id(), + e.date(), + e.description().replace('"', "\"\""), + money(amount), + name, + a.blob_sha + )); + files.push((name, bytes.clone())); + receipt_count += 1; + } + } + files.push(("receipts/index.csv".into(), index.into_bytes())); + } + let k1_included = k1_csv + .as_ref() + .is_some_and(|(name, _)| name == "k1-members.csv"); + if let Some(k1) = k1_csv { + files.push(k1); + } + let seq = books.journal_seq(); + files.push(( + "general-journal.iif".into(), + export_iif(ledger, settings.cash_accounts)?.into_bytes(), + )); + let include_tins = tins.is_some(); + let readme = format!( + "{name} — books package for the CPA\n\nFiscal year to date: {fy_start} through {as_of_s}. {basis} basis.\nKept in wanderbooks: double-entry books from an append-only, hash-chained journal ({seq} events, verified).\n\nStatements (PDF and XLSX): profit and loss, balance sheet, cash flow, {members}tax summary, trial balance.\nLedgers (XLSX): general ledger by account; general journal entry by entry.\ngeneral-journal.iif: every posted entry, for import into QuickBooks Desktop.\n{worksheet}{k1}{receipts}{tins}\nThe tax summary maps accounts to return categories; it's a worksheet, not a filing. Questions go to the owners, who can ask their bookkeeping agent to trace any number to its entries and receipts.\n", + name = company.name, + basis = company.basis, + members = if company.members.is_empty() { + "" + } else { + "members' equity, " + }, + worksheet = if worksheet_1099 { + format!( + "1099-worksheet-{year}.csv: cash paid per vendor marked for a 1099, against the year's threshold{}.\n", + if include_tins { + ", with each payee's TIN" + } else { + "" + } + ) + } else { + format!( + "1099-worksheet-{year}-NOT-INCLUDED.txt: why the year's 1099 worksheet isn't here.\n" + ) + }, + k1 = if company.members.is_empty() { + String::new() + } else if !k1_included { + "k1-members-NOT-INCLUDED.txt: why the members' K-1 worksheet isn't here.\n".to_string() + } else { + let mut k = format!( + "k1-members.csv: each member's capital account for the year (Schedule K-1 Item L: beginning, contributions in cash and in property, share of income, distributions, ending), with their {}{}.\n", + if include_tins { + "taxpayer ID" + } else { + "taxpayer ID's last four" + }, + if company.members.len() > 1 { + "; the last row totals the members (not a K-1) to tie to Schedule L and the statement of members' equity" + } else { + "" + } + ); + let members = &company.members; + for (i, m) in members.iter().enumerate() { + if let Some(j) = &m.files_jointly_with + && members[..i].iter().all(|o| o.name != *j) + { + k.push_str(&format!( + "{} and {j} file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).\n", + m.name + )); + } + } + k + }, + tins = if include_tins { + let mut t = String::from( + "\nThis package holds full taxpayer identification numbers: keep it out of email and shared folders, and delete copies once filed.\n", + ); + if !tin_gaps.is_empty() { + t.push_str("TINs not included:\n"); + for g in &tin_gaps { + t.push_str(&format!("- {g}\n")); + } + } + t + } else { + String::new() + }, + receipts = if req.include_receipts { + format!( + "receipts/: the {receipt_count} receipts behind the period's entries; index.csv ties each to its entry, date, amount, and SHA-256.\n{}", + if document_count > 0 { + "documents/: the company's formation, legal, and tax documents.\n" + } else { + "" + } + ) + } else { + String::new() + }, + ); + files.insert(0, ("README.txt".into(), readme.into_bytes())); + + // Zip it. + let mut zip_buf = Vec::new(); + { + let mut zip = zip::ZipWriter::new(std::io::Cursor::new(&mut zip_buf)); + let opts = zip::write::SimpleFileOptions::default(); + for (name, bytes) in &files { + zip.start_file(name.as_str(), opts) + .map_err(|e| e.to_string())?; + use std::io::Write; + zip.write_all(bytes).map_err(|e| e.to_string())?; + } + zip.finish().map_err(|e| e.to_string())?; + } + Ok(Package { + file_name: package_file_name(as_of), + files, + zip: zip_buf, + tin_gaps, + receipt_count, + }) +} diff --git a/crates/ledger-package/src/reports.rs b/crates/ledger-package/src/reports.rs new file mode 100644 index 0000000..58c4f52 --- /dev/null +++ b/crates/ledger-package/src/reports.rs @@ -0,0 +1,281 @@ +//! The financial statements as the server's report tools and the package +//! make them: from the books' settings, a date range, and account codes. +//! +//! ```text +//! GOAL: a statement in the package is the one the report tool shows for +//! the same range, with the same refusals. +//! SOURCE: ledger-mcp server.rs (books.profit_and_loss, books.balance_sheet, +//! books.cash_flow, books.export_cpa_package); ASC 230-10-20 +//! (financing and investing activities). +//! INVARIANTS: +//! 1. A range defaults its end to `today` and its start to the first +//! posted entry (never after the end); an end before its start is +//! refused. +//! 2. The cash flow statement needs at least one cash account, and +//! every code it is given must be in the chart; account lists a call +//! leaves empty default to the settings' roles. +//! 3. The year's earnings split net income year to date from the part +//! not yet closed into equity (closes are cumulative). +//! ``` + +use chrono::NaiveDate; +use ledger_core::settings::{Company, Member}; +use ledger_core::{AccountId, Ledger, PeriodStatus}; +use ledger_import::ReportMeta; +use rust_decimal::Decimal; + +use crate::text::parse_date; + +/// The books' settings the package and its reports read: the company, and +/// the account roles (`books.cash_accounts`, `books.financing_accounts`, +/// `books.investing_accounts`) as chart codes. +#[derive(Clone, Copy, Debug)] +pub struct Settings<'a> { + /// The company: name, basis, fiscal year, members, tax form. + pub company: &'a Company, + /// The cash accounts' codes. + pub cash_accounts: &'a [String], + /// The financing accounts' codes. + pub financing_accounts: &'a [String], + /// The investing accounts' codes. + pub investing_accounts: &'a [String], +} + +/// Resolve a statement's date range: `end` defaults to `today` and `start` +/// to the first posted entry (where "from the beginning" really begins, so +/// no statement says "0001-01-01"), or to `end` for empty books. An end +/// before its start is refused (INVARIANT 1). +pub fn statement_range( + ledger: &Ledger, + start: Option<&str>, + end: Option<&str>, + today: NaiveDate, +) -> Result<(NaiveDate, NaiveDate), String> { + let end = match end { + Some(s) => parse_date(s)?, + None => today, + }; + let start = match start { + Some(s) => parse_date(s)?, + None => ledger + .posted_entries() + .map(|e| e.date()) + .min() + .map_or(end, |first| first.min(end)), + }; + if end < start { + return Err("end is before start".to_string()); + } + Ok((start, end)) +} + +/// Profit and loss for a range (see [`statement_range`]), optionally one +/// class. +pub fn pnl_statement( + ledger: &Ledger, + start: Option<&str>, + end: Option<&str>, + class_id: Option, + today: NaiveDate, +) -> Result { + let (start, end) = statement_range(ledger, start, end, today)?; + ledger_core::profit_and_loss_for_period(ledger, start, end, class_id).map_err(|e| e.to_string()) +} + +/// Balance sheet as of a date (default `today`). +pub fn bs_statement( + ledger: &Ledger, + as_of: Option<&str>, + today: NaiveDate, +) -> Result { + let as_of = match as_of { + Some(s) => parse_date(s)?, + None => today, + }; + ledger_core::balance_sheet_as_of(ledger, as_of).map_err(|e| e.to_string()) +} + +/// Build the cash flow statement from account codes, as the exports need it: +/// `start` defaults to the beginning of time, `end` to `today`, and at +/// least one cash account is required (the statement reconciles to them). +pub fn cash_flow_statement( + ledger: &Ledger, + start: Option<&str>, + end: Option<&str>, + cash_codes: &[String], + financing_codes: &[String], + investing_codes: &[String], + today: NaiveDate, +) -> Result { + let resolve = |codes: &[String], what: &str| -> Result, String> { + codes + .iter() + .map(|code| { + ledger + .chart() + .find_by_code(code) + .map(|a| a.id) + .ok_or_else(|| format!("unknown {what} account code: {code}")) + }) + .collect() + }; + let cash_accounts = resolve(cash_codes, "cash")?; + if cash_accounts.is_empty() { + return Err( + "cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)" + .to_string(), + ); + } + let financing_accounts = resolve(financing_codes, "financing")?; + let investing_accounts = resolve(investing_codes, "investing")?; + let (start, end) = statement_range(ledger, start, end, today)?; + ledger_core::cash_flow( + ledger, + &ledger_core::CashFlowParams { + start, + end, + cash_accounts, + financing_accounts, + investing_accounts, + }, + ) + .map_err(|e| e.to_string()) +} + +/// The cash flow statement from account codes, each list defaulting to +/// the settings' roles when the call passes none. +#[allow(clippy::too_many_arguments)] +pub fn cash_flow_for( + ledger: &Ledger, + settings: &Settings<'_>, + start: Option<&str>, + end: Option<&str>, + cash: &[String], + financing: &[String], + investing: &[String], + today: NaiveDate, +) -> Result { + let or = |given: &'_ [String], default: &'_ [String]| -> Vec { + if given.is_empty() { + default.to_vec() + } else { + given.to_vec() + } + }; + cash_flow_statement( + ledger, + start, + end, + &or(cash, settings.cash_accounts), + &or(financing, settings.financing_accounts), + &or(investing, settings.investing_accounts), + today, + ) +} + +/// Header and provenance for a rendered report: the company's name and +/// basis, the day it was prepared, and the journal state it reflects. +pub fn report_meta(company: &Company, prepared_on: NaiveDate, journal_seq: u64) -> ReportMeta { + ReportMeta { + company: company.name.clone(), + basis: company.basis.clone(), + prepared_on, + journal_seq, + } +} + +/// Members as reports name them (empty unless a multi-member LLC). +pub fn report_members(company: &Company) -> Vec { + company + .members + .iter() + .map(|m| ledger_import::Member { + name: m.name.clone(), + capital_code: m.capital_account.clone(), + distribution_code: m.distribution_account.clone(), + }) + .collect() +} + +/// Members with their capital (and distribution) accounts resolved in the +/// chart, for the statement of members' equity and the K-1 worksheet; a +/// code the chart lacks is refused, naming the member. +pub fn member_capitals( + ledger: &Ledger, + members: &[Member], +) -> Result, String> { + members + .iter() + .map(|m| { + let account = ledger + .chart() + .find_by_code(&m.capital_account) + .ok_or_else(|| { + format!( + "member {}: unknown capital account {}", + m.name, m.capital_account + ) + })?; + let distribution_account = match &m.distribution_account { + Some(code) => Some( + ledger + .chart() + .find_by_code(code) + .ok_or_else(|| { + format!("member {}: unknown distribution account {code}", m.name) + })? + .id, + ), + None => None, + }; + Ok(ledger_core::MemberCapital { + name: m.name.clone(), + capital_account: account.id, + distribution_account, + share: m.share, + }) + }) + .collect() +} + +/// The fiscal year's earnings through `as_of`, for the balance sheet: +/// net income (loss) year to date (closing entries excluded), and the +/// part of it not yet closed into equity: the income after the last +/// close (closes are cumulative), from no earlier than the year's start. +pub fn year_earnings( + ledger: &Ledger, + company: &Company, + as_of: NaiveDate, +) -> Result { + let (fy_start, _) = company.fiscal_year_containing(as_of); + let net_income_ytd = ledger_core::profit_and_loss_for_period(ledger, fy_start, as_of, None) + .map_err(|e| e.to_string())? + .net_income(); + // A close takes everything unclosed through its end, earlier years + // included, so this year's unclosed part is the income from the day + // after the last close (never before the year's start) through + // as_of. (Counting the closing entries dated this year instead + // charged this year with closing last year's income: found by + // year_earnings_split_closed_from_unclosed, 2026-10-14.) + let last_close = ledger + .list_periods() + .iter() + .filter(|p| p.status == PeriodStatus::Closed && p.end <= as_of) + .map(|p| p.end) + .max(); + let from = match last_close.and_then(|e| e.succ_opt()) { + Some(after) if after > fy_start => after, + _ => fy_start, + }; + let unclosed = if from <= as_of { + ledger_core::profit_and_loss_for_period(ledger, from, as_of, None) + .map_err(|e| e.to_string())? + .net_income() + } else { + Decimal::ZERO + }; + Ok(ledger_import::YearEarnings { + net_income_ytd, + unclosed, + }) +} diff --git a/crates/ledger-package/src/text.rs b/crates/ledger-package/src/text.rs new file mode 100644 index 0000000..215c536 --- /dev/null +++ b/crates/ledger-package/src/text.rs @@ -0,0 +1,153 @@ +//! How the books' amounts, shares, dates, and CSV cells are written and +//! read: one spelling for the server's tools, the package, and the viewer. +//! +//! ```text +//! GOAL: an amount, a share, or a date reads the same in every file and +//! tool the books make, and a date in is checked one way. +//! SOURCE: ledger-mcp server.rs INVARIANT 3 (amounts out are decimal +//! strings with at least two places, never rounded); RFC 4180 +//! (a CSV field holding a comma, quote, or line break is quoted, +//! its quotes doubled); P0 review round 3, S1 (dates). +//! INVARIANTS: +//! 1. money pads to two places and never rounds; it never prints "-0". +//! 2. share_text depends on a share's value, never its spelling. +//! 3. csv_escape quotes exactly the fields that need it. +//! 4. parse_date takes exactly YYYY-MM-DD, a day the calendar keeps. +//! ``` + +use chrono::NaiveDate; +use rust_decimal::Decimal; + +/// A money amount as a decimal string with at least two places: "0.00", +/// "207.68", "1000.00". Padding only, never rounding: an exact amount with +/// more places (an FX-converted line) keeps them. Never "-0". +pub fn money(n: Decimal) -> String { + let mut n = if n.is_zero() { Decimal::ZERO } else { n }; + if n.scale() < 2 { + n.rescale(2); + } + n.to_string() +} + +/// A member's share as the K-1 worksheet prints it: at least two places, +/// however it was written ("50%", "0.5" and "0.500" all print "0.50"; a +/// share of a third keeps its places). The printed text depends on the +/// share, never on its spelling. +pub fn share_text(n: Decimal) -> String { + let mut n = n.normalize(); + if n.scale() < 2 { + n.rescale(2); + } + n.to_string() +} + +/// A CSV field: quoted, with its quotes doubled, when it holds a comma, a +/// quote, or a line feed; else as it is. +pub fn csv_escape(s: &str) -> String { + if s.contains([',', '"', '\n']) { + format!("\"{}\"", s.replace('"', "\"\"")) + } else { + s.to_string() + } +} + +/// A date: exactly YYYY-MM-DD, a real day in the calendar the books keep +/// (0001-01-01 through 9999-12-31, `ledger_core::calendar`). No sign, no +/// space, no five-digit year: every date past here is one date arithmetic +/// reaches (P0 review round 3, S1). +pub fn parse_date(s: &str) -> Result { + let shaped = s.len() == 10 + && s.bytes().enumerate().all(|(i, b)| { + if i == 4 || i == 7 { + b == b'-' + } else { + b.is_ascii_digit() + } + }); + shaped + .then(|| NaiveDate::parse_from_str(s, "%Y-%m-%d").ok()) + .flatten() + .filter(|d| ledger_core::calendar::in_calendar(*d)) + .ok_or_else(|| { + format!("bad date {s:?}: expected YYYY-MM-DD, a real day from 0001-01-01 to 9999-12-31") + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + + fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() + } + + #[test] + fn money_always_has_cents_and_never_rounds() { + let m = |s: &str| money(Decimal::from_str(s).unwrap()); + assert_eq!(money(Decimal::ZERO), "0.00"); + assert_eq!(m("-0"), "0.00"); + assert_eq!(m("1000"), "1000.00"); + assert_eq!(m("5.8"), "5.80"); + assert_eq!(m("-207.68"), "-207.68"); + assert_eq!(m("12.3456"), "12.3456"); + } + + /// A share prints by its value, never its spelling: at least two + /// places, a third keeping its own. + #[test] + fn a_share_prints_the_same_however_it_was_written() { + let t = |s: &str| share_text(Decimal::from_str(s).unwrap()); + for half in ["0.5", "0.50", "0.500000"] { + assert_eq!(t(half), "0.50", "{half}"); + } + assert_eq!(t("1"), "1.00"); + assert_eq!(t("1.0"), "1.00"); + assert_eq!(t("0.25"), "0.25"); + assert_eq!(t("0.3333"), "0.3333"); + assert_eq!(t("0.125"), "0.125"); + } + + #[test] + fn csv_fields_are_quoted_only_when_they_must_be() { + assert_eq!(csv_escape("plain"), "plain"); + assert_eq!(csv_escape("a,b"), "\"a,b\""); + assert_eq!(csv_escape("say \"hi\""), "\"say \"\"hi\"\"\""); + assert_eq!(csv_escape("two\nlines"), "\"two\nlines\""); + assert_eq!(csv_escape(""), ""); + } + + /// A date is exactly YYYY-MM-DD, a real day from 0001-01-01 to + /// 9999-12-31 (ledger_core::calendar): no sign, no space, no fifth + /// year digit, no year 0 (P0 review round 3, S1). + #[test] + fn dates_are_four_digit_years_and_real_days() { + assert_eq!(parse_date("2026-02-28"), Ok(d(2026, 2, 28))); + assert_eq!(parse_date("2028-02-29"), Ok(d(2028, 2, 29))); + assert_eq!(parse_date("0001-01-01"), Ok(d(1, 1, 1))); + assert_eq!(parse_date("9999-12-31"), Ok(d(9999, 12, 31))); + for bad in [ + "0000-01-01", + "0000-00-00", + "2026-02-29", + "2026-02-30", + "2026-13-01", + "2026-1-05", + "2026-01-5", + " 2026-01-05", + "2026-01-05 ", + "+2026-01-05", + "-0001-01-01", + "+262142-12-31", + "-262143-01-01", + "99999-12-31", + "2026/01/05", + "2026-01-05T00:00", + "2026-01-05", + "", + ] { + let err = parse_date(bad).expect_err(bad); + assert!(err.contains("0001-01-01 to 9999-12-31"), "{bad:?}: {err}"); + } + } +} diff --git a/crates/ledger-package/src/tins.rs b/crates/ledger-package/src/tins.rs new file mode 100644 index 0000000..3c558d5 --- /dev/null +++ b/crates/ledger-package/src/tins.rs @@ -0,0 +1,100 @@ +//! Full TINs for the CPA's worksheets: where they come from, and the check +//! that the vault's TIN is the one the books record. +//! +//! ```text +//! GOAL: a worksheet for the CPA carries a TIN only when the vault and the +//! books agree on it, and says why when it can't. +//! SOURCE: DECISION (owner, 2026-10-15): the vault holds every member's +//! and payee's full TIN for the CPA package; docs/parties-and- +//! attachments.md "The TIN vault". +//! INVARIANTS: +//! 1. The party's TIN facts, when on file, have the vault TIN's type and +//! last four, and the record's hash when they name one. +//! 2. Its W-9 facts, when on file, have the vault TIN's last four; with +//! no TIN facts, the W-9 facts' vault record is the record's hash. +//! 3. A vault that can't be opened or read, or holds no TIN for the +//! party, is a note, never an error (a package never fails on it). +//! ``` + +use ledger_core::{Ledger, PartyId}; + +/// Where a worksheet's full TINs come from: a party's TIN, else why not +/// (a note for the worksheet, never holding more than a last four). The +/// server and the viewer give `|id| vault_tin(ledger, &vault, id)`. +pub type TinSource<'a> = &'a dyn Fn(PartyId) -> Result; + +/// A TIN type's wire name. +pub fn tin_type_key(t: ledger_vault::TinType) -> &'static str { + match t { + ledger_vault::TinType::Ssn => "ssn", + ledger_vault::TinType::Ein => "ein", + ledger_vault::TinType::Itin => "itin", + } +} + +/// The books' name for a vault TIN's type. +pub fn tin_kind(t: ledger_vault::TinType) -> ledger_core::TinType { + match t { + ledger_vault::TinType::Ssn => ledger_core::TinType::Ssn, + ledger_vault::TinType::Ein => ledger_core::TinType::Ein, + ledger_vault::TinType::Itin => ledger_core::TinType::Itin, + } +} + +/// The full TIN the vault holds for a party, checked against what the +/// books record of it; else why not, in words for a worksheet's note (the +/// note never holds more than a last four). `vault` is the vault as +/// opened, or why it couldn't be (INVARIANT 3). +pub fn vault_tin( + ledger: &Ledger, + vault: &Result, + party: PartyId, +) -> Result { + let vault = vault.as_ref().map_err(String::clone)?; + let t = vault + .tin(party) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + "TIN not in the vault: record it with books.set_tin (or books.set_w9 tin, from the W-9)" + .to_string() + })?; + let hash = vault.record_hash(party).ok().flatten(); + let p = ledger.get_party(party); + let (facts, w9) = ( + p.and_then(|p| p.tin.as_ref()), + p.and_then(|p| p.w9.as_ref()), + ); + if facts.is_none() && w9.is_none() { + return Err( + "the vault holds a TIN but the books have no record of it: record it again with books.set_tin" + .into(), + ); + } + if let Some(f) = facts { + if f.last4 != t.last4() || f.tin_type != tin_kind(t.kind) { + return Err(format!( + "the vault's TIN ({}, ending {}) isn't the one the books record ({}, ending {}): record the right one with books.set_tin", + tin_type_key(t.kind), + t.last4(), + f.tin_type.key(), + f.last4 + )); + } + if f.vault_record.is_some() && f.vault_record != hash { + return Err("the vault record isn't the one the books recorded: record the TIN again with books.set_tin".into()); + } + } + if let Some(f) = w9 { + if f.tin_last4 != t.last4() { + return Err(format!( + "the vault's TIN ends in {}, the W-9 facts say {}: check the W-9 and fix with books.set_w9", + t.last4(), + f.tin_last4 + )); + } + if facts.is_none() && f.vault_record.is_some() && f.vault_record != hash { + return Err("the vault record isn't the one the books recorded: check the W-9 and record it again with books.set_w9 tin".into()); + } + } + Ok(t) +} diff --git a/crates/ledger-package/src/vendor_1099.rs b/crates/ledger-package/src/vendor_1099.rs new file mode 100644 index 0000000..e860a57 --- /dev/null +++ b/crates/ledger-package/src/vendor_1099.rs @@ -0,0 +1,250 @@ +//! The 1099 worksheet: what `books.vendor_1099` answers and hands over as +//! CSV, and what the viewer saves, with each payee's TIN when asked. +//! +//! ```text +//! GOAL: the CPA gets one 1099 worksheet, the same from the server and +//! the viewer: cash paid per vendor, who is reportable, who lacks a +//! W-9, and each payee's TIN. +//! SOURCE: ledger_core::form1099 (payments out of the cash and financing +//! accounts; an entry's shares resolved with the members); IRS +//! thresholds (rules.md B-19: never guessed); RFC 4180 (CSV). +//! INVARIANTS: +//! 1. Payments come from the settings' cash and financing accounts +//! (codes the chart lacks are skipped), an entry's shares resolved +//! with the company's members. +//! 2. A year whose threshold isn't known is refused, never guessed. +//! 3. Full TINs only from the TIN source given (crate INVARIANT 2): with +//! one, every line carries its TIN or a note why not; without one, +//! none does. +//! 4. The CSV has a header and one row per line, in the worksheet's +//! order; the TIN columns only when asked. +//! ``` + +use ledger_core::{EntryId, Ledger, PartyId, VendorId}; +use rust_decimal::Decimal; + +use crate::reports::Settings; +use crate::text::{csv_escape, money}; +use crate::tins::{TinSource, tin_type_key}; + +/// Where payments to vendors come from, for the 1099 worksheet: the +/// settings' cash and financing accounts in the chart (codes the chart +/// lacks are skipped; `books.settings` reports them). +pub fn payment_accounts(ledger: &Ledger, settings: &Settings<'_>) -> ledger_core::PaymentAccounts { + let ids = |codes: &[String]| { + codes + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect() + }; + ledger_core::PaymentAccounts { + cash: ids(settings.cash_accounts), + financing: ids(settings.financing_accounts), + } +} + +/// The 1099 report for `year`, the one every tool, the worksheet, and the +/// CPA package use: payments out of [`payment_accounts`], an entry's +/// shares resolved with the settings' members. +pub fn vendor_1099_report( + ledger: &Ledger, + settings: &Settings<'_>, + year: i32, + threshold: Option, +) -> Result { + ledger_core::vendor_1099_report_with_members( + ledger, + year, + threshold, + &payment_accounts(ledger, settings), + &settings.company.party_members(ledger), + ) +} + +/// A 1099 form's label ("1099-NEC", "1099-MISC"), if the vendor is marked. +pub fn form_1099_label(form: Option) -> Option { + form.map(|f| f.label().to_string()) +} + +/// One vendor's line of the 1099 worksheet. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Vendor1099WorksheetLine { + /// The vendor. + pub vendor_id: VendorId, + /// Its name. + pub name: String, + /// "1099-NEC", "1099-MISC", or none. + pub form_1099: Option, + /// W-9 on file. + pub tin_on_file: bool, + /// Cash paid in the year. + pub paid: Decimal, + /// Marked for a 1099 and paid at least the threshold. + pub reportable: bool, + /// The vendor's party. + pub party_id: Option, + /// The TIN's last four, from the party's W-9 facts, else its TIN + /// facts. + pub tin_last4: Option, + /// The full TIN, formatted (123-45-6789, 12-3456789): only with a TIN + /// source, and only when it has one. + pub tin: Option, + /// The TIN's type (ssn, ein, itin): only with a TIN source. + pub tin_type: Option, + /// With a TIN source, why `tin` is missing. + pub tin_note: Option, +} + +/// The 1099 worksheet for a calendar year. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Vendor1099Worksheet { + /// Calendar year. + pub year: i32, + /// The threshold applied. + pub threshold: Decimal, + /// One line per vendor marked for a 1099 or paid in the year, by name. + pub lines: Vec, + /// Reportable vendors with no W-9 on file. + pub missing_w9: Vec, + /// Entries naming several parties whose shares don't resolve (each + /// counted in full toward its vendor). + pub unresolved: Vec, + /// What to check: what counts, exemptions, the threshold's indexing, + /// untagged entries, unresolved shares. + pub notes: Vec, + /// Whether the lines carry TINs from a TIN source. + pub tins_included: bool, +} + +/// The 1099 worksheet for `year` (threshold: the IRS figure to use when +/// the year's isn't known, else none), each line with its full TIN from +/// `tins` when given (INVARIANT 3). +pub fn vendor_1099_worksheet( + ledger: &Ledger, + settings: &Settings<'_>, + year: i32, + threshold: Option, + tins: Option>, +) -> Result { + // A year past what wanderbooks knows is refused with guidance + // (research or ask, pass `threshold`, then update the rule). + let r = vendor_1099_report(ledger, settings, year, threshold).map_err(|e| e.to_string())?; + let missing_w9 = r + .vendors + .iter() + .filter(|v| v.reportable && !v.tin_on_file) + .map(|v| v.name.clone()) + .collect(); + let untagged = ledger + .posted_entries() + .filter(|e| { + chrono::Datelike::year(&e.date()) == year && ledger.entry_vendor(e.id()).is_none() + }) + .count(); + let mut notes = vec![ + "Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).".to_string(), + "Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.".to_string(), + ]; + if year > 2026 { + notes.push(format!( + "The $2,000 threshold is indexed for inflation after 2026; confirm the {year} amount and pass threshold to override." + )); + } + if untagged > 0 { + notes.push(format!( + "{untagged} posted entries in {year} have no vendor tag; review any that paid contractors or rent." + )); + } + if !r.unresolved.is_empty() { + notes.push(format!( + "Entries {} name several parties whose shares don't resolve, so each counts in full toward its vendor: fix the shares with books.set_entry_party.", + r.unresolved + .iter() + .map(|id| id.to_string()) + .collect::>() + .join(", ") + )); + } + let mut lines = Vec::new(); + for v in r.vendors { + let party_id = ledger.party_of_vendor(v.vendor_id); + let tin_last4 = party_id.and_then(|id| ledger.get_party(id)).and_then(|p| { + p.w9.as_ref() + .map(|f| f.tin_last4.clone()) + .or_else(|| p.tin.as_ref().map(|t| t.last4.clone())) + }); + let mut line = Vendor1099WorksheetLine { + vendor_id: v.vendor_id, + name: v.name, + form_1099: form_1099_label(v.form), + tin_on_file: v.tin_on_file, + paid: v.paid, + reportable: v.reportable, + party_id, + tin_last4, + tin: None, + tin_type: None, + tin_note: None, + }; + if let Some(tins) = tins { + match party_id.map(tins) { + None => line.tin_note = Some("no party for this vendor".into()), + Some(Err(why)) => line.tin_note = Some(why), + Some(Ok(t)) => { + line.tin = Some(t.formatted()); + line.tin_type = Some(tin_type_key(t.kind).to_string()); + } + } + } + lines.push(line); + } + Ok(Vendor1099Worksheet { + year: r.year, + threshold: r.threshold, + lines, + missing_w9, + unresolved: r.unresolved, + notes, + tins_included: tins.is_some(), + }) +} + +/// The worksheet as CSV (INVARIANT 4): vendor id, name, form, paid, +/// reportable, W-9 on file, and last four; `with_tins` adds the TIN, its +/// type, and the note why it's missing. +pub fn vendor_1099_csv(worksheet: &Vendor1099Worksheet, with_tins: bool) -> String { + let mut csv = String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4"); + if with_tins { + csv.push_str(",tin,tin_type,tin_note"); + } + csv.push('\n'); + for l in &worksheet.lines { + let mut cells = vec![ + l.vendor_id.to_string(), + csv_escape(&l.name), + l.form_1099.clone().unwrap_or_default(), + money(l.paid), + l.reportable.to_string(), + l.tin_on_file.to_string(), + l.tin_last4.clone().unwrap_or_default(), + ]; + if with_tins { + cells.push(l.tin.clone().unwrap_or_default()); + cells.push(l.tin_type.clone().unwrap_or_default()); + cells.push(csv_escape(l.tin_note.as_deref().unwrap_or_default())); + } + csv.push_str(&cells.join(",")); + csv.push('\n'); + } + csv +} + +/// The worksheet CSV's file name: `1099-worksheet-.csv`, or +/// `1099-worksheet--with-tins.csv`. +pub fn vendor_1099_csv_name(year: i32, with_tins: bool) -> String { + format!( + "1099-worksheet-{}{}.csv", + year, + if with_tins { "-with-tins" } else { "" } + ) +} diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs new file mode 100644 index 0000000..6b4555f --- /dev/null +++ b/crates/ledger-package/tests/package.rs @@ -0,0 +1,799 @@ +//! The CPA package and the 1099 worksheet, built over a small set of books +//! through both views of them (`Store`, `Snapshot`): the files, their +//! order, their contents, and the TINs, with a TIN source and without. + +use chrono::NaiveDate; +use ledger_core::settings::{Company, Member}; +use ledger_core::{AccountType, Currency, Form1099, Money, PartyId, Role, Side, TinFacts, TinType}; +use ledger_package::{ + Package, PackageBooks, PackageRequest, Settings, build_package, vendor_1099_csv, + vendor_1099_csv_name, vendor_1099_worksheet, +}; +use ledger_store::{Snapshot, Store, Subject}; +use rust_decimal::Decimal; +use std::io::Read; +use std::path::Path; +use std::str::FromStr; + +const MEMBER_SSN: &str = "241-40-4915"; +const EIN: &str = "12-3279105"; + +fn d(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(Decimal::from_str(s).unwrap(), Currency::USD) +} + +struct Books { + store: Store, + rent: u64, + sale: u64, + contribution: u64, + acme: PartyId, + tobin: PartyId, + mo: PartyId, + vendor: u64, +} + +fn id_of(s: &Store, code: &str) -> ledger_core::AccountId { + s.ledger().chart().find_by_code(code).unwrap().id +} + +fn post(s: &mut Store, date: &str, desc: &str, lines: &[(&str, Side, &str)]) -> u64 { + let e = s.create_entry(d(date), desc).unwrap(); + for (code, side, a) in lines { + let id = id_of(s, code); + s.add_line(e, id, *side, usd(a)).unwrap(); + } + s.post_entry(e).unwrap(); + e +} + +/// Two members (owner, with TIN facts; Mo, without), a contractor paid +/// past 2026's threshold, a receipt filed on the rent and one linked to +/// the sale, a company document, a loose receipt, and a withdrawn file. +fn books(dir: &Path) -> Books { + let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap(); + for (code, name, ty) in [ + ("1000", "Checking", AccountType::Asset), + ("3000", "Capital, Tobin", AccountType::Equity), + ("3001", "Capital, Mo", AccountType::Equity), + ("4000", "Sales", AccountType::Revenue), + ("6000", "Rent", AccountType::Expense), + ("6100", "Contract labor", AccountType::Expense), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + let tobin = s.add_party("Tobin Example", &[Role::Member]).unwrap(); + let mo = s.add_party("Mo Example", &[Role::Member]).unwrap(); + s.set_party_tin( + tobin, + Some(TinFacts { + tin_type: TinType::Ssn, + last4: "4915".into(), + vault_record: None, + purged: None, + }), + ) + .unwrap(); + let vendor = s.add_vendor("Acme, \"Design\"").unwrap(); + let acme = s.ledger().party_of_vendor(vendor).unwrap(); + s.set_vendor_1099(vendor, Some(Form1099::Nec), true) + .unwrap(); + s.set_party_tin( + acme, + Some(TinFacts { + tin_type: TinType::Ein, + last4: "9105".into(), + vault_record: None, + purged: None, + }), + ) + .unwrap(); + + let contribution = post( + &mut s, + "2026-01-02", + "Members' contributions", + &[ + ("1000", Side::Debit, "10000.00"), + ("3000", Side::Credit, "5000.00"), + ("3001", Side::Credit, "5000.00"), + ], + ); + let sale = post( + &mut s, + "2026-02-01", + "Sale", + &[ + ("1000", Side::Debit, "4000"), + ("4000", Side::Credit, "4000"), + ], + ); + let rent = post( + &mut s, + "2026-03-01", + "Rent, \"March\"", + &[ + ("6000", Side::Debit, "1200"), + ("1000", Side::Credit, "1200"), + ], + ); + let e = s.create_entry(d("2026-04-01"), "Acme design work").unwrap(); + s.add_line(e, id_of(&s, "6100"), Side::Debit, usd("2500")) + .unwrap(); + s.add_line(e, id_of(&s, "1000"), Side::Credit, usd("2500")) + .unwrap(); + s.set_entry_vendor(e, Some(vendor)).unwrap(); + s.post_entry(e).unwrap(); + // After as_of: not in the package. + post( + &mut s, + "2027-01-05", + "Next year's rent", + &[ + ("6000", Side::Debit, "1200"), + ("1000", Side::Credit, "1200"), + ], + ); + + s.attach_receipt( + Some(rent), + "rent/march.pdf", + "application/pdf", + b"%PDF rent", + None, + ) + .unwrap(); + let (linked, _, _) = s + .attach_receipt(None, "invoice.pdf", "application/pdf", b"%PDF sale", None) + .unwrap(); + s.link_attachment(linked, Subject::Entry(sale)).unwrap(); + s.file_document( + "legal", + "operating agreement.pdf", + "application/pdf", + b"%PDF agreement", + None, + ) + .unwrap(); + s.attach_receipt(None, "loose.pdf", "application/pdf", b"%PDF loose", None) + .unwrap(); + let (gone, _, _) = s + .file_document("tax", "mistake.pdf", "application/pdf", b"%PDF oops", None) + .unwrap(); + s.withdraw_attachment(gone, "filed by mistake").unwrap(); + Books { + store: s, + rent, + sale, + contribution, + acme, + tobin, + mo, + vendor, + } +} + +fn company(members: bool) -> Company { + let member = |name: &str, capital: &str| Member { + name: name.into(), + share: Decimal::from_str("0.5").unwrap(), + capital_account: capital.into(), + distribution_account: None, + files_jointly_with: None, + }; + Company { + name: "Example Labs LLC".into(), + basis: "Cash".into(), + members: if members { + // Spouses: each names the other (settings require both). + let mut tobin = member("Tobin Example", "3000"); + tobin.files_jointly_with = Some("Mo Example".into()); + let mut mo = member("Mo Example", "3001"); + mo.files_jointly_with = Some("Tobin Example".into()); + vec![tobin, mo] + } else { + Vec::new() + }, + ..Company::default() + } +} + +fn cash() -> Vec { + vec!["1000".to_string()] +} + +fn settings<'a>(company: &'a Company, cash: &'a [String]) -> Settings<'a> { + Settings { + company, + cash_accounts: cash, + financing_accounts: &[], + investing_accounts: &[], + } +} + +fn request<'a>( + include_receipts: bool, + tins: Option>, +) -> PackageRequest<'a> { + PackageRequest { + as_of: d("2026-12-31"), + prepared_on: d("2027-01-15"), + cash_accounts: &[], + financing_accounts: &[], + investing_accounts: &[], + include_receipts, + tins, + } +} + +fn names(p: &Package) -> Vec<&str> { + p.files.iter().map(|(n, _)| n.as_str()).collect() +} + +fn text<'p>(p: &'p Package, name: &str) -> &'p str { + let (_, bytes) = p + .files + .iter() + .find(|(n, _)| n == name) + .unwrap_or_else(|| panic!("{name} in {:?}", names(p))); + std::str::from_utf8(bytes).unwrap() +} + +/// `files` with each XLSX as its parts, its creation stamp +/// (docProps/core.xml's dcterms:created and dcterms:modified, the clock's +/// time when it is written) blanked: the only bytes of a package that +/// depend on when it is built. +fn stable(files: &[(String, Vec)]) -> Vec<(String, Vec)> { + files + .iter() + .map(|(name, bytes)| { + if !name.ends_with(".xlsx") { + return (name.clone(), bytes.clone()); + } + let mut x = zip::ZipArchive::new(std::io::Cursor::new(bytes)).unwrap(); + let mut out = Vec::new(); + for i in 0..x.len() { + let mut part = x.by_index(i).unwrap(); + let part_name = part.name().unwrap().to_string(); + let mut b = Vec::new(); + part.read_to_end(&mut b).unwrap(); + if part_name == "docProps/core.xml" { + let mut t = String::from_utf8(b).unwrap(); + for tag in ["dcterms:created", "dcterms:modified"] { + let open = t.find(&format!("<{tag}")).unwrap(); + let start = open + t[open..].find('>').unwrap() + 1; + let end = start + t[start..].find(&format!("")).unwrap(); + t.replace_range(start..end, "STAMP"); + } + b = t.into_bytes(); + } + out.extend_from_slice(part_name.as_bytes()); + out.push(0); + out.extend_from_slice(&b); + out.push(0); + } + (name.clone(), out) + }) + .collect() +} + +/// The zip holds exactly the files, in order, byte for byte. +fn assert_zip_is_the_files(p: &Package) { + let mut z = zip::ZipArchive::new(std::io::Cursor::new(&p.zip)).unwrap(); + assert_eq!(z.len(), p.files.len()); + for (i, (name, bytes)) in p.files.iter().enumerate() { + let mut f = z.by_index(i).unwrap(); + assert_eq!(f.name().unwrap(), name.as_str()); + let mut got = Vec::new(); + f.read_to_end(&mut got).unwrap(); + assert_eq!(&got, bytes, "{name}"); + } +} + +#[test] +fn a_single_member_package_holds_the_documented_files_in_order() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let company = company(false); + let cash = cash(); + let p = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap(); + assert_eq!(p.file_name, "cpa-package-2026-12-31.zip"); + let sale_date = "2026-02-01"; + let want = vec![ + "README.txt".to_string(), + "profit-and-loss.pdf".into(), + "profit-and-loss.xlsx".into(), + "balance-sheet.pdf".into(), + "balance-sheet.xlsx".into(), + "cash-flow.pdf".into(), + "cash-flow.xlsx".into(), + "tax-summary.pdf".into(), + "tax-summary.xlsx".into(), + "trial-balance.pdf".into(), + "trial-balance.xlsx".into(), + "general-ledger.xlsx".into(), + "general-journal.xlsx".into(), + "1099-worksheet-2026.csv".into(), + // Attachment order: the rent's receipt (its name cleaned), the + // sale's linked one, the company's document; never the loose + // receipt or the withdrawn file. + format!("receipts/2026-03-01-entry-{}-rent_march.pdf", b.rent), + format!("receipts/{sale_date}-entry-{}-invoice.pdf", b.sale), + "documents/legal/operating agreement.pdf".into(), + "receipts/index.csv".into(), + "general-journal.iif".into(), + ]; + assert_eq!(names(&p), want); + assert_eq!(p.receipt_count, 2); + assert!(p.tin_gaps.is_empty()); + for (name, bytes) in &p.files { + if name.ends_with(".pdf") && !name.contains('/') { + assert!(bytes.starts_with(b"%PDF-"), "{name}"); + } + if name.ends_with(".xlsx") { + assert!(bytes.starts_with(b"PK"), "{name}"); + } + } + let rent_sha = ledger_store::journal::sha256_hex(b"%PDF rent"); + let sale_sha = ledger_store::journal::sha256_hex(b"%PDF sale"); + assert_eq!( + text(&p, "receipts/index.csv"), + format!( + "entry_id,date,description,amount,file,sha256\n\ + {rent},2026-03-01,\"Rent, \"\"March\"\"\",1200.00,receipts/2026-03-01-entry-{rent}-rent_march.pdf,{rent_sha}\n\ + {sale},2026-02-01,\"Sale\",4000.00,receipts/2026-02-01-entry-{sale}-invoice.pdf,{sale_sha}\n", + rent = b.rent, + sale = b.sale, + ) + ); + assert_eq!( + text(&p, "1099-worksheet-2026.csv"), + "vendor,form,w9_on_file,paid,reportable\n\"Acme, \"\"Design\"\"\",1099-NEC,true,2500.00,true\n" + ); + let readme = text(&p, "README.txt"); + assert!(readme.starts_with("Example Labs LLC — books package for the CPA\n\nFiscal year to date: 2026-01-01 through 2026-12-31. Cash basis.\n"), "{readme}"); + assert!( + readme.contains(&format!("({} events, verified)", b.store.journal_seq())), + "{readme}" + ); + assert!(readme.contains("receipts/: the 2 receipts behind the period's entries")); + assert!(readme.contains("cash flow, tax summary, trial balance.")); + assert!(!readme.contains("k1-members.csv") && !readme.contains("full taxpayer")); + assert!(!names(&p).contains(&"k1-members.csv")); + let iif = text(&p, "general-journal.iif"); + assert!(iif.contains("Checking") && iif.contains("BANK"), "{iif}"); + // Every posted entry, the year after as_of's included. + assert!(iif.contains("Next year's rent"), "{iif}"); + assert_zip_is_the_files(&p); +} + +#[test] +fn without_receipts_the_package_holds_no_evidence() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let company = company(false); + let cash = cash(); + let with = build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap(); + let p = build_package(&b.store, &settings(&company, &cash), &request(false, None)).unwrap(); + assert!( + !names(&p) + .iter() + .any(|n| n.starts_with("receipts/") || n.starts_with("documents/")), + "{:?}", + names(&p) + ); + assert_eq!(p.receipt_count, 0); + // Everything else is the same, byte for byte, but the README's + // evidence lines. + let rest = |p: &Package| -> Vec<(String, Vec)> { + p.files + .iter() + .filter(|(n, _)| { + n != "README.txt" && !n.starts_with("receipts/") && !n.starts_with("documents/") + }) + .cloned() + .collect() + }; + assert_eq!(stable(&rest(&p)), stable(&rest(&with))); + let readme = text(&p, "README.txt"); + assert!(!readme.contains("receipts/") && !readme.contains("documents/")); + assert_eq!( + text(&with, "README.txt").replace( + "receipts/: the 2 receipts behind the period's entries; index.csv ties each to its entry, date, amount, and SHA-256.\ndocuments/: the company's formation, legal, and tax documents.\n", + "" + ), + readme + ); + assert_zip_is_the_files(&p); +} + +/// Tobin's and Acme's TINs from the source; Mo's isn't there. +fn tin_source(b: &Books) -> impl Fn(PartyId) -> Result + '_ { + move |id| { + if id == b.tobin { + Ok(ledger_vault::Tin::new(MEMBER_SSN, ledger_vault::TinType::Ssn).unwrap()) + } else if id == b.acme { + Ok(ledger_vault::Tin::new(EIN, ledger_vault::TinType::Ein).unwrap()) + } else { + Err("TIN not in the vault: record it with books.set_tin".into()) + } + } +} + +#[test] +fn members_get_the_k1_worksheet_with_full_tins_only_from_a_source() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let company = company(true); + let cash = cash(); + let source = tin_source(&b); + let with = build_package( + &b.store, + &settings(&company, &cash), + &request(true, Some(&source)), + ) + .unwrap(); + let without = + build_package(&b.store, &settings(&company, &cash), &request(true, None)).unwrap(); + + for p in [&with, &without] { + let n = names(p); + assert!(n.contains(&"members-equity.pdf") && n.contains(&"members-equity.xlsx")); + // The K-1 worksheet comes after the evidence, before the IIF. + assert_eq!(&n[n.len() - 2..], ["k1-members.csv", "general-journal.iif"]); + assert_zip_is_the_files(p); + } + // Capital: 5000 in each; income 4000 - 1200 - 2500 = 300, half each. + assert_eq!( + text(&with, "k1-members.csv"), + format!( + "member,party_id,share,capital_account,tin,tin_type,tin_note,beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n\ + Tobin Example,{s},0.50,3000,{MEMBER_SSN},ssn,,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\ + Mo Example,{k},0.50,3001,,,TIN not in the vault: record it with books.set_tin,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\ + \"Total, all members (not a K-1)\",,1.00,,,,,0.00,10000.00,0.00,300.00,0.00,0.00,10300.00\n", + s = b.tobin, + k = b.mo, + ) + ); + assert_eq!( + text(&without, "k1-members.csv"), + format!( + "member,party_id,share,capital_account,tin_last4,tin_type,beginning_capital,contributions_cash,contributions_property,net_income,distributions,other,ending_capital\n\ + Tobin Example,{s},0.50,3000,4915,ssn,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\ + Mo Example,{k},0.50,3001,,,0.00,5000.00,0.00,150.00,0.00,0.00,5150.00\n\ + \"Total, all members (not a K-1)\",,1.00,,,,0.00,10000.00,0.00,300.00,0.00,0.00,10300.00\n", + s = b.tobin, + k = b.mo, + ) + ); + assert_eq!( + text(&with, "1099-worksheet-2026.csv"), + format!( + "vendor,form,w9_on_file,paid,reportable,party_id,tin_last4,tin,tin_type,tin_note\n\ + \"Acme, \"\"Design\"\"\",1099-NEC,true,2500.00,true,{},9105,{EIN},ein,\n", + b.acme + ) + ); + assert!(!text(&without, "1099-worksheet-2026.csv").contains(EIN)); + + // The gap: Mo's, in tin_gaps and the README. + assert_eq!( + with.tin_gaps, + ["member Mo Example: TIN not in the vault: record it with books.set_tin"] + ); + assert!(without.tin_gaps.is_empty()); + let readme = text(&with, "README.txt"); + assert!(readme.contains("cash flow, members' equity, tax summary")); + assert!(readme.contains(", with each payee's TIN.\n")); + assert!(readme.contains("with their taxpayer ID; the last row totals")); + // Said once for the couple. + assert_eq!( + readme + .matches("file a joint individual return: each receives their own Schedule K-1; both are reported on the one Form 1040 (Schedule E, Part II).\n") + .count(), + 1 + ); + assert!(readme.contains("\nTobin Example and Mo Example file a joint individual return")); + assert!(readme.contains( + "\nThis package holds full taxpayer identification numbers: keep it out of email and shared folders, and delete copies once filed.\nTINs not included:\n- member Mo Example: TIN not in the vault: record it with books.set_tin\n" + )); + let plain = text(&without, "README.txt"); + assert!(plain.contains("with their taxpayer ID's last four;")); + assert!(!plain.contains("full taxpayer") && !plain.contains("payee's TIN")); + for (_, bytes) in &without.files { + let s = String::from_utf8_lossy(bytes); + assert!(!s.contains(MEMBER_SSN) && !s.contains(EIN)); + } + let _ = b.contribution; +} + +#[test] +fn a_store_and_a_snapshot_of_the_same_books_build_the_same_package() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let snap = Snapshot::load(&dir.path().join("books-data")).unwrap(); + assert_eq!( + PackageBooks::journal_seq(&snap), + PackageBooks::journal_seq(&b.store) + ); + for a in b.store.list_attachments(None).unwrap() { + let mut from_store: Vec = b.store.links_of(a.id).unwrap(); + from_store.retain(|s| a.entry_id.map(Subject::Entry) != Some(*s)); + assert_eq!( + snap.links_of(a.id).unwrap(), + from_store, + "attachment {}", + a.id + ); + } + let company = company(true); + let cash = cash(); + let source = tin_source(&b); + for tins in [None, Some(&source as ledger_package::TinSource<'_>)] { + for receipts in [true, false] { + let s = settings(&company, &cash); + let from_store = build_package(&b.store, &s, &request(receipts, tins)).unwrap(); + let from_snap = build_package(&snap, &s, &request(receipts, tins)).unwrap(); + assert_eq!(stable(&from_store.files), stable(&from_snap.files)); + assert_eq!(from_store.tin_gaps, from_snap.tin_gaps); + assert_eq!(from_store.receipt_count, from_snap.receipt_count); + } + } +} + +#[test] +fn a_package_needs_a_cash_account() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let company = company(false); + let err = build_package(&b.store, &settings(&company, &[]), &request(true, None)).unwrap_err(); + assert_eq!( + err, + "cash_accounts is required for the cash flow statement (pass it, or set the books' cash accounts setting: books.settings shows it)" + ); + // A request's own accounts override the settings'. + let mut req = request(true, None); + let bad = vec!["9999".to_string()]; + req.cash_accounts = &bad; + let err = build_package(&b.store, &settings(&company, &cash()), &req).unwrap_err(); + assert_eq!(err, "unknown cash account code: 9999"); +} + +#[test] +fn the_1099_worksheet_carries_tins_only_from_a_source() { + let dir = tempfile::tempdir().unwrap(); + let b = books(dir.path()); + let company = company(false); + let cash = cash(); + let s = settings(&company, &cash); + let ledger = b.store.ledger(); + let plain = vendor_1099_worksheet(ledger, &s, 2026, None, None).unwrap(); + assert_eq!(plain.year, 2026); + assert_eq!(plain.threshold, Decimal::from(2000)); + assert!(!plain.tins_included); + assert_eq!(plain.lines.len(), 1); + let l = &plain.lines[0]; + assert_eq!(l.vendor_id, b.vendor); + assert_eq!(l.party_id, Some(b.acme)); + assert_eq!(l.paid, Decimal::from(2500)); + assert!(l.reportable && l.tin_on_file); + assert_eq!(l.tin_last4.as_deref(), Some("9105")); + assert_eq!((&l.tin, &l.tin_type, &l.tin_note), (&None, &None, &None)); + assert!(plain.missing_w9.is_empty()); + // Five posted entries in 2026, one tagged. + assert_eq!( + plain.notes, + [ + "Only entries tagged with a vendor count (books.set_entry_vendor; books.pay_bill tags automatically).", + "Corporations are generally exempt, except attorneys' fees and medical payments; royalties report at $10. Confirm with the CPA.", + "3 posted entries in 2026 have no vendor tag; review any that paid contractors or rent.", + ] + ); + assert_eq!( + vendor_1099_csv(&plain, false), + format!( + "vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4\n{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105\n", + b.vendor + ) + ); + let source = tin_source(&b); + let full = vendor_1099_worksheet(ledger, &s, 2026, None, Some(&source)).unwrap(); + assert!(full.tins_included); + assert_eq!(full.lines[0].tin.as_deref(), Some(EIN)); + assert_eq!(full.lines[0].tin_type.as_deref(), Some("ein")); + assert_eq!( + vendor_1099_csv(&full, true), + format!( + "vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin,tin_type,tin_note\n{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105,{EIN},ein,\n", + b.vendor + ) + ); + // A source without the payee's TIN: the note says why. + let none = |_: PartyId| -> Result { + Err("the TIN vault is locked, \"for now\"".into()) + }; + let locked = vendor_1099_worksheet(ledger, &s, 2026, None, Some(&none)).unwrap(); + assert_eq!( + vendor_1099_csv(&locked, true).lines().nth(1).unwrap(), + format!( + "{},\"Acme, \"\"Design\"\"\",1099-NEC,2500.00,true,true,9105,,,\"the TIN vault is locked, \"\"for now\"\"\"", + b.vendor + ) + ); + assert_eq!(vendor_1099_csv_name(2026, false), "1099-worksheet-2026.csv"); + assert_eq!( + vendor_1099_csv_name(2026, true), + "1099-worksheet-2026-with-tins.csv" + ); + // A year whose threshold isn't known is refused, never guessed; given + // one, it is used. + let far = vendor_1099_worksheet(ledger, &s, 2031, None, None); + assert!(far.is_err(), "{far:?}"); + let given = vendor_1099_worksheet(ledger, &s, 2031, Some(Decimal::from(2100)), None).unwrap(); + assert_eq!(given.threshold, Decimal::from(2100)); + assert!( + given + .notes + .iter() + .any(|n| n.contains("indexed for inflation after 2026; confirm the 2031 amount")) + ); +} + +/// Small books with no members: a checking account and rent, one rent +/// payment, and whatever the test files. +fn plain_books(dir: &Path) -> (Store, u64) { + let mut s = Store::create(&dir.join("books-data"), Currency::USD).unwrap(); + for (code, name, ty) in [ + ("1000", "Checking", AccountType::Asset), + ("3000", "Capital", AccountType::Equity), + ("6000", "Rent", AccountType::Expense), + ] { + s.add_account(code, name, ty, None, false).unwrap(); + } + let rent = post( + &mut s, + "2026-03-01", + "March rent", + &[ + ("6000", Side::Debit, "1200.00"), + ("1000", Side::Credit, "1200.00"), + ], + ); + (s, rent) +} + +#[test] +fn files_of_the_same_name_each_get_their_own_place() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, rent) = plain_books(dir.path()); + // Two receipts named alike on one entry, the second in other case; two + // documents named alike in one category. + s.attach_receipt( + Some(rent), + "r.pdf", + "application/pdf", + b"%PDF-1.4 one", + None, + ) + .unwrap(); + s.attach_receipt( + Some(rent), + "R.pdf", + "application/pdf", + b"%PDF-1.4 two", + None, + ) + .unwrap(); + s.file_document( + "legal", + "deed.pdf", + "application/pdf", + b"%PDF-1.4 deed one", + None, + ) + .unwrap(); + s.file_document( + "legal", + "deed.pdf", + "application/pdf", + b"%PDF-1.4 deed two", + None, + ) + .unwrap(); + let company = company(false); + let cash = cash(); + let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); + let receipt = |suffix: &str| format!("receipts/2026-03-01-entry-{rent}-{suffix}"); + let all = names(&p); + for name in [ + receipt("r.pdf"), + receipt("R (2).pdf"), + "documents/legal/deed.pdf".to_string(), + "documents/legal/deed (2).pdf".to_string(), + ] { + assert!(all.contains(&name.as_str()), "{name} in {all:?}"); + } + assert_eq!(text(&p, &receipt("r.pdf")), "%PDF-1.4 one"); + assert_eq!(text(&p, &receipt("R (2).pdf")), "%PDF-1.4 two"); + assert_eq!( + text(&p, "documents/legal/deed (2).pdf"), + "%PDF-1.4 deed two" + ); + // The index names each receipt as it is stored. + let index = text(&p, "receipts/index.csv"); + assert!( + index.contains(&receipt("r.pdf")) && index.contains(&receipt("R (2).pdf")), + "{index}" + ); + assert_eq!(p.receipt_count, 2); + assert_zip_is_the_files(&p); + // And the README says the documents are there. + assert!(text(&p, "README.txt").contains("documents/:")); +} + +#[test] +fn the_readme_names_only_what_the_package_holds() { + let dir = tempfile::tempdir().unwrap(); + let (mut s, rent) = plain_books(dir.path()); + s.attach_receipt( + Some(rent), + "r.pdf", + "application/pdf", + b"%PDF-1.4 one", + None, + ) + .unwrap(); + let company = company(false); + let cash = cash(); + let p = build_package(&s, &settings(&company, &cash), &request(true, None)).unwrap(); + let readme = text(&p, "README.txt"); + assert!(!readme.contains("documents/"), "no documents: {readme}"); + assert!(readme.contains("1099-worksheet-2026.csv:"), "{readme}"); + assert!(names(&p).contains(&"1099-worksheet-2026.csv")); + // A year whose 1099 threshold isn't known: the worksheet is left out, + // and the README says what is there instead. + let mut late = request(true, None); + late.as_of = d("2031-06-30"); + let p = build_package(&s, &settings(&company, &cash), &late).unwrap(); + let readme = text(&p, "README.txt"); + assert!( + names(&p).contains(&"1099-worksheet-2031-NOT-INCLUDED.txt"), + "{:?}", + names(&p) + ); + assert!( + readme.contains("1099-worksheet-2031-NOT-INCLUDED.txt:"), + "{readme}" + ); + assert!(!readme.contains("1099-worksheet-2031.csv"), "{readme}"); +} + +#[test] +fn a_single_members_readme_promises_no_totals_row() { + let dir = tempfile::tempdir().unwrap(); + let (s, _) = plain_books(dir.path()); + let mut solo = company(false); + solo.members = vec![Member { + name: "Solo Owner".into(), + share: Decimal::ONE, + capital_account: "3000".into(), + distribution_account: None, + files_jointly_with: None, + }]; + let cash = cash(); + let p = build_package(&s, &settings(&solo, &cash), &request(false, None)).unwrap(); + let readme = text(&p, "README.txt"); + assert!(readme.contains("k1-members.csv:"), "{readme}"); + assert!(!readme.contains("the last row totals"), "{readme}"); + let k1 = text(&p, "k1-members.csv"); + assert_eq!(k1.lines().count(), 2, "a header and the one member: {k1}"); + // Two members: the totals row, and the README says so. + let two = company(true); + let b = books(dir.path().join("two").as_path()); + let p = build_package(&b.store, &settings(&two, &cash), &request(false, None)).unwrap(); + assert!(text(&p, "README.txt").contains("the last row totals")); +} diff --git a/crates/ledger-store/src/exports.rs b/crates/ledger-store/src/exports.rs new file mode 100644 index 0000000..4adc678 --- /dev/null +++ b/crates/ledger-store/src/exports.rs @@ -0,0 +1,373 @@ +//! Files on their way out (plan 4.7, D-29): a file the server makes for an +//! agent (the CPA package, the 1099 worksheet, a W-9) is kept here a +//! while, for the token that asked for it, until `wanderbooks download` +//! fetches it. +//! +//! ```text +//! GOAL: an agent hands the human a file of any size the books make, +//! without its bytes passing through the agent's context or naming a +//! path on the server. +//! SOURCE: plan 4.7 (`download_link`: "valid 10 minutes, access-logged"); +//! D-29 ("their files are made by the server and handed over by a +//! command, `wanderbooks download`"). +//! INVARIANTS: +//! 1. An export is kept for one token, under an id of no digits (the +//! access log masks what could be a TIN), and lasts EXPORT_TTL; only +//! that token fetches it, as often as it likes until it lapses. +//! 2. It is written aside and renamed whole: a fetch gets the whole file +//! or none, and its bytes hash to the SHA-256 kept with it. +//! 3. Exports live in the book's `exports/` folder (0700, files 0600); +//! lapsed ones are swept when the books open, whenever one is made, +//! and by the server's sweeper. A token has at most MAX_LIVE. +//! 4. No error names a path. +//! ``` + +use crate::journal::sha256_hex; +use crate::uploads::{file_name_problem, mime_problem}; +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// The exports' folder, in a book's folder. +pub const EXPORTS_DIR: &str = "exports"; +/// How long an export is kept, seconds. +pub const EXPORT_TTL: i64 = 15 * 60; +/// The most exports one token may have kept at once (INVARIANT 3). +pub const MAX_LIVE: usize = 16; + +/// What an export is (INVARIANT 1): its bytes are beside it. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Export { + /// Its id: `ex_` and 32 letters `a` to `p`. + pub id: String, + /// The token it was made for. + pub token: String, + /// The file's name. + pub file_name: String, + /// Its MIME type. + pub mime: String, + /// Its size. + pub size: u64, + /// Its SHA-256, hex. + pub sha256: String, + /// When it lapses (Unix seconds). + pub expires: i64, +} + +/// Why an export can't be kept or fetched. Never names a path. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum ExportError { + /// No such export, or it lapsed. + #[error("there is no export {0:?}, or it lapsed (an export is kept 15 minutes): make it again")] + Unknown(String), + /// Another token's. + #[error("that export was made for another token")] + NotYours, + /// Kept, but not whole (changed on the disk). + #[error("that export was damaged on the disk: make it again")] + Damaged, + /// A request that can't make one. + #[error("{0}")] + Bad(String), + /// The disk refused. + #[error("the export couldn't be kept: {0}")] + Io(std::io::ErrorKind), +} + +impl From for ExportError { + fn from(e: std::io::Error) -> Self { + ExportError::Io(e.kind()) + } +} + +/// A book's exports. +#[derive(Debug, Clone)] +pub struct Exports { + dir: PathBuf, + /// Held while exports are made or swept (INVARIANT 3). + lock: std::sync::Arc>, +} + +/// `ex_` and 32 letters `a` to `p`, a letter a nibble (INVARIANT 1). +pub fn is_id(id: &str) -> bool { + id.len() == 3 + 32 + && id.starts_with("ex_") + && id[3..].bytes().all(|b| (b'a'..=b'p').contains(&b)) +} + +fn new_id(raw: &[u8; 16]) -> String { + let mut id = String::from("ex_"); + for b in raw { + id.push(char::from(b'a' + (b >> 4))); + id.push(char::from(b'a' + (b & 0x0f))); + } + id +} + +fn private_new(path: &Path) -> std::io::Result { + let mut o = std::fs::OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o.open(path) +} + +impl Exports { + /// The exports of the book in `book_dir` (its folder made, private, if + /// missing), lapsed ones swept. + pub fn open(book_dir: &Path, now: i64) -> Result { + let dir = crate::root::private_book_folder(book_dir, EXPORTS_DIR) + .map_err(|e| ExportError::Bad(format!("the exports can't be kept: {e}")))?; + let exports = Self { + dir, + lock: Default::default(), + }; + exports.sweep(now); + Ok(exports) + } + + fn locked(&self) -> std::sync::MutexGuard<'_, ()> { + self.lock.lock().unwrap_or_else(|e| e.into_inner()) + } + + fn path(&self, id: &str, ext: &str) -> PathBuf { + self.dir.join(format!("{id}.{ext}")) + } + + /// Keep `bytes` as `file_name` for `token` (INVARIANTS 1-3). + pub fn put( + &self, + token: &str, + file_name: &str, + mime: &str, + bytes: &[u8], + now: i64, + ) -> Result { + if let Some(why) = file_name_problem(file_name).or_else(|| mime_problem(mime)) { + return Err(ExportError::Bad(why)); + } + let _held = self.locked(); + self.sweep_held(now); + if self.live_for(token, now) >= MAX_LIVE { + return Err(ExportError::Bad(format!( + "this agent has {MAX_LIVE} exports kept: each lapses 15 minutes after it was made; make this one again then" + ))); + } + let mut raw = [0u8; 16]; + getrandom::getrandom(&mut raw) + .map_err(|_| ExportError::Bad("the system gave no randomness".into()))?; + let export = Export { + id: new_id(&raw), + token: token.to_string(), + file_name: file_name.to_string(), + mime: mime.to_string(), + size: bytes.len() as u64, + sha256: sha256_hex(bytes), + expires: now + EXPORT_TTL, + }; + // The bytes first, then the record that names them: each written + // aside and renamed (INVARIANT 2). + for (ext, content) in [ + ("bin", bytes.to_vec()), + ( + "json", + serde_json::to_vec(&export).expect("an export serializes"), + ), + ] { + let aside = self.path(&export.id, &format!("{ext}.new")); + let mut f = private_new(&aside)?; + f.write_all(&content)?; + f.sync_all()?; + drop(f); + std::fs::rename(&aside, self.path(&export.id, ext))?; + } + Ok(export) + } + + /// Export `id` for `token` and its bytes, checked (INVARIANTS 1, 2). + pub fn get(&self, id: &str, token: &str, now: i64) -> Result<(Export, Vec), ExportError> { + if !is_id(id) { + return Err(ExportError::Unknown(id.chars().take(40).collect())); + } + let unknown = || ExportError::Unknown(id.to_string()); + let record = std::fs::read(self.path(id, "json")).map_err(|_| unknown())?; + let export: Export = serde_json::from_slice(&record).map_err(|_| unknown())?; + if export.expires <= now { + return Err(unknown()); + } + if export.token != token { + return Err(ExportError::NotYours); + } + let bytes = std::fs::read(self.path(id, "bin")).map_err(|_| unknown())?; + if bytes.len() as u64 != export.size || sha256_hex(&bytes) != export.sha256 { + return Err(ExportError::Damaged); + } + Ok((export, bytes)) + } + + /// How many exports `token` has kept at `now`. + fn live_for(&self, token: &str, now: i64) -> usize { + let Ok(entries) = std::fs::read_dir(&self.dir) else { + return 0; + }; + entries + .flatten() + .filter(|e| e.file_name().to_string_lossy().ends_with(".json")) + .filter_map(|e| std::fs::read(e.path()).ok()) + .filter_map(|b| serde_json::from_slice::(&b).ok()) + .filter(|x| x.token == token && x.expires > now) + .count() + } + + /// Sweep away every export that lapsed by `now`, and anything left + /// without one (INVARIANT 3). + pub fn sweep(&self, now: i64) { + let _held = self.locked(); + self.sweep_held(now); + } + + fn sweep_held(&self, now: i64) { + let Ok(entries) = std::fs::read_dir(&self.dir) else { + return; + }; + for e in entries.flatten() { + let name = e.file_name().to_string_lossy().to_string(); + let Some((id, _)) = name.split_once('.') else { + let _ = std::fs::remove_file(e.path()); + continue; + }; + let lapsed = std::fs::read(self.path(id, "json")) + .ok() + .and_then(|b| serde_json::from_slice::(&b).ok()) + .is_none_or(|x| x.expires <= now); + if lapsed { + let _ = std::fs::remove_file(e.path()); + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const NOW: i64 = 1_800_000_000; + + #[test] + fn an_export_is_fetched_by_its_token_until_it_lapses() { + let dir = tempfile::tempdir().unwrap(); + let x = Exports::open(dir.path(), NOW).unwrap(); + let bytes = b"PK cpa package".to_vec(); + let e = x + .put("tok_a", "cpa-2026.zip", "application/zip", &bytes, NOW) + .unwrap(); + assert!(is_id(&e.id)); + assert!(!e.id[3..].bytes().any(|b| b.is_ascii_digit())); + assert_eq!(e.expires, NOW + EXPORT_TTL); + assert_eq!(e.sha256, sha256_hex(&bytes)); + // As often as it likes, by its own token only. + for _ in 0..2 { + let (got, b) = x.get(&e.id, "tok_a", NOW + 1).unwrap(); + assert_eq!((got, b), (e.clone(), bytes.clone())); + } + assert_eq!( + x.get(&e.id, "tok_b", NOW).unwrap_err(), + ExportError::NotYours + ); + assert!(matches!( + x.get(&e.id, "tok_a", NOW + EXPORT_TTL).unwrap_err(), + ExportError::Unknown(_) + )); + for bad in [ + "../../journal", + "ex_", + "up_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "", + ] { + assert!(matches!( + x.get(bad, "tok_a", NOW).unwrap_err(), + ExportError::Unknown(_) + )); + } + // Swept once lapsed, nothing left. + x.sweep(NOW + EXPORT_TTL); + assert_eq!( + std::fs::read_dir(dir.path().join(EXPORTS_DIR)) + .unwrap() + .count(), + 0 + ); + } + + #[test] + fn a_damaged_export_is_never_handed_over() { + let dir = tempfile::tempdir().unwrap(); + let x = Exports::open(dir.path(), NOW).unwrap(); + let e = x + .put("t", "w9.pdf", "application/pdf", b"%PDF-1.4 W-9", NOW) + .unwrap(); + let bin = dir.path().join(EXPORTS_DIR).join(format!("{}.bin", e.id)); + std::fs::write(&bin, b"%PDF-1.4 W-8").unwrap(); + assert_eq!(x.get(&e.id, "t", NOW).unwrap_err(), ExportError::Damaged); + std::fs::write(&bin, b"%PDF").unwrap(); + assert_eq!(x.get(&e.id, "t", NOW).unwrap_err(), ExportError::Damaged); + } + + #[test] + fn a_token_keeps_only_so_many_and_names_are_plain() { + let dir = tempfile::tempdir().unwrap(); + let x = Exports::open(dir.path(), NOW).unwrap(); + for _ in 0..MAX_LIVE { + x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap(); + } + assert!(matches!( + x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap_err(), + ExportError::Bad(_) + )); + x.put("u", "a.csv", "text/csv", b"x", NOW).unwrap(); + // Lapsed ones don't count: making one sweeps them. + x.put("t", "a.csv", "text/csv", b"x", NOW + EXPORT_TTL) + .unwrap(); + assert_eq!( + std::fs::read_dir(dir.path().join(EXPORTS_DIR)) + .unwrap() + .count(), + 2, + "every lapsed one swept (both tokens'): the new one kept, a record and its bytes" + ); + for (name, mime) in [("../x.zip", "application/zip"), ("x.zip", "zip")] { + assert!(matches!( + x.put("u", name, mime, b"x", NOW).unwrap_err(), + ExportError::Bad(_) + )); + } + } + + #[cfg(unix)] + #[test] + fn its_folder_and_files_are_private_and_strays_go() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + let x = Exports::open(dir.path(), NOW).unwrap(); + let e = x.put("t", "a.csv", "text/csv", b"x", NOW).unwrap(); + let folder = dir.path().join(EXPORTS_DIR); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&folder), 0o700); + for ext in ["json", "bin"] { + assert_eq!(mode(&folder.join(format!("{}.{ext}", e.id))), 0o600); + } + // A file left half made, or with no record, is swept. + std::fs::write(folder.join("ex_x.bin.new"), b"half").unwrap(); + std::fs::write(folder.join("stray"), b"x").unwrap(); + let _ = Exports::open(dir.path(), NOW).unwrap(); + let mut left: Vec = std::fs::read_dir(&folder) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + left.sort(); + assert_eq!(left, [format!("{}.bin", e.id), format!("{}.json", e.id)]); + } +} diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index f69754e..5e1806a 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -23,17 +23,19 @@ pub mod auth; pub mod blob; pub mod chain; pub mod error; +pub mod exports; pub mod format; pub mod journal; pub mod meta; +pub mod ops; pub mod passphrase; pub mod reconcilable; pub mod root; +pub mod saving; pub mod schema; pub mod snapshot; pub mod store; pub mod uploads; -pub mod viewer; pub use blob::BlobStore; pub use error::StoreError; diff --git a/crates/ledger-store/src/ops.rs b/crates/ledger-store/src/ops.rs new file mode 100644 index 0000000..3c32112 --- /dev/null +++ b/crates/ledger-store/src/ops.rs @@ -0,0 +1,307 @@ +//! The viewer's side channel (plan 3.6): what the agent asks the viewer +//! to show (`books.show`), and whether a viewer has the books open. Not +//! bookkeeping, so never journaled, but kept with the books, in their own +//! `ops/` folder: the server writes; the viewer, which never writes, reads. +//! +//! ```text +//! GOAL: the agent can move a viewer that has the books open, and know +//! whether one has, without the viewer writing anything. +//! SOURCE: plan 3.6 ("the server appends {seq, ts, target} to +//! ops/show.jsonl (bounded, rotated, not journaled)"; "the GUI holds +//! a shared flock on ops/viewer.lock, opened read-only. The server +//! probes the lock to report viewer.open"). +//! INVARIANTS: +//! 1. A show request is a line of `ops/show.jsonl`, `{seq, at, target}`, +//! its seq one more than the last; the file holds at most KEPT lines +//! and is replaced whole (written aside, renamed), so a reader sees +//! the old file or the new one, each line whole. +//! 2. `ops/` (0700) and `ops/viewer.lock` (0600) are made by the server +//! (`prepare`), never by the viewer; a request only navigates, so a +//! stray writer can do no more than move the view. +//! 3. A viewer is open while some process holds a shared lock on +//! `ops/viewer.lock`; the viewer takes it through a read-only +//! descriptor, and `viewer_open` probes it without taking it. +//! ``` + +use serde::{Deserialize, Serialize}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +/// The books' side-channel folder, in their own folder. +pub const OPS_DIR: &str = "ops"; +/// The show requests (INVARIANT 1). +pub const SHOW_FILE: &str = "show.jsonl"; +/// The viewer's presence lock (INVARIANT 3). +pub const VIEWER_LOCK: &str = "viewer.lock"; +/// The most show requests kept. +pub const KEPT: usize = 32; + +/// Where to go. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "show", rename_all = "snake_case")] +pub enum ViewerTarget { + /// A top-level page: overview, cash, profit, transactions, reports, + /// accounts, assets, parties, documents, company, journal. + Page { + /// Page name. + page: String, + }, + /// One entry, with its lines, receipt, and history. + Entry { + /// Entry id. + entry_id: u64, + }, + /// An account's register, optionally for a date range. + Account { + /// Account code. + code: String, + /// First date, YYYY-MM-DD. + start: Option, + /// Last date, YYYY-MM-DD. + end: Option, + }, + /// A report, optionally for a date range. + Report { + /// profit_and_loss, balance_sheet, cash_flow, members_equity, + /// trial_balance, general_ledger, tax_summary, vendor_1099, + /// ar_aging, ap_aging, fixed_asset_register. + report: String, + /// First date, YYYY-MM-DD. + start: Option, + /// Last date, YYYY-MM-DD. + end: Option, + /// Split into columns: total, month, quarter, or year. + #[serde(default)] + columns: Option, + /// Compare with: prior_period or prior_year. + #[serde(default)] + compare: Option, + }, + /// A stored receipt or document. + Document { + /// Attachment id. + attachment_id: u64, + }, + /// One fixed asset: its identity, schedule, entries, and documents. + Asset { + /// Asset id. + asset_id: u64, + }, + /// One party: who they are, their roles, and everything the company + /// did with them. + Party { + /// Party id. + party_id: u64, + }, + /// One reconciliation: its account, with the account's statements and + /// reconciliations. + Reconciliation { + /// Reconciliation id. + id: u64, + }, + /// The transactions list, searched. + Search { + /// Search text: description, account, vendor, amount. + query: String, + }, +} + +/// A request for the viewer. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct ViewerCommand { + /// Increases with every request. + pub seq: u64, + /// When it was made, RFC 3339. + pub at: String, + /// Where to go. + pub target: ViewerTarget, +} + +/// The `ops/` folder of the books in `data_dir`. +pub fn ops_dir(data_dir: &Path) -> PathBuf { + data_dir.join(OPS_DIR) +} + +/// Make the books' `ops/` folder and the viewer's lock (INVARIANT 2): the +/// server does, when it opens the books and before it writes a request. +pub fn prepare(data_dir: &Path) -> std::io::Result<()> { + let dir = crate::root::private_book_folder(data_dir, OPS_DIR) + .map_err(|e| std::io::Error::other(e.to_string()))?; + let lock = dir.join(VIEWER_LOCK); + let mut o = std::fs::OpenOptions::new(); + o.write(true).create(true).truncate(false); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + o.open(&lock)?; + // Not the books: kept out of a books dir's version history (an older + // books dir lives in git), by a rule in the folder itself. + let ignore = dir.join(".gitignore"); + if !ignore.exists() { + std::fs::write(&ignore, "# The viewer's side channel: not the books.\n*\n")?; + } + Ok(()) +} + +/// Every request kept, oldest first; lines that aren't whole requests are +/// skipped. +pub fn requests(data_dir: &Path) -> Vec { + std::fs::read_to_string(ops_dir(data_dir).join(SHOW_FILE)) + .unwrap_or_default() + .lines() + .filter_map(|l| serde_json::from_str(l).ok()) + .collect() +} + +/// The latest request for the books in `data_dir`, if any. +pub fn read(data_dir: &Path) -> Option { + requests(data_dir).pop() +} + +/// Ask the viewer of the books in `data_dir` to show `target` +/// (INVARIANT 1). +pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result { + prepare(data_dir)?; + let mut kept = requests(data_dir); + let cmd = ViewerCommand { + seq: kept.last().map_or(1, |c| c.seq + 1), + at: chrono::Utc::now().to_rfc3339(), + target, + }; + kept.push(cmd.clone()); + let from = kept.len().saturating_sub(KEPT); + let mut text = String::new(); + for c in &kept[from..] { + text.push_str(&serde_json::to_string(c).map_err(std::io::Error::other)?); + text.push('\n'); + } + let dir = ops_dir(data_dir); + let aside = dir.join(format!(".{SHOW_FILE}.new")); + let _ = std::fs::remove_file(&aside); + let mut o = std::fs::OpenOptions::new(); + o.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + o.mode(0o600); + } + let mut f = o.open(&aside)?; + f.write_all(text.as_bytes())?; + f.sync_all()?; + drop(f); + std::fs::rename(&aside, dir.join(SHOW_FILE))?; + Ok(cmd) +} + +/// A viewer's presence: a shared lock on `ops/viewer.lock`, through a +/// read-only descriptor, held while this lives (INVARIANT 3). +#[derive(Debug)] +pub struct Presence(#[allow(dead_code)] std::fs::File); + +impl Presence { + /// Take it for the books in `data_dir`, if the server made the lock + /// (None until it has: the viewer makes nothing). + pub fn hold(data_dir: &Path) -> Option { + let f = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)).ok()?; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SAFETY: flock on a descriptor this function owns. + if unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_SH | libc::LOCK_NB) } != 0 { + return None; + } + } + Some(Presence(f)) + } +} + +/// Whether a viewer has the books in `data_dir` open (INVARIANT 3): some +/// process holds a shared lock on their `ops/viewer.lock`. +pub fn viewer_open(data_dir: &Path) -> bool { + let Ok(f) = std::fs::File::open(ops_dir(data_dir).join(VIEWER_LOCK)) else { + return false; + }; + #[cfg(unix)] + { + use std::os::fd::AsRawFd; + // SAFETY: flock on a descriptor this function owns; a lock taken + // is let go at once (and with the descriptor anyway). + let free = unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) } == 0; + if free { + // SAFETY: as above. + unsafe { libc::flock(f.as_raw_fd(), libc::LOCK_UN) }; + } + !free + } + #[cfg(not(unix))] + { + drop(f); + false + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn requests_count_up_and_only_so_many_are_kept() { + let dir = tempfile::tempdir().unwrap(); + assert_eq!(read(dir.path()), None); + let a = request(dir.path(), ViewerTarget::Entry { entry_id: 7 }).unwrap(); + let b = request( + dir.path(), + ViewerTarget::Search { + query: "adobe".into(), + }, + ) + .unwrap(); + assert_eq!((a.seq, b.seq), (1, 2)); + assert_eq!(read(dir.path()), Some(b)); + for n in 0..(KEPT as u64 + 5) { + request(dir.path(), ViewerTarget::Entry { entry_id: n }).unwrap(); + } + let kept = requests(dir.path()); + assert_eq!(kept.len(), KEPT); + assert_eq!(kept.last().unwrap().seq, 2 + KEPT as u64 + 5); + assert!(kept.windows(2).all(|w| w[1].seq == w[0].seq + 1)); + // A torn line (an old writer's) is skipped, never the whole file. + let file = ops_dir(dir.path()).join(SHOW_FILE); + let mut text = std::fs::read_to_string(&file).unwrap(); + text.push_str("{\"seq\": 99, \"at\""); + std::fs::write(&file, text).unwrap(); + assert_eq!(requests(dir.path()).len(), KEPT); + } + + #[cfg(unix)] + #[test] + fn the_server_makes_the_folder_and_the_viewer_only_reads_it() { + use std::os::unix::fs::PermissionsExt; + let dir = tempfile::tempdir().unwrap(); + // Nothing made: no presence, and taking it makes nothing. + assert!(Presence::hold(dir.path()).is_none()); + assert!(!ops_dir(dir.path()).exists()); + assert!(!viewer_open(dir.path())); + prepare(dir.path()).unwrap(); + let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; + assert_eq!(mode(&ops_dir(dir.path())), 0o700); + assert_eq!(mode(&ops_dir(dir.path()).join(VIEWER_LOCK)), 0o600); + assert!(!viewer_open(dir.path())); + let held = Presence::hold(dir.path()).unwrap(); + assert!(viewer_open(dir.path())); + // Two viewers at once share it. + let second = Presence::hold(dir.path()).unwrap(); + drop(held); + assert!(viewer_open(dir.path())); + drop(second); + assert!(!viewer_open(dir.path())); + // Made again, nothing changes. + prepare(dir.path()).unwrap(); + assert!(!viewer_open(dir.path())); + // Kept out of a books dir's version history by a rule of its own. + let rule = std::fs::read_to_string(ops_dir(dir.path()).join(".gitignore")).unwrap(); + assert!(rule.lines().any(|l| l == "*"), "{rule}"); + } +} diff --git a/crates/ledger-store/src/saving.rs b/crates/ledger-store/src/saving.rs new file mode 100644 index 0000000..b3becfb --- /dev/null +++ b/crates/ledger-store/src/saving.rs @@ -0,0 +1,164 @@ +//! Saving a copy of a file for the human (`wanderbooks download`, the +//! viewer's "Save a copy" and exports): always a new file, whole. +//! +//! ```text +//! GOAL: a copy saved for the human never replaces a file of theirs and +//! never leaves part of itself under the name. +//! INVARIANTS: +//! 1. The copy goes under `named`, or `named (1)`, `named (2)`... the +//! first free, else `fallback` (a folder that takes no such name), +//! never over a file. +//! 2. It is written to a hidden file in the same folder and linked in +//! whole; on a file system without links it is written in place, +//! new, and removed if it can't be finished. The hidden file is gone +//! whatever happens (a killed process excepted). +//! 3. A failure says why: the folder's own error, not "no free name". +//! ``` + +use std::path::PathBuf; + +/// Save `body` in `folder` as `named`, or `named (1)`, `named (2)`... the +/// first free; `fallback` if the folder takes no such name. Written aside +/// (a hidden file) and linked in whole, so the name never holds part of +/// it; never over another file. The path it went to. +pub fn save_new( + folder: &std::path::Path, + named: &str, + fallback: &str, + body: &[u8], +) -> Result { + use std::io::Write; + let fail = |what: &std::path::Path, e: std::io::Error| { + format!("can't write {}: {}", what.display(), e.kind()) + }; + let nanos = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map_or(0, |d| d.subsec_nanos()); + let aside = folder.join(format!(".wanderbooks-{}-{nanos}.part", std::process::id())); + let mut f = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&aside) + .map_err(|e| fail(folder, e))?; + if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) { + drop(f); + let _ = std::fs::remove_file(&aside); + return Err(fail(folder, e)); + } + drop(f); + let candidates = |name: &str| { + let (stem, ext) = match name.rsplit_once('.') { + Some((s, e)) if !s.is_empty() => (s.to_string(), format!(".{e}")), + _ => (name.to_string(), String::new()), + }; + let name = name.to_string(); + (0..1000).map(move |n| { + if n == 0 { + name.clone() + } else { + format!("{stem} ({n}){ext}") + } + }) + }; + let mut outcome = Err(format!("{} has no free name for it", folder.display())); + // Why the last name tried was refused, if not for being taken: what + // the human is told when no name is left (a full disk, not a name). + let mut last: Option = None; + 'names: for name in [named, fallback] { + for candidate in candidates(name) { + let path = folder.join(&candidate); + match std::fs::hard_link(&aside, &path) { + Ok(()) => { + outcome = Ok(path); + break 'names; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + // A file system with no links (FAT): written in place, never + // over another file, removed if it can't be finished. + Err(e) + if matches!( + e.kind(), + std::io::ErrorKind::Unsupported | std::io::ErrorKind::PermissionDenied + ) => + { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + { + Ok(mut f) => { + if let Err(e) = f.write_all(body).and_then(|()| f.sync_all()) { + drop(f); + let _ = std::fs::remove_file(&path); + outcome = Err(fail(&path, e)); + last = None; + } else { + outcome = Ok(path); + } + break 'names; + } + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => {} + Err(e) => { + last = Some(e); + continue 'names; + } + } + } + // A name this folder won't take (too long, a character it + // refuses): the next name. + Err(e) => { + last = Some(e); + continue 'names; + } + } + } + } + let _ = std::fs::remove_file(&aside); + match (outcome, last) { + (Err(_), Some(e)) => Err(fail(folder, e)), + (outcome, _) => outcome, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn a_saved_copy_is_new_and_whole_beside_what_is_there() { + let dir = tempfile::tempdir().unwrap(); + let d = dir.path(); + std::fs::write(d.join("r.pdf"), b"theirs").unwrap(); + let first = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap(); + assert_eq!(first, d.join("r (1).pdf")); + let second = save_new(d, "r.pdf", "attachment-7", b"ours").unwrap(); + assert_eq!(second, d.join("r (2).pdf")); + assert_eq!(std::fs::read(d.join("r.pdf")).unwrap(), b"theirs"); + assert_eq!(std::fs::read(&first).unwrap(), b"ours"); + // A name the folder won't take: the fallback. + let long = "x".repeat(300); + assert_eq!( + save_new(d, &long, "attachment-7", b"ours").unwrap(), + d.join("attachment-7") + ); + // Nothing aside is left. + let mut names: Vec = std::fs::read_dir(d) + .unwrap() + .map(|e| e.unwrap().file_name().to_string_lossy().into_owned()) + .collect(); + names.sort(); + assert_eq!(names, ["attachment-7", "r (1).pdf", "r (2).pdf", "r.pdf"]); + // A folder that can't be written: refused, nothing made. + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let ro = d.join("ro"); + std::fs::create_dir(&ro).unwrap(); + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o500)).unwrap(); + if std::fs::write(ro.join("probe"), b"").is_err() { + assert!(save_new(&ro, "r.pdf", "attachment-7", b"ours").is_err()); + } + std::fs::set_permissions(&ro, std::fs::Permissions::from_mode(0o700)).unwrap(); + } + } +} diff --git a/crates/ledger-store/src/viewer.rs b/crates/ledger-store/src/viewer.rs deleted file mode 100644 index a676875..0000000 --- a/crates/ledger-store/src/viewer.rs +++ /dev/null @@ -1,232 +0,0 @@ -//! Telling a running viewer what to show. -//! -//! The agent can ask the viewer (`ledger-gui`) to open an entry, an -//! account, a report, a document, or a search. That's not bookkeeping, so -//! it never touches the books: the request is a small JSON file outside the -//! data directory, in the user's runtime directory, named for the books it -//! belongs to. The viewer watches it and navigates; the server writes it. -//! A request carries a sequence number so the same one never applies -//! twice, and a time so a viewer started later ignores stale ones. - -use serde::{Deserialize, Serialize}; -use std::path::{Path, PathBuf}; - -/// Where to go. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "show", rename_all = "snake_case")] -pub enum ViewerTarget { - /// A top-level page: overview, cash, profit, transactions, reports, - /// accounts, assets, parties, documents, company, journal. - Page { - /// Page name. - page: String, - }, - /// One entry, with its lines, receipt, and history. - Entry { - /// Entry id. - entry_id: u64, - }, - /// An account's register, optionally for a date range. - Account { - /// Account code. - code: String, - /// First date, YYYY-MM-DD. - start: Option, - /// Last date, YYYY-MM-DD. - end: Option, - }, - /// A report, optionally for a date range. - Report { - /// profit_and_loss, balance_sheet, cash_flow, members_equity, - /// trial_balance, general_ledger, tax_summary, vendor_1099, - /// ar_aging, ap_aging, fixed_asset_register. - report: String, - /// First date, YYYY-MM-DD. - start: Option, - /// Last date, YYYY-MM-DD. - end: Option, - /// Split into columns: total, month, quarter, or year. - #[serde(default)] - columns: Option, - /// Compare with: prior_period or prior_year. - #[serde(default)] - compare: Option, - }, - /// A stored receipt or document. - Document { - /// Attachment id. - attachment_id: u64, - }, - /// One fixed asset: its identity, schedule, entries, and documents. - Asset { - /// Asset id. - asset_id: u64, - }, - /// One party: who they are, their roles, and everything the company - /// did with them. - Party { - /// Party id. - party_id: u64, - }, - /// One reconciliation: its account, with the account's statements and - /// reconciliations. - Reconciliation { - /// Reconciliation id. - id: u64, - }, - /// The transactions list, searched. - Search { - /// Search text: description, account, vendor, amount. - query: String, - }, -} - -/// A request for the viewer. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -pub struct ViewerCommand { - /// Increases with every request. - pub seq: u64, - /// When it was made, RFC 3339. - pub at: String, - /// Where to go. - pub target: ViewerTarget, -} - -/// The command file for the books in `data_dir`: in `$XDG_RUNTIME_DIR` -/// (or the temp dir), under `wanderbooks/`, named by a hash of the data -/// directory's canonical path. -pub fn command_path(data_dir: &Path) -> PathBuf { - let canonical = data_dir - .canonicalize() - .unwrap_or_else(|_| data_dir.to_path_buf()); - let key = crate::journal::sha256_hex(canonical.to_string_lossy().as_bytes()); - let base = std::env::var_os("XDG_RUNTIME_DIR") - .map(PathBuf::from) - .unwrap_or_else(std::env::temp_dir); - base.join("wanderbooks") - .join(format!("viewer-{}.json", &key[..16])) -} - -/// Ask the viewer for `data_dir` to show `target`. Atomic (write then -/// rename), so the viewer never reads half a request. -pub fn request(data_dir: &Path, target: ViewerTarget) -> std::io::Result { - let path = command_path(data_dir); - if let Some(dir) = path.parent() { - private_folder(dir)?; - } - let seq = read(data_dir).map_or(1, |c| c.seq + 1); - let cmd = ViewerCommand { - seq, - at: chrono::Utc::now().to_rfc3339(), - target, - }; - let tmp = path.with_extension("json.tmp"); - std::fs::write( - &tmp, - serde_json::to_vec(&cmd).map_err(std::io::Error::other)?, - )?; - std::fs::rename(&tmp, &path)?; - Ok(cmd) -} - -/// The folder the requests go in: made private (0700) if missing, made -/// private again if ours; refused if it is a link or another account's -/// (in a shared temp folder anyone could have planted one, to have a -/// request written through it). -fn private_folder(dir: &Path) -> std::io::Result<()> { - #[cfg(unix)] - { - use std::os::unix::fs::{MetadataExt, PermissionsExt}; - if let Some(parent) = dir.parent() { - std::fs::create_dir_all(parent)?; - } - match std::fs::symlink_metadata(dir) { - Ok(meta) => { - // SAFETY: geteuid has no preconditions and can't fail. - let me = unsafe { libc::geteuid() }; - if !meta.is_dir() || meta.uid() != me { - return Err(std::io::Error::new( - std::io::ErrorKind::PermissionDenied, - "the viewer's request folder isn't this account's own", - )); - } - // Private again, whatever it was. - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; - } - Err(e) if e.kind() == std::io::ErrorKind::NotFound => { - std::fs::create_dir(dir)?; - std::fs::set_permissions(dir, std::fs::Permissions::from_mode(0o700))?; - } - Err(e) => return Err(e), - } - Ok(()) - } - #[cfg(not(unix))] - std::fs::create_dir_all(dir) -} - -/// The latest request for `data_dir`, if any. -pub fn read(data_dir: &Path) -> Option { - let bytes = std::fs::read(command_path(data_dir)).ok()?; - serde_json::from_slice(&bytes).ok() -} - -#[cfg(test)] -mod tests { - use super::*; - - /// A test's dir, removed when the test ends, pass or panic. - struct TestDir(std::path::PathBuf); - - impl Drop for TestDir { - fn drop(&mut self) { - let _ = std::fs::remove_dir_all(&self.0); - } - } - - #[test] - fn requests_round_trip_and_count_up() { - let dir = std::env::temp_dir().join(format!("wb-viewer-{}", std::process::id())); - std::fs::create_dir_all(&dir).unwrap(); - let _cleanup = TestDir(dir.clone()); - let a = request(&dir, ViewerTarget::Entry { entry_id: 7 }).unwrap(); - let b = request( - &dir, - ViewerTarget::Search { - query: "adobe".into(), - }, - ) - .unwrap(); - assert_eq!(b.seq, a.seq + 1); - assert_eq!( - read(&dir).unwrap().target, - ViewerTarget::Search { - query: "adobe".into() - } - ); - // Same books, same file; other books, another. - assert_eq!(command_path(&dir), command_path(&dir.join("."))); - #[cfg(unix)] - { - use std::os::unix::fs::PermissionsExt; - let base = std::env::temp_dir().join(format!("wb-viewer-priv-{}", std::process::id())); - let _ = std::fs::remove_dir_all(&base); - std::fs::create_dir_all(&base).unwrap(); - // Made private; made private again. - let folder = base.join("wanderbooks"); - private_folder(&folder).unwrap(); - let mode = |p: &Path| std::fs::metadata(p).unwrap().permissions().mode() & 0o777; - assert_eq!(mode(&folder), 0o700); - std::fs::set_permissions(&folder, std::fs::Permissions::from_mode(0o755)).unwrap(); - private_folder(&folder).unwrap(); - assert_eq!(mode(&folder), 0o700); - // A link planted in its place is refused. - let link = base.join("planted"); - std::os::unix::fs::symlink(&base, &link).unwrap(); - assert!(private_folder(&link).is_err()); - let _ = std::fs::remove_dir_all(&base); - } - assert_ne!(command_path(&dir), command_path(&std::env::temp_dir())); - let _ = std::fs::remove_file(command_path(&dir)); - } -} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 5e137ab..0d0ece5 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -196,9 +196,10 @@ settings-and-conventions section says how to use them. in the data root, on a socket in the root's private `run/` folder that only this account can connect to. Every request carries a bearer token live for that book (refused 401 without one, 403 with another book's). - Refusals at the door, tool calls, uploads and downloads are recorded - in the book's access log; when a record can't be written the book - stops taking requests (an upload or download is then refused, 503). + Refusals at the door, tool calls, uploads, downloads and exports are + recorded in the book's access log; when a record can't be written the + book stops taking requests (an upload, download or export is then + refused, 503). - `/b/{book}/mcp`: the book's MCP service. A request is at most 1 MiB. - `PUT /b/{book}/u/{upload}`: an upload's bytes (`books.begin_upload`), from the token that began it; what `wanderbooks upload --book B @@ -233,13 +234,25 @@ settings-and-conventions section says how to use them. (a HEAD is 405, unrecorded). Logged as action `download` with the attachment's id, a refusal at the door too. - `wanderbooks download --book B [--token NAME] ATTACHMENT --to DIR` - asks for it, checks the digest before saving, and saves it under the + - `GET /b/{book}/x/{export}`: a file the books made for an agent (the + CPA package, a 1099 worksheet, a W-9: a `HandedFile`, "Tools — the + TIN vault"), as `/a/` (the same door, the same headers, + `Repr-Digest` included, GET only), but only for the token it was made + for (403 `not_yours`), with no scope of its own to check (the tool + that made it checked its scope); 404 `no_such_export` (unknown or + lapsed), 503 `stopped`. An export's id is `ex_` and 32 letters + `a`-`p`; it is kept 15 minutes, fetched as often as wanted until + then, at most 16 per token, and swept like uploads. Logged as action + `export` with the export's id. + - `wanderbooks download --book B [--token NAME] ID --to DIR` takes an + attachment's id (a number, `/a/`) or an export's (`ex_…`, `/x/`), + checks the digest before saving, and saves it under the server's name (`filename*` first) made plain: separators and control characters made `_`, invisible (Unicode format) characters and leading dots and spaces dropped, at most 200 bytes with the extension kept. Beside a file of that name it is `name (1).ext`, and so on; - `attachment-N` if the folder takes no such name. It is written to a + `attachment-` or `export-` if the folder takes no such name. + It is written to a hidden file and hard-linked in: never a partial file under the real name, never over another file. @@ -339,15 +352,19 @@ seals a new vault record, every time, so none carries it; `idempotent_tools_change_nothing_when_repeated` calls each candidate twice to check). Every tool's `openWorldHint` is false: the books are a closed world, and no tool -reaches beyond them. `books.export_cpa_package` writes a file, so it is -not read-only. No tool carries `requiresUserInteraction` (owner decision +reaches beyond them. `books.export_cpa_package`, `books.vendor_1099` +and `books.get_w9` keep a file for the agent (an export) and record the +ones with TINs, so they are not read-only. No tool carries +`requiresUserInteraction` (owner decision 2026-10-16: no added prompts). `annotations_complete` pins all of this. The read-only hint is true in behaviour: `spec_mcp` checks, on every call, that a read-only tool leaves the journal, the contents of every file under the data dir (the database by every row of every table; SQLite's -wal and -shm files, which move pages and hold read marks on their own, aside), the vault and the settings file as they were -(`read_only_tools_change_nothing`). +(`read_only_tools_change_nothing`). `books.show` is read-only though it +writes the viewer's request into the books' `ops/` folder: that folder +is a side channel, never journaled, and moves only the view. **Failing closed.** Tool bodies run on blocking threads, one at a time on the store. If a request panics (an internal error), the journal may hold a @@ -538,18 +555,23 @@ through the policy. - `books.show { entry_id | account | report | attachment_id | asset | party | reconciliation | search | page, start?, end? }` → `{ shown, - note }`. Asks the human's viewer (`ledger-gui`) to open an entry, an - account register or a report (for start/end dates if given), a stored - file, an asset, a party (by id or name, resolved as in "Tools — - parties"), a reconciliation (by id: its account's page, with its - statements and reconciliations; viewer target `{show: "reconciliation", - id}`), a transactions search, or a page (`parties` among them). Exactly - one target; entries, accounts, attachments, assets, parties, and - reconciliations are checked to exist. Never touches the books: the request is a small JSON file in - the user's runtime directory (`$XDG_RUNTIME_DIR/wanderbooks/viewer-.json`), written atomically, watched by the viewer. A - viewer started within two minutes of a request opens it; older requests - are ignored. + viewer_open, note }`. Asks the human's viewer (`ledger-gui`) to open an + entry, an account register or a report (for start/end dates if given), + a stored file, an asset, a party (by id or name, resolved as in "Tools + — parties"), a reconciliation (by id: its account's page, with its + statements and reconciliations; viewer target `{show: + "reconciliation", id}`), a transactions search, or a page (`parties` + among them). Exactly one target; entries, accounts, attachments, + assets, parties, and reconciliations are checked to exist. Never + journaled: the request is a line `{seq, at, target}` of the books' own + `ops/show.jsonl` (at most 32 lines, replaced whole, so a reader sees + each line whole), which the viewer reads and never writes. The server + makes `ops/` and `ops/viewer.lock` when it opens the books (and on a + request); the viewer holds a shared lock on `ops/viewer.lock`, through + a read-only descriptor, while it has the books open, and + `viewer_open` says whether one does. The note says "The viewer shows + …", or "No viewer has these books open: ask the human to open it + (opened within a couple of minutes, it shows …)". ## Tools — integrity @@ -602,10 +624,10 @@ is on file and the TIN's last four; full TINs live only in the TIN vault is; tagging a vendor whose party isn't named, or clearing the tag while its party is named, is refused (drop or add the party with `parties` instead). An entry and its reversal always carry the same tags. -- `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` → +- `books.vendor_1099 { year, threshold?, include_tins?, csv? }` → `{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file, paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }], - missing_w9, notes, warning?, saved_csv? }`. For a year whose threshold + missing_w9, notes, warning?, csv? }`. For a year whose threshold wanderbooks doesn't know (after 2026 until the indexed amount is added) it refuses with what to do; pass `threshold` explicitly once the figure is confirmed. On an entry naming several parties, `paid` counts only @@ -616,10 +638,13 @@ is on file and the TIN's last four; full TINs live only in the TIN vault TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an EIN), and `tin_type`, read from the vault, or `tin_note` saying why not (not in the vault, the vault locked, a record whose last four or hash - disagree with the books); `warning` then says the output holds full - TINs, for the CPA only. This is the only tool output that ever holds a - full TIN. `save_csv_to` also writes the worksheet as CSV (TINs only with - `include_tins`) to a new file outside the books dir, mode 0600. + disagree with the books); `warning` then says "This worksheet holds + full TINs, for the CPA; keep it out of the books.". This is the only + tool output that ever holds a full TIN. `csv: true` (default false) + also hands the worksheet over as a CSV file, `csv` (a `HandedFile`, + "Tools — the TIN vault"), named `1099-worksheet-.csv`, or + `1099-worksheet--with-tins.csv` with `include_tins` (TINs only + then). ## Tools — parties @@ -788,7 +813,8 @@ and the CPA package carries them. missing. - **Never out:** no tool output, error, or log holds more than a TIN's last four, except `books.vendor_1099 { include_tins: true }`; the CPA - package carries them only in its zip, written outside the books dir. + package carries them only in its zip, handed over as an export (or + inline over stdio), never written to a path. The tool arguments that take a TIN never echo it, even when refused. - **TIN facts:** any party (any role) may have `tin` facts: `{ tin_type: "ssn" | "ein" | "itin", last4, vault_record?, purged? }`, @@ -822,15 +848,19 @@ and the CPA package carries them. and are refused when they don't (pass the new TIN, or purge). Clearing the facts (`clear: true`) is refused while the vault holds the party's TIN or form: purge first. -- `books.get_w9 { party, save_to }` → `{ party_id, path, file_name, - size_bytes, note }`. Writes the party's form from the vault to - `save_to`: a new file (an existing one is refused), in a folder that - exists, outside the books dir (where it really leads, every link - followed), mode 0600. Returns the path, never the contents. Journals - nothing. The same holds for `books.vendor_1099`'s `save_csv_to` with - `include_tins` and `books.export_cpa_package`'s `save_to` with TINs: a - file with full TINs never lands inside the books. Nor does one go in - from elsewhere: the server keeps, beside the vault, the SHA-256 of every +- `books.get_w9 { party }` → `{ party_id, file }`: the party's form from + the vault, handed over. Journals nothing. +- **Handed files** (D-29): no tool takes a path. A file the books make + for an agent (`books.get_w9`'s form, `books.vendor_1099`'s `csv`, + `books.export_cpa_package`'s zip) comes back as a `HandedFile { + file_name, mime_type, size_bytes, sha256, download?, expires_at?, + content_base64? }`. On books served by `wanderbooks mcp` it is an + export, kept 15 minutes for the token that asked (at most 16 per + token, swept like uploads), and `download` is the command that saves + it in a folder the human names: `wanderbooks download --book B + [--token NAME] ex_… --to ` (`GET /b/{book}/x/{export}`, + "Transports"). Over stdio it comes back inline as `content_base64`. + A file with full TINs never goes into the books: the server keeps, beside the vault, the SHA-256 of every file it read or wrote with full TINs (the W-9 `books.set_w9` reads, recorded once the call has passed every check, just before the vault keeps it, so a refused call marks no file; each of these outputs; each @@ -852,8 +882,7 @@ and the CPA package carries them. the same words whether or not one is that TIN, so the vault is compared against at most two candidates a filing (rules.md B-26). An altered copy of a binary file (a W-9 PDF re-saved or re-scanned), and a TIN the vault - doesn't hold written another way, are not detected: P2 slice 6 moves the - TIN outputs (`save_to`, `save_csv_to`) to the viewer, and P7 takes full + doesn't hold written another way, are not detected: P7 takes full TINs out of the agent's reach. - `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" | "purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?, @@ -1283,8 +1312,9 @@ entries. ## Tools — CPA handoff - `books.export_cpa_package { as_of?, cash_accounts?, financing_accounts?, - investing_accounts?, include_receipts?, include_tins?, save_to? }` → - `{ file_name, content_base64?, saved_to?, tins_included, tin_gaps, note }`. + investing_accounts?, include_receipts?, include_tins? }` → `{ file, + tins_included, tin_gaps, note }`: the zip, `cpa-package-.zip`, + handed over as `file` (a `HandedFile`, "Tools — the TIN vault"). **Receipts** (`include_receipts`, default true): `receipts/` holds every current file behind each posted entry of the fiscal year to date, filed on it or linked to it (`books.link_attachment`; a linked file counts as @@ -1293,16 +1323,16 @@ entries. entry, receipts excepted. **TINs** (`include_tins`, default true; DECISION, the owner, 2026-10-15): the K-1 worksheet and the 1099 worksheet carry every member's and - payee's full TIN from the vault, so the zip is written to `save_to` (a - new `.zip` path, or an existing folder it goes in as - `cpa-package-.zip`; outside the books dir, `exports/` included; - mode 0600) and the output is its path, never its bytes; without - `save_to` it is refused. A locked vault or a missing TIN never fails the + payee's full TIN from the vault; the zip, and each file in it with a + TIN, is recorded so that, given back to be filed, it is refused. A + locked vault or a missing TIN never fails the package: the row says why ("TIN not in the vault", "the TIN vault is locked", a record that disagrees with the books), the README lists it, and `tin_gaps` names each member and each reportable payee without one. - `include_tins: false`: last fours only, returned as `content_base64` - unless `save_to` is given. **K-1 worksheet** (`k1-members.csv`, with + `include_tins: false`: last fours only. `note` is "The package, with + last fours only.", or "The package, with full TINs, for the CPA.", + followed, with gaps, by "N TIN(s) are missing (tin_gaps): collect them + with books.set_tin or books.set_w9 and export again." **K-1 worksheet** (`k1-members.csv`, with `[[company.members]]`): one row per member, in order: `member, party_id, share, capital_account, tin, tin_type, tin_note` (without TINs: `tin_last4, tin_type`), then `beginning_capital, diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index 230ffcb..f19ae38 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -234,8 +234,9 @@ damn CPA package." - **Where:** `vault/` in the books dir, listed in `.gitignore`. Never in the hash-chained journal, the SQLite projection, git, an export other - than the 1099 worksheet and the CPA package (saved outside the books - dir), or anything the viewer draws. Nothing outside the vault holds + than the 1099 worksheet and the CPA package (handed to the agent as a + file to download, never saved in the books), or anything the viewer + draws. Nothing outside the vault holds more than the last four digits. - **Who:** any party, whatever its roles: every member (their K-1 needs it) and every 1099 payee. The books keep the party's TIN facts (type, @@ -267,8 +268,9 @@ damn CPA package." - **Use:** `books.vendor_1099` with `include_tins: true` and `books.export_cpa_package` (its K-1 worksheet, `k1-members.csv`, and its 1099 worksheet; `include_tins` defaults to true there) decrypt them. - The package is written only to `save_to`, outside the books dir, and - the tool returns its path; it is refused without one. The MCP server + The package is handed over as a file (an export the asking token + fetches with `wanderbooks download`, or inline over stdio); no tool + takes a path. The MCP server never returns a TIN in any other tool, log, or error. Tests check that no other output contains a nine-digit run. - **Books without a vault** (another machine, a restored backup) still @@ -284,16 +286,16 @@ damn CPA package." {party, tin, tin_type}` stores any party's TIN (a member's SSN, say) the same way and journals `party.tin_set`; a party whose W-9 facts name another last four is refused (a payee's new TIN comes with its W-9). - `books.get_w9 {party, save_to}` writes the form to a new file outside the - books dir and returns only the path. `books.purge_tin {party, reason, + `books.get_w9 {party}` hands the form over the same way. + `books.purge_tin {party, reason, confirmed?}` previews, then deletes the records (no key needed) and journals the purge as `party.w9_set` with `vault_record` cleared and `kept_at` saying when and why, and `party.tin_set` with `vault_record` cleared and `purged` saying when and why. `books.vendor_1099 {include_tins: true}` - is the one tool output with full TINs (with a warning; `save_csv_to` - writes the CSV outside the books dir); `books.export_cpa_package - {save_to}` writes them in its zip, outside the books dir, and returns - only the path. A key file inside the books dir is + is the one tool output with full TINs (with a warning; `csv: true` + also hands the worksheet over as a CSV file); + `books.export_cpa_package` carries them in its zip, handed over the + same way. A key file inside the books dir is refused. `tests/spec_vault.rs` scans every tool response, the server's log, and every file in the books dir outside `vault/` for the TINs in any formatting. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 1ff6d9c..c71a160 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -581,13 +581,15 @@ with `include_tins` (DECISION, the owner, 2026-10-15: for the CPA, when asked): a refusal that quotes its input masks any TIN in it (`parties::redact_full_tins`, checked by `refusals_never_echo_a_tin`), and a party text field refuses nine digits however they are separated -(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `save_csv_to`, and the CPA -package, whose K-1 and 1099 worksheets carry every member's and payee's -TIN by default) are written only outside the books dir (DECISION, P0 -review, 2026-10-16: so none can be filed back in as a receipt and read -out again; the server's import folders, refused then too, went with -`books.import_local_file` in P2 slice 5), and the package comes back -only as its path. No file holding a full TIN is filed into the books +(space, hyphen, dot, slash, underscore; `has_full_tin`). Files holding one (`books.get_w9`, `books.vendor_1099`'s +`csv` with `include_tins`, and the CPA package, whose K-1 and 1099 +worksheets carry every member's and payee's TIN by default) are never +written to a path: no tool takes one (D-29, P2 slice 6). Each is handed +over as an export the asking token fetches with `wanderbooks download` +(kept 15 minutes), or inline over stdio. (Until slice 6 they were +written only outside the books dir: DECISION, P0 review, 2026-10-16, so +none could be filed back in as a receipt and read out again.) No file +holding a full TIN is filed into the books (DECISION, P0 review round 2, 2026-10-16): the server keeps, beside the vault, the SHA-256 of every file it read or wrote with full TINs (the W-9 `books.set_w9` reads, recorded once the call has passed every check, just diff --git a/docs/testing.md b/docs/testing.md index f035ad5..7763d65 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -68,12 +68,12 @@ that would fail if it broke: | UTF-16 text without a byte-order mark is scanned like any text; a refused `books.set_w9` marks no file; a refused resource read never echoes a TIN | `tin_files.rs` (tests) `utf16_text_without_a_byte_order_mark_is_scanned_either_way_round`, `a_refused_set_w9_never_marks_its_file`, unit test `only_nul_interleaved_bytes_read_as_utf16_without_a_mark`; `spec_vault.rs` `resource_refusals_never_echo_a_tin` | | idempotentHint only where a repeat with the same arguments leaves the journal and the vault as they were; every tool's hints pinned in one table | `spec_vault.rs` `idempotent_tools_change_nothing_when_repeated`; `annotations_complete` | | books.settings lists every receipt-required and exempt account, uncut; a fresh company is "a new company"; the lint catches `just` however it ends and where the books are | `mcp_stdio.rs` `every_receipt_account_left_out_of_the_connect_text_is_in_books_settings`; `handbook.rs` `a_fresh_company_is_a_new_company`; `tool_text_lint_catches_helpers_and_locations` | -| A file with full TINs is never written inside the books, and never filed into the books: every file the server read or wrote with TINs (from any session, after a restart, out of a CPA package), by every filing tool | `mcp_stdio.rs` `tin_bearing_files_never_land_where_they_could_be_filed_back_in`; `tin_files.rs` (tests) `a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`; unit test `a_recorded_tin_file_is_refused_by_every_reader_of_the_registry` | +| A file with full TINs is never written to a path (handed over instead, D-29), and never filed into the books: every file the server read or wrote with TINs (from any session, after a restart, out of a CPA package), by every filing tool | `mcp_stdio.rs` `tin_bearing_files_are_refused_when_given_back_to_be_filed`; `spec_vault.rs` `a_package_is_handed_over_and_never_written`; `tin_files.rs` (tests) `a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`; unit test `a_recorded_tin_file_is_refused_by_every_reader_of_the_registry` | | Text filed into the books is refused when it holds a TIN the vault keeps (nine digits, 3-2-4 by hyphen, space or dot, 2-7 by hyphen or space, anywhere) or a stand-alone printed SSN or EIN (123-45-6789, 12-3456789), and only then: a bank export (routing numbers, long references, a date next to an amount) and a longer number holding an SSN shape go in; a locked vault leaves the printed shapes (rules.md B-26) | `tin_files.rs` (tests) `text_with_a_vault_tin_or_a_printed_one_is_refused_and_a_bank_export_is_not`; unit tests `text_holding_a_vault_tin_is_refused_in_every_rendering`, `a_printed_ssn_or_ein_is_refused`, `a_longer_number_holding_a_tin_shape_passes`, `an_ordinary_bank_export_passes`, `binary_bytes_are_not_scanned` | | Filing is no TIN oracle: text with more than two TIN-shaped numbers ending in a known TIN's last four (the books' TIN and W-9 facts, then the vault's own) is refused before the vault is opened, byte for byte alike whether one is the vault's TIN or none is, the vault open or locked, by every filing tool; one or two such numbers are compared against the vault, one number written many ways counts once, and other last fours never count (rules.md B-26) | `tin_files.rs` (tests) `text_probing_for_a_tin_is_refused_alike_by_every_filing_tool`; unit tests `text_probing_for_a_tin_is_refused_alike_whether_or_not_it_holds_it` (the vault never opened for a probe), `one_or_two_candidates_are_compared_against_the_vault` | | The connect text fits its budgets, its lead stands alone, it states the receipt rule enforced (exact threshold in the base currency, required and exempt accounts, reversals) and the parties rule in force, and the company name can't break it (control and format characters) | `handbook.rs` `instructions_budget`, `a_typical_company_gets_the_whole_connect_text`, `the_company_name_is_one_plain_line`; `receipt_policy.rs` `the_rule_told_is_the_rule_enforced`, `a_long_or_odd_account_list_stays_short_and_plain`; a new session is told the settings in force: `a_new_session_is_told_the_settings_in_force` | | No text the agent is given (whole tool JSON, resources, connect text, handbook, every error template and refusal) points at files, git, GitHub, repo docs, flags or helper commands; descriptions fit 700 characters (listed exceptions, P5) | `tool_text_lint`, `tool_descriptions_fit_700_characters`; `ledger-core` `an_unknown_year_threshold_is_never_guessed` | -| Every tool is annotated (openWorldHint false on all), and the read-only hint is true in behaviour (journal, every data-dir file's contents, the database's rows, the vault and the settings unchanged) | `annotations_complete`; `spec_mcp.rs` `read_only_tools_change_nothing` (checked on every call in `Mcp::tool`) | +| Every tool is annotated (openWorldHint false on all), and the read-only hint is true in behaviour (journal, every data-dir file's contents but `ops/`, the viewer's side channel for `books.show`, which is not the books; the database's rows, the vault and the settings unchanged) | `annotations_complete`; `spec_mcp.rs` `read_only_tools_change_nothing` (checked on every call in `Mcp::tool`) | | A bare (or empty-`since`) digest covers this month so far, on any day of it, as the server counts it (the stdio test reads the server's own month, and starts over if it turns) | `a_bare_digest_covers_this_month_so_far`; `mcp_stdio.rs` `digest_resource_bare_reads` | | The handbook: every section, "all" in order, the schema names every id | `handbook_sections_render_the_rule_in_force`, `the_whole_handbook_is_every_section_in_order`, `mcp_stdio.rs` `handbook_tool_and_resources_over_stdio` | @@ -121,8 +121,10 @@ broke, removed by simpler code, or is equivalent: (`resolved_follows_forty_links_and_no_more`). A file is saved only to a new file in a folder that exists, and a missing folder or a taken file is refused as such, never as a failed write - (`a_package_goes_only_to_a_new_file_in_a_folder_that_exists`, - `the_w9_file_goes_to_the_vault_never_the_documents`). The CPA package + (a CPA package test, and + `the_w9_file_goes_to_the_vault_never_the_documents`; P2 slice 6 took + the paths out of the tools, and the package test with them). The CPA + package marks only its own TIN files: a statement it carries still goes in afterwards (`a_tin_bearing_file_is_refused_from_every_session_and_after_a_restart`). An unreadable TIN-file registry refuses every file diff --git a/plan/served.md b/plan/served.md index be0738f..3fc9af3 100644 --- a/plan/served.md +++ b/plan/served.md @@ -259,6 +259,16 @@ The server, the CLI and the GUI find the data root with one shared function (`le - `[viewer] open_at_start` becomes a per-human eframe preference (`app.rs:833`), not journaled. - **Color schemes** (owner, 2026-10-19: "full blown colorscheme support … all the top neovim style colorschemes", "on the order of 20 families, each with a flyout of all its variants", a light/dark/all filter; Ayu included). The button in the sidebar's lower corner opens a menu: the filter, then, alphabetically and all on screen at once, the viewer's own family (Wanderbooks Light and Dark) and twenty families of editor schemes (Ayu, Catppuccin, Cyberdream, Dracula, Everforest, GitHub, Gruvbox, Kanagawa, Material, Melange, Monokai Pro, Night Owl, Nightfox, Nord, One Dark, Oxocarbon, Rosé Pine, Solarized, Sonokai, Tokyo Night), each a flyout of all its variants, alphabetical, each a row with a strip of its colors that chooses it; a row rested on for 50 ms is worn live until the pointer moves on (owner, 2026-10-19), and only a click keeps it. The choice and the filter are per-human eframe preferences like `open_at_start`; nothing is journaled and no figure changes (`schemes.rs` paints nine pages in all 80 schemes and checks every painted word and figure is the same, in the same place, and readable on what is under it). Every palette keeps every text color (figures, gains and losses, warnings, links, the faintest text) at WCAG AA contrast (4.5:1) on every background it is drawn on: a scheme's own color that falls short keeps its hue and only changes lightness, and gains, losses and text stay plainly different to the eye. The scheme pins egui's light or dark theme, so the system flipping changes nothing. The palettes come from each scheme's official source (`docs/color-schemes.md`). +**As built (P2 slice 6), and where it departs:** +- **Finding the books.** `wanderbooks gui [BOOK]` (`ledger-gui --root PATH --book ID`): the data root from `--root`, else the one `setup` recorded (`Root::find`), never the working directory or a toml; the book named, else the root's only one (or the first by id). `--data-dir` still opens an older books dir's `books-data` (tests, and the first company's books until the cutover), and `--books-toml` names its settings file, read only while its journal keeps none. +- **Settings come from the journal.** A book of the data root never reads `wanderbooks.toml` or `config.json`: books that keep no settings show the defaults. An older books dir whose journal keeps none is read as its server reads it, from its `wanderbooks.toml` (or `--books-toml`) and `config.json`, followed live, until its settings move in (`books.import_settings`). The demo company journals its settings. +- **The vault.** For a book in the data root (`inside_a_root`), its own `vault/` and `keys/legacy.key`, whatever a setting says; for an older books dir, as before (`--vault-key-file`, the setting, the default). No passphrase: TINs get no handling beyond the vault (D-29); a click copies one to the clipboard as before. +- **`books.show` and presence** (`ledger_store::ops`, which replaces `viewer.rs`): the server makes the book's `ops/` (0700) and `ops/viewer.lock` (0600) when it opens the books; a show request is a line of `ops/show.jsonl` (`{seq, at, target}`, at most 32, the file replaced whole); the viewer watches `ops/` once it exists and holds a shared `flock` on `ops/viewer.lock` through a read-only descriptor while it runs, let go of when it closes; `books.show` answers `viewer_open` and says so. `ops/` is not the books: the read-only tool check leaves it out. +- **Save a copy / Open** on every document, **Save the W-9** on a party whose W-9 is in the vault, and the **Export** menu (the CPA package as of today, the 1099 worksheet for this year or last, full TINs included): each a new file in the Downloads folder (`ledger_store::saving`: never over a file, written aside and linked in; the viewer's report PDFs now save the same way instead of overwriting), what came of it shown beside the button. The menu's files are built off the UI thread from a fresh read-only snapshot; a document's copy and a W-9 (one stored file each, hash-checked as read) are saved at the click. "Open" is a copy in Downloads opened with the system's app (the one folder the viewer may write once confined, slice 8). +- **One builder** (`ledger-package`): the CPA package, the 1099 worksheet and the report helpers the server's tools share moved out of `server.rs` into a crate both the server and the viewer use, over `Store` or `Snapshot` alike (`PackageBooks`), the TINs passed in. Proven unchanged: 91 tool responses, 19 packages and 366 entries byte-identical before and after (only the XLSX and zip timestamps, which differ between any two runs, aside). The package now names files of the same name `name (2).ext` instead of failing, its README names only what it holds, and a K-1 worksheet whose rows don't match the members is left out with the reason. +- **The agent's side (D-29):** see 4.7's slice 5 list; `export_cpa_package`, `vendor_1099` (`csv: true`) and `get_w9` hand their files over as exports (`GET /b/{book}/x/{export}`, `wanderbooks download ex_…`), inline over stdio; no tool takes a path. +- **Not in this slice:** the menu's owner intents ("Close the books", "Back up now", "Restore…", "New books…", "Connect Claude Code / Codex") need the viewer's helper and the lease socket, and come with backups (slice 7) and setup (slice 8); so does running the viewer under read-only Landlock and Open-with through its helper. + ### 3.7 Backup and tamper evidence The archive process (`ledger-mcp archive`: locally started by the host after the server, and stopped with it, 3.9; a system unit on self-host) runs on a head-advance signal (debounced 30 s), on `period.closed`, on a timer while it runs, and once when it starts, for anything left unpushed. The books change only while they are open, so a backup is never missed while they are closed. "Back up now" in the viewer is the lease socket's `back_up_now` intent (3.9), opening the books first if they are closed. Each run: @@ -1227,6 +1237,9 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form | D-26 | Where releases are hosted (owner, 2026-10-19: "I'm assuming the GH releases page will do the hosting?") | **GitHub Releases** on the public mirror (`swaits/wanderbooks`): each release carries the `.dmg`, the setup `.exe`, the AppImage, the `.deb` and `.rpm`, and the signed release manifest; the self-updaters read the manifest at the fixed `releases/latest/download/` address, so no server of ours is needed. CI builds and code-signs on release tags on GitHub's macOS, Windows and Linux runners and holds no release key (P11). The apt and dnf repositories' signed indexes are served from GitHub Pages on the same repository; the AUR package points at the Release assets. | | D-27 | The agent dock and books homes (owner, 2026-10-19: bundle the agents "then it'd be a one stop shop … a chat window maybe full span across the bottom"; "Yeah I like the idea of the embedded alacritty launching claude/codex … maybe MVP defaults to claude"; "you'd want like a 'books home' dir somewhere that makes sense so it has a place to work"; "add it to the plan!") | The app docks a terminal (alacritty_terminal; ConPTY on Windows) along the bottom of its window and runs Claude Code (the default) or Codex in it, found or installed through the vendors' installers, never bundled; started by the viewer's helper, in the book's **books home**: a visible folder per book holding only the agent's pointer and configuration for that book, never the data root. Trust in that folder is the person's to give. **Never required** (owner, 2026-10-19: "we wouldn't want to prevent people from using their OWN AI agent. They should never HAVE to use our built in tty+claude session! Ideally they could even use the claude.ai browser version to connect to it. And of course, eventually we'd host the entire MCP (and gui!) remotely ... use any agent they prefer to remote connect into that.. and they'd view the GUI in a browser."): the books stay fully usable from any agent the person runs themselves, locally or (P9) remotely, claude.ai in a browser included; the dock's one extra is pasting the app's suggested instructions straight into its session. A hosted MCP and a browser viewer are the later service (section 11). P11 (the agent dock and books homes). | | D-28 | Who does what, locally (owner, 2026-10-19: "Just have the agent ask for them and the user replies. The interface is through claude/codex and that's that. Don't fucking stray from it. TIN, W-9 is NOT critical or super secret PII." and "for downloading the CPA package or other assets, I don't mind if that can happen through the GUI app ... the GUI is for READING the data. The claude/codex is for MUTATAING (and reading) the data!!!") | **Two interfaces, no third.** Every change to the books goes through the agent (Claude Code or Codex) over the MCP; the agent asks the human for what it needs, TINs and W-9s included, and seals them in the vault (`books.set_tin`, `books.set_w9`): no human web pages, terminal commands or open ports for that locally. Reading and downloading (the CPA package and other exports, receipts, documents) happen in the viewer, which still never writes the books. This supersedes, for local books, the `/h/` pages of 3.3, 4.7 and P7's TIN entry: I-7's "the agent never holds a full TIN" becomes "a TIN passes through the agent only on its way into the vault; no result shows one". `/h/` pages remain for self-hosted books reached remotely (P9). | +| D-29 | The full-TIN outputs, from both doors (owner, 2026-10-19, asked whether the agent's CPA export should go away once the viewer has one: "Both WITH TINs. Stop acting like TINS MATTER SO MUCH THEY DONT") | **The agent and the viewer both make them, TINs included.** `books.export_cpa_package` (TINs by default), `books.vendor_1099` with `include_tins` and `books.get_w9` keep working for the agent; their files are made by the server and handed over by a command, `wanderbooks download`, like a receipt too large for a call (no path parameter, nothing in the agent's context). The viewer's menu makes the same files into Downloads. Over stdio (the development transport) the file comes back inline. TINs get no handling beyond the vault: no passphrase, no extra prompts, no special cleanup. | +| D-30 | Q-1 answered: where the backups go (owner, 2026-10-19, asked "Where should the books' automatic backups go?" with a private git repo, a folder or other disk, or both: chose "Both") | **Two targets.** The archive pushes to a private git repository the owner names and also copies to a folder the owner names (an external disk or a synced folder); both hold only the encrypted archive. The repository's address and the folder are asked for when backups are first set up (slice 7, or at the cutover for the first company), and kept as the book's backup settings. | +| D-31 | Backups are not encrypted (owner, 2026-10-19, asked where to keep the backup's recovery key: "Why encrypt them? How about we let the backup system handle the security???? You're over complicating everything.") | **A backup is the book as it is, in git.** No age encryption, no archive key, no recovery key: the archive is a plain git repository of the book's journal, receipts, vault (as the vault keeps it) and keys, one commit per backup, pushed to the owner's private git repository and to a git repository in the owner's folder (D-30). The destinations are trusted to keep it private. This replaces 3.7's and 3.4's age encryption on push and 3.5's archive key. | ### 10.2 Deferred