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:
swaitsandClaude Opus 5.5 committed 2026-10-20 11:14:21 -06:00
1 parent b26c099a68
commit 5bc0633cdd
77 files changed
+5833 -2427

No files matched your search

+3
View File
@@ -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
+12 -2
View File
@@ -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;
-1
View File
@@ -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()
+54 -15
View File
@@ -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
View File
@@ -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);
+14 -4
View File
@@ -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);
+37 -8
View File
@@ -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")]
+65 -22
View File
@@ -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;
+214
View File
@@ -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))
}
}
}
}
+11 -6
View File
@@ -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),
+40
View File
@@ -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 {
+31
View File
@@ -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::*;
-2
View File
@@ -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();
-1
View File
@@ -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)
-2
View File
@@ -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();
+1 -2
View File
@@ -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()
+1 -2
View File
@@ -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);
-1
View File
@@ -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)
+1 -2
View File
@@ -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
-1
View File
@@ -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();
+490
View File
@@ -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}");
}
-1
View File
@@ -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();
+69 -6
View File
@@ -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}"),
_ => {}
}
}
}
-1
View File
@@ -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),
}
}
+90 -17
View File
@@ -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);
}
+42 -42
View File
@@ -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!(
-1
View File
@@ -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);
-1
View File
@@ -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 -2
View File
@@ -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()
+1 -2
View File
@@ -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();
+30 -24
View File
@@ -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());
}
-1
View File
@@ -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();
+27 -15
View File
@@ -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();