check / check (push) Canceled after 0s
The project page opens on a snapshot: revenue, direct costs, the contribution before overhead and its margin of revenue (a segment margin: revenue less every cost traced to the project, fixed and variable; B-37 item 7), in the black or red, and for on-spec work the cost unrecovered if repaid. To date or this fiscal year. By month, revenue and costs with the net to date in its own strip; costs by category; the list colors each net by its sign. books.project carries the same figures, and a parity test runs the viewer and the MCP on the same books to the cent. Tests honor TMPDIR. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
529 lines
18 KiB
Rust
529 lines
18 KiB
Rust
//! 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.
|
|
static DOWNLOADS: std::sync::OnceLock<PathBuf> = std::sync::OnceLock::new();
|
|
|
|
unsafe extern "C" {
|
|
/// The C library's: runs `f` as the process exits, which libtest's
|
|
/// main does pass or fail (it returns, or calls `exit`).
|
|
safe fn atexit(f: extern "C" fn()) -> std::ffi::c_int;
|
|
}
|
|
|
|
/// Removes the downloads folder as the test binary exits: its files hold
|
|
/// (made-up) TINs, and every test's dir is removed when it ends (the
|
|
/// justfile's sweep-test-tmp catches a run killed first: it is `wb-*`).
|
|
extern "C" fn remove_downloads() {
|
|
if let Some(d) = DOWNLOADS.get() {
|
|
let _ = std::fs::remove_dir_all(d);
|
|
}
|
|
}
|
|
|
|
fn downloads() -> PathBuf {
|
|
DOWNLOADS
|
|
.get_or_init(|| {
|
|
let d = tempfile::Builder::new()
|
|
.prefix("wb-gui-downloads-")
|
|
.tempdir()
|
|
.unwrap()
|
|
.keep();
|
|
assert_eq!(atexit(remove_downloads), 0, "atexit refused");
|
|
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,
|
|
}
|
|
|
|
/// A 1099-NEC decision from the beginning, decided by the test.
|
|
fn nec() -> ledger_core::Dated1099 {
|
|
ledger_core::Dated1099::from_beginning(
|
|
ledger_core::Decision1099::Files(ledger_core::Form1099::Nec),
|
|
Some(ledger_core::settings::Source {
|
|
decided_by: "test".into(),
|
|
decided_on: today(),
|
|
citation: None,
|
|
note: None,
|
|
}),
|
|
)
|
|
}
|
|
|
|
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 QuickBooks file stops at the package's day, like every file.
|
|
assert!(
|
|
readme
|
|
.contains("general-journal.iif: every posted entry from the first through 2026-10-15,"),
|
|
"{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, &nec(), Some(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, &nec(), Some(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 payee(s) a 1099 is or may be due for have no TIN"),
|
|
"{note}"
|
|
);
|
|
}
|