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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
1 parent
b26c099a68
commit
5bc0633cdd
77 files changed
+5833
-2427
No files matched your search
@@ -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 </dev/null &
|
||||
nohup "@@LEDGER_GUI@@" --data-dir "{{justfile_directory()}}/books-data" >"$log" 2>&1 </dev/null &
|
||||
sleep 1
|
||||
if kill -0 $! 2>/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<String>) -> Vec<String> {
|
||||
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]
|
||||
|
||||
@@ -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<String> = 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<u8> {
|
||||
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 <a folder>"),
|
||||
"{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<String>, 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<String> = 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());
|
||||
}
|
||||
@@ -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}"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <a folder>",
|
||||
&["--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;
|
||||
|
||||
@@ -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
|
||||
@@ -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;
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<String, String> {
|
||||
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<ledger_vault::W9File, String> {
|
||||
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<ledger_vault::Vault, String> {
|
||||
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<Vec<u8>, 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:
|
||||
|
||||
+120
-42
@@ -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<PathBuf>,
|
||||
/// Fix "today" (tests, screenshots); otherwise the local date.
|
||||
pub today: Option<NaiveDate>,
|
||||
}
|
||||
|
||||
/// 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<PathBuf> {
|
||||
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<PathBuf> {
|
||||
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<PathBuf> = 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<Books>),
|
||||
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<Update>,
|
||||
/// 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<std::sync::atomic::AtomicBool>,
|
||||
}
|
||||
|
||||
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<BooksSettings, String> {
|
||||
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<BooksSettings, String> {
|
||||
let books = snap.ledger().settings();
|
||||
if books.is_journaled() {
|
||||
return Ok(ledger_config::codec::books_settings(
|
||||
books,
|
||||
snap.ledger().base_currency().code(),
|
||||
));
|
||||
}
|
||||
match &source.books_toml {
|
||||
Some(p) if p.exists() => ledger_config::load(p),
|
||||
_ => Ok(BooksSettings::default()),
|
||||
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<PathBuf>) -> Option<SystemTime> {
|
||||
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<Update>) {
|
||||
fn run(
|
||||
source: Source,
|
||||
ctx: egui::Context,
|
||||
tx: Sender<Update>,
|
||||
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<Update>) {
|
||||
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>) {
|
||||
};
|
||||
(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<PathBuf>| {
|
||||
t.as_ref()
|
||||
.and_then(|p| std::fs::metadata(p).ok())
|
||||
.and_then(|m| m.modified().ok())
|
||||
};
|
||||
let toml_mtime = |t: &Option<PathBuf>| (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<Update>) {
|
||||
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<Update>) {
|
||||
.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<ledger_store::ops::Presence> = None;
|
||||
let mut look_at_ops = |watcher: &mut Option<notify::RecommendedWatcher>| {
|
||||
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<Update>) {
|
||||
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<Update>) {
|
||||
&& 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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<PathBuf> = 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<PathBuf, String> {
|
||||
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<PathBuf, String> {
|
||||
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")]
|
||||
|
||||
@@ -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<Item = String>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let mut root: Option<PathBuf> = None;
|
||||
let mut book: Option<String> = None;
|
||||
let mut data_dir: Option<PathBuf> = None;
|
||||
let mut books_toml: Option<PathBuf> = 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<PathBuf, String> {
|
||||
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;
|
||||
|
||||
@@ -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>), 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>)>,
|
||||
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<Books>, 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<Job> = 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<Books>, 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))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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::<Result<String, String>>(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<String, String> {
|
||||
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 {
|
||||
|
||||
@@ -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::<Result<String, String>>(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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<PathBuf> = 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<String> {
|
||||
let mut names: Vec<String> = 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::<Decimal>().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<String> = (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<String> = 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}");
|
||||
}
|
||||
@@ -73,7 +73,6 @@ fn item(title: &str, due: Option<NaiveDate>) -> 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();
|
||||
|
||||
@@ -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}"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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!(
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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<String> = 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<PathBuf>) -> 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<AccountId> = 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());
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -1486,109 +1486,6 @@ fn arrived_whole(headers: &reqwest::header::HeaderMap, body: &[u8]) -> bool {
|
||||
sent.as_deref() == Some(&<sha2::Sha256 as sha2::Digest>::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<PathBuf, String> {
|
||||
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<std::io::Error> = 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<Item = std::ffi::OsString>,
|
||||
) -> Result<(), Box<dyn std::error::Error>> {
|
||||
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::<u64>().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::<u64>().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<dyn std::error::Error> {
|
||||
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<String> = 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 {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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.
|
||||
@@ -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() {
|
||||
|
||||
+125
-47
@@ -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<BooksServer, LocalSessionManager>;
|
||||
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<Service>,
|
||||
/// Its store, closed when the server closes (INVARIANT 6).
|
||||
store: Arc<SharedStore>,
|
||||
/// Its access log, where requests refused at the door are
|
||||
@@ -117,6 +120,8 @@ enum Book {
|
||||
access: Arc<AccessLog>,
|
||||
/// 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<Arc<Books>>,
|
||||
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<Arc<Books>>,
|
||||
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<Books>,
|
||||
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::<u64>()
|
||||
.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::<u64>().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
|
||||
|
||||
+182
-1206
File diff suppressed because it is too large.
Load diff
@@ -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<u64>,
|
||||
}
|
||||
|
||||
/// `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<u8>,
|
||||
) -> Result<HandedFile, String> {
|
||||
// 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 <a folder>",
|
||||
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<String> {
|
||||
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:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -362,15 +362,14 @@ pub struct Vendor1099Params {
|
||||
/// through 2025, $2,000 from 2026).
|
||||
pub threshold: Option<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// Caveats for the human and CPA.
|
||||
pub notes: Vec<String>,
|
||||
/// 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<String>,
|
||||
/// The CSV written with save_csv_to, if any.
|
||||
pub saved_csv: Option<String>,
|
||||
/// With csv: the worksheet as a CSV file, handed over.
|
||||
pub csv: Option<HandedFile>,
|
||||
}
|
||||
|
||||
/// 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-<as_of>.zip`). Written
|
||||
/// readable by its owner only. With TINs, never inside the books dir.
|
||||
pub save_to: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// 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<String>,
|
||||
/// When it lapses (UTC), with `download`.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub expires_at: Option<String>,
|
||||
/// Over stdio: its bytes, base64.
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub content_base64: Option<String>,
|
||||
}
|
||||
|
||||
/// 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`.
|
||||
|
||||
@@ -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::path::PathBuf> = 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<u8> {
|
||||
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<String> = (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");
|
||||
|
||||
@@ -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<u8> {
|
||||
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()
|
||||
|
||||
@@ -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<String> = (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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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<String, Vec<u8>> {
|
||||
let mut z = zip::ZipArchive::new(std::fs::File::open(path).unwrap()).unwrap();
|
||||
fn unzip(bytes: &[u8]) -> HashMap<String, Vec<u8>> {
|
||||
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<String, Vec<u8>> {
|
||||
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<u8> {
|
||||
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<u8>)>) -> 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<PathBuf> {
|
||||
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<HashMap<String, String>> {
|
||||
@@ -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<String>, Vec<String>) = (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}");
|
||||
|
||||
@@ -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<u8> {
|
||||
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();
|
||||
|
||||
@@ -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"
|
||||
@@ -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<Vec<Attachment>, 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<Vec<Subject>, StoreError>;
|
||||
|
||||
/// An attachment's bytes, verified against its hash.
|
||||
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, 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<Vec<Attachment>, StoreError> {
|
||||
Store::list_attachments(self, None)
|
||||
}
|
||||
|
||||
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
|
||||
Store::attachment_links(self, attachment_id)
|
||||
}
|
||||
|
||||
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, 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<Vec<Attachment>, StoreError> {
|
||||
Snapshot::attachments(self, None)
|
||||
}
|
||||
|
||||
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, 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<Vec<u8>, StoreError> {
|
||||
Snapshot::attachment_bytes(self, attachment_id)
|
||||
}
|
||||
}
|
||||
@@ -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<String, String> {
|
||||
use ledger_import::{QbAccountType as Qb, iif_accounts, journal_to_iif};
|
||||
let fixed: std::collections::HashSet<AccountId> = 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())
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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<TinSource<'a>>,
|
||||
}
|
||||
|
||||
/// A built package.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Package {
|
||||
/// The zip's name: `cpa-package-<as_of>.zip`.
|
||||
pub file_name: String,
|
||||
/// Every file in the zip, in its order: (name in the zip, bytes).
|
||||
pub files: Vec<(String, Vec<u8>)>,
|
||||
/// The zip.
|
||||
pub zip: Vec<u8>,
|
||||
/// With a TIN source: each member or reportable 1099 payee whose TIN
|
||||
/// the package lacks, and why (INVARIANT 3).
|
||||
pub tin_gaps: Vec<String>,
|
||||
/// 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<B: PackageBooks + ?Sized>(
|
||||
books: &B,
|
||||
settings: &Settings<'_>,
|
||||
req: &PackageRequest<'_>,
|
||||
) -> Result<Package, String> {
|
||||
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<String> = 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<u8>)> = 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<AccountId> = 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::<Decimal>()
|
||||
};
|
||||
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<u8>)> = 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<String> =
|
||||
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<u64> = 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,
|
||||
})
|
||||
}
|
||||
@@ -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<ledger_core::ClassId>,
|
||||
today: NaiveDate,
|
||||
) -> Result<ledger_core::ProfitAndLossStatement, String> {
|
||||
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<ledger_core::BalanceSheetStatement, String> {
|
||||
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<ledger_core::reports::CashFlowStatement, String> {
|
||||
let resolve = |codes: &[String], what: &str| -> Result<Vec<AccountId>, 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<ledger_core::reports::CashFlowStatement, String> {
|
||||
let or = |given: &'_ [String], default: &'_ [String]| -> Vec<String> {
|
||||
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<ledger_import::Member> {
|
||||
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<Vec<ledger_core::MemberCapital>, 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<ledger_import::YearEarnings, String> {
|
||||
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,
|
||||
})
|
||||
}
|
||||
@@ -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<NaiveDate, String> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ledger_vault::Tin, String>;
|
||||
|
||||
/// 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<ledger_vault::Vault, String>,
|
||||
party: PartyId,
|
||||
) -> Result<ledger_vault::Tin, String> {
|
||||
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)
|
||||
}
|
||||
@@ -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<Decimal>,
|
||||
) -> Result<ledger_core::Vendor1099Report, ledger_core::LedgerError> {
|
||||
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<ledger_core::Form1099>) -> Option<String> {
|
||||
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<String>,
|
||||
/// 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<PartyId>,
|
||||
/// The TIN's last four, from the party's W-9 facts, else its TIN
|
||||
/// facts.
|
||||
pub tin_last4: Option<String>,
|
||||
/// The full TIN, formatted (123-45-6789, 12-3456789): only with a TIN
|
||||
/// source, and only when it has one.
|
||||
pub tin: Option<String>,
|
||||
/// The TIN's type (ssn, ein, itin): only with a TIN source.
|
||||
pub tin_type: Option<String>,
|
||||
/// With a TIN source, why `tin` is missing.
|
||||
pub tin_note: Option<String>,
|
||||
}
|
||||
|
||||
/// 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<Vendor1099WorksheetLine>,
|
||||
/// Reportable vendors with no W-9 on file.
|
||||
pub missing_w9: Vec<String>,
|
||||
/// Entries naming several parties whose shares don't resolve (each
|
||||
/// counted in full toward its vendor).
|
||||
pub unresolved: Vec<EntryId>,
|
||||
/// What to check: what counts, exemptions, the threshold's indexing,
|
||||
/// untagged entries, unresolved shares.
|
||||
pub notes: Vec<String>,
|
||||
/// 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<Decimal>,
|
||||
tins: Option<TinSource<'_>>,
|
||||
) -> Result<Vendor1099Worksheet, String> {
|
||||
// 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::<Vec<_>>()
|
||||
.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-<year>.csv`, or
|
||||
/// `1099-worksheet-<year>-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 { "" }
|
||||
)
|
||||
}
|
||||
@@ -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<String> {
|
||||
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<ledger_package::TinSource<'a>>,
|
||||
) -> 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<u8>)]) -> Vec<(String, Vec<u8>)> {
|
||||
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!("</{tag}>")).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<u8>)> {
|
||||
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<ledger_vault::Tin, String> + '_ {
|
||||
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<Subject> = 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<ledger_vault::Tin, String> {
|
||||
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"));
|
||||
}
|
||||
@@ -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<std::io::Error> 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<std::sync::Mutex<()>>,
|
||||
}
|
||||
|
||||
/// `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<std::fs::File> {
|
||||
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<Self, ExportError> {
|
||||
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<Export, ExportError> {
|
||||
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<u8>), 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::<Export>(&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::<Export>(&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<String> = 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)]);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
/// Split into columns: total, month, quarter, or year.
|
||||
#[serde(default)]
|
||||
columns: Option<String>,
|
||||
/// Compare with: prior_period or prior_year.
|
||||
#[serde(default)]
|
||||
compare: Option<String>,
|
||||
},
|
||||
/// 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<ViewerCommand> {
|
||||
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<ViewerCommand> {
|
||||
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<ViewerCommand> {
|
||||
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<Presence> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
@@ -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<PathBuf, String> {
|
||||
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<std::io::Error> = 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<String> = 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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
},
|
||||
/// 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<String>,
|
||||
/// Last date, YYYY-MM-DD.
|
||||
end: Option<String>,
|
||||
/// Split into columns: total, month, quarter, or year.
|
||||
#[serde(default)]
|
||||
columns: Option<String>,
|
||||
/// Compare with: prior_period or prior_year.
|
||||
#[serde(default)]
|
||||
compare: Option<String>,
|
||||
},
|
||||
/// 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<ViewerCommand> {
|
||||
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<ViewerCommand> {
|
||||
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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user