From e265ed4339089d947df856af135b3225b7cac788 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 15 Oct 2026 14:43:40 -0600 Subject: [PATCH] Parties, attachment links, statements, reconciliations (store), TIN vault crate Parties: one directory for vendors, contractors, customers, employees, members, platforms, agencies, banks, landlords (ledger-core parties.rs). W-9/W-4 facts without the number (last four only; any nine-digit run in a party field is refused, Treas. Reg. 301.6109-4). Vendor and customer records link to parties on replay; core tags every bill, invoice, payment, and reversal with its counterparty. Journal events party.*, entry.party_set; payroll lines name the employee party. MCP: books.add_party, update_party, set_party_roles, set_w9, set_w4, parties, party, set_entry_party; draft_entry party; run_payroll employee; show party. Viewer: Parties directory and party pages, search, journal text, W-9 attention, demo data. One definition of a party's money (ledger_core::parties::activity) for MCP and viewer. 1099 amounts count payments from cash and financing accounts (a member paying for the company counts; a card doesn't; a prepayment counts when made): vendor_1099_report_with, B-19. Store: attachment links to any subject, statements, reconciliations (schema v11), with replay and projection. ledger-vault: encrypted TIN and W-9 records (ChaCha20-Poly1305, key file outside the books). Spec-blind tests for parties (core, store, MCP, viewer) and attachments. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01GWVo7x3UHw5kwCUaWxiejX --- Cargo.lock | 65 + Cargo.toml | 2 +- crates/ledger-cli/src/main.rs | 9 + crates/ledger-cli/tests/init.rs | 3 +- crates/ledger-config/src/lib.rs | 20 + crates/ledger-core/src/error.rs | 9 + crates/ledger-core/src/form1099.rs | 81 +- crates/ledger-core/src/ledger.rs | 510 ++++++- crates/ledger-core/src/lib.rs | 7 +- crates/ledger-core/src/parties.rs | 409 ++++++ crates/ledger-core/src/payroll.rs | 5 + crates/ledger-core/tests/form1099.rs | 49 + .../ledger-core/tests/spec_assets_payroll.rs | 2 + crates/ledger-core/tests/spec_parties.rs | 1137 +++++++++++++++ crates/ledger-core/tests/spec_roles.rs | 1 + crates/ledger-gui/src/app.rs | 8 + crates/ledger-gui/src/attention.rs | 49 +- crates/ledger-gui/src/books.rs | 251 +++- crates/ledger-gui/src/demo.rs | 95 +- crates/ledger-gui/src/nav.rs | 14 +- crates/ledger-gui/src/pages/assets.rs | 6 +- crates/ledger-gui/src/pages/company.rs | 21 +- crates/ledger-gui/src/pages/entry.rs | 24 +- crates/ledger-gui/src/pages/journal.rs | 298 +++- crates/ledger-gui/src/pages/mod.rs | 3 + crates/ledger-gui/src/pages/parties.rs | 1182 +++++++++++++++ crates/ledger-gui/src/pages/transactions.rs | 11 +- crates/ledger-gui/src/palette.rs | 48 +- crates/ledger-gui/tests/parties.rs | 630 ++++++++ crates/ledger-gui/tests/read_only.rs | 3 + crates/ledger-gui/tests/screens.rs | 15 + crates/ledger-gui/tests/view_model.rs | 1 + crates/ledger-mcp/src/server.rs | 746 +++++++++- crates/ledger-mcp/src/types.rs | 402 +++++- crates/ledger-mcp/tests/mcp_stdio.rs | 2 +- crates/ledger-mcp/tests/spec_mcp.rs | 41 +- crates/ledger-mcp/tests/spec_parties.rs | 1149 +++++++++++++++ crates/ledger-store/src/error.rs | 25 + crates/ledger-store/src/lib.rs | 2 +- crates/ledger-store/src/schema.rs | 41 +- crates/ledger-store/src/store.rs | 831 ++++++++++- crates/ledger-store/src/viewer.rs | 8 +- crates/ledger-store/tests/asset_tags.rs | 28 + crates/ledger-store/tests/attachments.rs | 1275 +++++++++++++++++ crates/ledger-store/tests/parties.rs | 754 ++++++++++ crates/ledger-store/tests/store.rs | 1 + crates/ledger-vault/Cargo.toml | 24 + crates/ledger-vault/src/lib.rs | 403 ++++++ crates/ledger-vault/tests/vault.rs | 182 +++ docs/mcp-interface.md | 139 +- docs/parties-and-attachments.md | 59 +- docs/spec/rules.md | 32 + docs/spec/sources/irs-tin-formats.md | 16 + docs/spec/sources/irs-truncated-tin.md | 11 + 54 files changed, 10961 insertions(+), 178 deletions(-) create mode 100644 crates/ledger-core/src/parties.rs create mode 100644 crates/ledger-core/tests/spec_parties.rs create mode 100644 crates/ledger-gui/src/pages/parties.rs create mode 100644 crates/ledger-gui/tests/parties.rs create mode 100644 crates/ledger-mcp/tests/spec_parties.rs create mode 100644 crates/ledger-store/tests/attachments.rs create mode 100644 crates/ledger-store/tests/parties.rs create mode 100644 crates/ledger-vault/Cargo.toml create mode 100644 crates/ledger-vault/src/lib.rs create mode 100644 crates/ledger-vault/tests/vault.rs create mode 100644 docs/spec/sources/irs-tin-formats.md create mode 100644 docs/spec/sources/irs-truncated-tin.md diff --git a/Cargo.lock b/Cargo.lock index e810414..6630906 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -123,6 +123,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1973cfbc1a2daf9cf550e74e1f088c28e7f7d8c1e1418fb6c9dc5184b7e84c99" +dependencies = [ + "crypto-common 0.2.2", + "inout", +] + [[package]] name = "aes" version = "0.9.3" @@ -906,10 +916,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" dependencies = [ "cfg-if", + "cipher", "cpufeatures 0.3.1", "rand_core 0.10.1", ] +[[package]] +name = "chacha20poly1305" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b89e1c441e926b9c82a8d023f6e1b7ae0adcfaa7d621814e4d60789bac751cb" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", +] + [[package]] name = "chinese-number" version = "0.7.8" @@ -975,6 +998,7 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ + "block-buffer 0.12.1", "crypto-common 0.2.2", "inout", ] @@ -1312,7 +1336,9 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ + "getrandom 0.4.3", "hybrid-array", + "rand_core 0.10.1", ] [[package]] @@ -3748,6 +3774,22 @@ dependencies = [ "thiserror 2.0.21", ] +[[package]] +name = "ledger-vault" +version = "0.1.0" +dependencies = [ + "base64 0.22.1", + "chacha20poly1305", + "getrandom 0.2.17", + "hex", + "proptest", + "serde", + "serde_json", + "sha2 0.10.9", + "thiserror 2.0.21", + "zeroize", +] + [[package]] name = "libbz2-rs-sys" version = "0.2.5" @@ -4946,6 +4988,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc6355899e1c9462875b6757c79f3caa011a1fdae12bbb1a2e72dd1f234f8336" +[[package]] +name = "poly1305" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2d0073b297041425c7c3df6eb4792d598a15323fe63346852b092eca02904c" +dependencies = [ + "cpufeatures 0.3.1", + "universal-hash", +] + [[package]] name = "polycool" version = "0.4.0" @@ -7209,6 +7261,16 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "unsafe-libyaml" version = "0.2.11" @@ -8585,6 +8647,9 @@ name = "zeroize" version = "1.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879" +dependencies = [ + "serde", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index ea55516..8e04721 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [workspace] resolver = "2" -members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui"] +members = ["crates/ledger-core", "crates/ledger-store", "crates/ledger-config", "crates/ledger-mcp", "crates/ledger-import", "crates/ledger-cli", "crates/ledger-gui", "crates/ledger-vault"] [workspace.package] edition = "2024" diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index 7ed2be2..a1e5cae 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -322,6 +322,13 @@ receipt_threshold = "25.00" # by books.roles. Until assigned, each uses its default code (1200, 2000, # 4000, 6999, 3999, 6100, 6110, 2110-2150). +[vault] +# The TIN vault (full taxpayer identification numbers and W-9s, encrypted, +# in vault/ beside the books, which git ignores) is opened with this key +# file, kept OUTSIDE the books. Keep a copy in your password manager: +# without it the vault can't be read. Unset: ~/.config/wanderbooks/vault.key +# key_file = "~/.config/wanderbooks/vault.key" + [viewer] # When a session starts and the viewer isn't open (`just gui-status`), the # agent: "ask" offers to open it, "always" opens it (`just gui`), "never" @@ -332,6 +339,8 @@ open_at_start = "ask" const GITIGNORE: &str = r#"# The SQLite projection is disposable — it rebuilds from journal.jsonl. books-data/wanderbooks.db books-data/wanderbooks.db-* +# The TIN vault: encrypted, but never in git (it must be purgeable). +vault/ # Copies of a database that disagreed with the journal, kept when it is # rebuilt; disposable like the database itself. books-data/wanderbooks.mismatch.* diff --git a/crates/ledger-cli/tests/init.rs b/crates/ledger-cli/tests/init.rs index cb79dff..ace4229 100644 --- a/crates/ledger-cli/tests/init.rs +++ b/crates/ledger-cli/tests/init.rs @@ -368,11 +368,12 @@ fn the_toml_shows_every_setting_the_config_reads() { // Read from the config crate's serde structs, so a new setting there // fails here until the template shows it. let src = include_str!("../../ledger-config/src/lib.rs"); - assert_eq!(fields(src, "BooksToml"), ["company", "books"]); + assert_eq!(fields(src, "BooksToml"), ["company", "books", "vault"]); for (strukt, sect) in [ ("CompanyToml", "company"), ("MemberToml", "company.members"), ("BooksSection", "books"), + ("VaultSection", "vault"), ] { for key in fields(src, strukt) { if strukt == "CompanyToml" && key == "members" { diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index eaac3bb..74ead85 100644 --- a/crates/ledger-config/src/lib.rs +++ b/crates/ledger-config/src/lib.rs @@ -112,6 +112,9 @@ pub struct BooksSettings { /// ("WL" for "WL-0001"). Unset: [`BooksSettings::asset_tag_prefix`] /// derives it from the company name. pub asset_tag_prefix: Option, + /// `[vault] key_file`: the TIN vault's key file. Unset: the vault's + /// default (`~/.config/wanderbooks/vault.key`). + pub vault_key_file: Option, } impl BooksSettings { @@ -168,6 +171,13 @@ struct BooksToml { company: CompanyToml, #[serde(default)] books: BooksSection, + #[serde(default)] + vault: VaultSection, +} + +#[derive(Default, Deserialize)] +struct VaultSection { + key_file: Option, } #[derive(Default, Deserialize)] @@ -497,6 +507,16 @@ pub fn parse(text: &str) -> Result { cash_accounts: codes("cash_accounts", raw.books.cash_accounts)?, financing_accounts: codes("financing_accounts", raw.books.financing_accounts)?, investing_accounts: codes("investing_accounts", raw.books.investing_accounts)?, + vault_key_file: match raw.vault.key_file { + Some(p) if p.trim().is_empty() => { + return Err("[vault] key_file is empty".into()); + } + Some(p) => Some(std::path::PathBuf::from(match p.strip_prefix("~/") { + Some(rest) => format!("{}/{rest}", std::env::var("HOME").unwrap_or_default()), + None => p, + })), + None => None, + }, asset_tag_prefix: match raw.books.asset_tag_prefix { Some(p) if !p.is_empty() diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 72ec30e..dab26cf 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -152,6 +152,15 @@ pub enum LedgerError { #[error("bad tax basis: {0}")] InvalidTaxBasis(String), + /// No party has this id. + #[error("unknown party {0}")] + UnknownParty(u64), + + /// A party command that breaks a rule of the directory + /// (`crate::parties`). + #[error("{0}")] + InvalidParty(String), + /// A serial number of the wrong form (see `assets::check_serial`). #[error("bad serial number: {0}")] InvalidSerial(String), diff --git a/crates/ledger-core/src/form1099.rs b/crates/ledger-core/src/form1099.rs index 4c3d202..eee1388 100644 --- a/crates/ledger-core/src/form1099.rs +++ b/crates/ledger-core/src/form1099.rs @@ -3,11 +3,17 @@ //! A vendor is marked with the information return it gets (1099-NEC for //! nonemployee compensation, 1099-MISC for rents and the like) and whether //! its W-9 is on file. Entries are tagged with the vendor they paid -//! (`pay_bill` tags automatically). The amount paid is the cash that left -//! asset accounts on the vendor's tagged entries: credits minus debits to -//! asset accounts. A card payment (Dr expense, Cr credit card) moves no -//! asset, so it is excluded, as it should be: card payments are reported by -//! the card processor on 1099-K, not by the payer. +//! (`pay_bill` tags automatically). The amount paid is what left the +//! company's payment sources on the vendor's tagged entries: credits minus +//! debits to them ([`PaymentAccounts`]). The sources are the cash accounts +//! (bank, cash on hand) and the financing liabilities (what owners or +//! lenders paid on the company's behalf, e.g. Due to Shareholders): the +//! company is the payer either way. A card payment (Dr expense, Cr credit +//! card) leaves neither, so it is excluded, as it should be: card payments +//! are reported by the card processor on 1099-K, not by the payer. A bill +//! (Cr payables) is not a payment until it is paid, and a prepayment +//! (Dr prepaid, Cr cash) counts in full when made: §6041 reports payments +//! made in the year. //! //! Thresholds are set by law and change; [`default_threshold`] encodes //! them and the report takes an override. Confirm with the CPA. @@ -117,6 +123,57 @@ pub fn vendor_1099_report( ledger: &Ledger, year: i32, threshold: Option, +) -> Result { + vendor_1099_report_with(ledger, year, threshold, &PaymentAccounts::default()) +} + +/// The accounts payments to vendors come out of, for +/// [`vendor_1099_report_with`]: the books' `[books] cash_accounts` and +/// `[books] financing_accounts`. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PaymentAccounts { + /// Cash accounts: bank, savings, cash on hand. + pub cash: Vec, + /// Financing liabilities: owner advances, loans (paid on the company's + /// behalf). Never a payment card. + pub financing: Vec, +} + +impl PaymentAccounts { + /// Whether `account` is a payment source. With no cash accounts given, + /// every asset account is one (books that haven't named their cash). + fn includes(&self, ledger: &Ledger, account: crate::AccountId) -> bool { + if self.cash.is_empty() { + ledger + .chart() + .get(account) + .is_some_and(|a| a.account_type == AccountType::Asset) + || self.financing.contains(&account) + } else { + self.cash.contains(&account) || self.financing.contains(&account) + } + } +} + +/// [`vendor_1099_report`], counting payments out of `sources`. +/// +/// ```text +/// SOURCE: docs/spec/rules.md B-19 (26 U.S.C. §6041(a): payments made in +/// the year; card payments go on 1099-K). +/// INVARIANTS: +/// 1. A vendor's amount is credits minus debits to `sources` on its +/// tagged posted entries dated in `year`. +/// SCENARIOS: +/// 1. Paid from cash, or by a member through a financing liability: +/// counts. Charged to a card, or billed and unpaid: doesn't. +/// 2. A prepayment (Dr prepaid, Cr cash) counts in full; a refund to +/// cash and a reversal subtract. +/// ``` +pub fn vendor_1099_report_with( + ledger: &Ledger, + year: i32, + threshold: Option, + sources: &PaymentAccounts, ) -> Result { let threshold = match threshold { Some(t) => t, @@ -133,12 +190,7 @@ pub fn vendor_1099_report( let cash: Decimal = entry .lines() .iter() - .filter(|l| { - ledger - .chart() - .get(l.account) - .is_some_and(|a| a.account_type == AccountType::Asset) - }) + .filter(|l| sources.includes(ledger, l.account)) .map(|l| match l.side { crate::Side::Credit => l.amount.amount, crate::Side::Debit => -l.amount.amount, @@ -156,7 +208,12 @@ pub fn vendor_1099_report( vendor_id: v.id, name: v.name.clone(), form: v.form_1099, - tin_on_file: v.tin_on_file, + // On file by the vendor's flag or its party's W-9 facts. + tin_on_file: v.tin_on_file + || ledger + .party_of_vendor(v.id) + .and_then(|p| ledger.get_party(p)) + .is_some_and(|p| p.w9.is_some()), paid: amount, reportable: v.form_1099.is_some() && amount >= threshold, } diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 9eee828..db352d6 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -261,6 +261,11 @@ pub struct Ledger { /// Every asset tag ever held ([`assets::tag_key`] → the asset that /// held it): a tag never moves to another asset. asset_tags: std::collections::BTreeMap, + /// The party directory ([`crate::parties`]). + parties: std::collections::BTreeMap, + next_party_id: crate::PartyId, + /// The party each entry is with (the counterparty). + entry_parties: HashMap, } impl Ledger { @@ -300,6 +305,9 @@ impl Ledger { bill_payments: HashMap::new(), roles: std::collections::BTreeMap::new(), asset_tags: std::collections::BTreeMap::new(), + parties: std::collections::BTreeMap::new(), + next_party_id: 1, + entry_parties: HashMap::new(), } } @@ -594,6 +602,9 @@ impl Ledger { ("bill_payments", self.bill_payments == other.bill_payments), ("roles", self.roles == other.roles), ("asset_tags", self.asset_tags == other.asset_tags), + ("parties", self.parties == other.parties), + ("next_party_id", self.next_party_id == other.next_party_id), + ("entry_parties", self.entry_parties == other.entry_parties), ] .into_iter() .filter(|(_, same)| !same) @@ -1359,10 +1370,13 @@ impl Ledger { .ok_or(LedgerError::UnknownEntry(id))? .mark_reversal_of(entry); self.post_entry(id)?; - // A reversed payment un-pays the vendor: carry the tag. + // A reversed payment un-pays the vendor: carry the tags. if let Some(v) = self.entry_vendor(entry) { self.entry_vendors.insert(id, v); } + if let Some(p) = self.entry_party(entry) { + self.entry_parties.insert(id, p); + } Ok(id) } @@ -2311,6 +2325,9 @@ impl Ledger { if name.trim().is_empty() { return Err(LedgerError::EmptyAssetDescription); } + // Its party takes this name, so the name obeys party rules (no + // full TIN; parties INVARIANT 1). + crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?; let id = self.next_customer_id; self.next_customer_id += 1; self.customers.insert( @@ -2320,6 +2337,7 @@ impl Ledger { name: name.to_string(), }, ); + self.link_record_party(name, false, id); Ok(id) } @@ -2328,6 +2346,9 @@ impl Ledger { if name.trim().is_empty() { return Err(LedgerError::EmptyAssetDescription); } + // Its party takes this name, so the name obeys party rules (no + // full TIN; parties INVARIANT 1). + crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?; let id = self.next_vendor_id; self.next_vendor_id += 1; self.vendors.insert( @@ -2339,6 +2360,7 @@ impl Ledger { tin_on_file: false, }, ); + self.link_record_party(name, true, id); Ok(id) } @@ -2395,8 +2417,444 @@ impl Ledger { return Err(LedgerError::UnknownVendor(v)); } self.entry_vendors.insert(entry, v); + if let Some(p) = self.party_of_vendor(v) { + self.entry_parties.insert(entry, p); + } } None => { + // The party tag goes too only when it was the vendor's. + if let Some(v) = self.entry_vendors.remove(&entry) + && self.entry_parties.get(&entry).copied() == self.party_of_vendor(v) + { + self.entry_parties.remove(&entry); + } + } + } + Ok(()) + } + + /// Tag an entry with a vendor and that vendor's party (bills, bill + /// payments): one counterparty, both tags. + fn tag_vendor(&mut self, entry: EntryId, vendor: VendorId) { + self.entry_vendors.insert(entry, vendor); + if let Some(p) = self.party_of_vendor(vendor) { + self.entry_parties.insert(entry, p); + } + } + + /// Tag an entry with a customer's party (invoices, invoice payments). + fn tag_customer(&mut self, entry: EntryId, customer: CustomerId) { + if let Some(p) = self.party_of_customer(customer) { + self.entry_parties.insert(entry, p); + } + } + + /// Link a new vendor or customer record to its party: the party not + /// archived with that name (ignoring case and surrounding space) that + /// has no such record yet, else a new party (named "Name (2)", "(3)", + /// ... when a live party already has the name). Deterministic, so replay + /// rebuilds the same directory from the same records. + fn link_record_party(&mut self, name: &str, vendor: bool, record: u64) { + let key = crate::parties::name_key(name); + let slot = |p: &crate::Party| if vendor { p.vendor } else { p.customer }; + let found = self + .parties + .values() + .find(|p| !p.archived && crate::parties::name_key(&p.name) == key && slot(p).is_none()) + .map(|p| p.id); + let id = match found { + Some(id) => id, + None => { + let id = self.next_party_id; + self.next_party_id += 1; + // Names are unique among live parties (parties INVARIANT 3): + // a second record of the same name gets "Name (2)", and so on. + let base = name.trim(); + let taken = |n: &str| { + let k = crate::parties::name_key(n); + self.parties + .values() + .any(|p| !p.archived && crate::parties::name_key(&p.name) == k) + }; + let mut party_name = base.to_string(); + let mut n = 2; + while taken(&party_name) { + party_name = format!("{base} ({n})"); + n += 1; + } + self.parties.insert( + id, + crate::Party { + id, + name: party_name, + display_name: None, + roles: std::collections::BTreeSet::new(), + website: None, + address: None, + notes: None, + archived: false, + w9: None, + w4: None, + vendor: None, + customer: None, + }, + ); + id + } + }; + let p = self.parties.get_mut(&id).expect("just found or made"); + if vendor { + p.vendor = Some(record); + } else { + p.customer = Some(record); + } + } + + /// The party a vendor record belongs to. + pub fn party_of_vendor(&self, vendor: VendorId) -> Option { + self.parties + .values() + .find(|p| p.vendor == Some(vendor)) + .map(|p| p.id) + } + + /// The party a customer record belongs to. + pub fn party_of_customer(&self, customer: CustomerId) -> Option { + self.parties + .values() + .find(|p| p.customer == Some(customer)) + .map(|p| p.id) + } + + /// One party. + pub fn get_party(&self, id: crate::PartyId) -> Option<&crate::Party> { + self.parties.get(&id) + } + + /// Every party, in id order. + pub fn parties(&self) -> impl Iterator + '_ { + self.parties.values() + } + + /// The party (not archived) named `name`, ignoring case and surrounding + /// space. + pub fn party_by_name(&self, name: &str) -> Option<&crate::Party> { + let key = crate::parties::name_key(name); + self.parties + .values() + .find(|p| !p.archived && crate::parties::name_key(&p.name) == key) + } + + /// The party an entry is with, if tagged. + pub fn entry_party(&self, entry: EntryId) -> Option { + self.entry_parties.get(&entry).copied() + } + + /// The next party id the directory will assign. + pub fn next_party_id(&self) -> crate::PartyId { + self.next_party_id + } + + /// Add a party. + /// + /// ```text + /// GOAL: a record for anyone the company deals with. + /// SOURCE: crate::parties (INVARIANTS 1, 3); DECISION (Stephen, + /// 2026-10-15). + /// SCENARIOS: + /// 1. IF the name is blank, holds a full TIN, or names a party not + /// archived: SHOULD refuse, changing nothing. + /// 2. IF a role is vendor or customer: SHOULD refuse (those follow + /// their records; add_vendor / add_customer make them). + /// ``` + pub fn add_party( + &mut self, + name: &str, + roles: &[crate::Role], + ) -> Result { + self.check_add_party(name, roles)?; + let id = self.next_party_id; + self.next_party_id += 1; + self.parties.insert( + id, + crate::Party { + id, + name: name.trim().to_string(), + display_name: None, + roles: roles.iter().copied().collect(), + website: None, + address: None, + notes: None, + archived: false, + w9: None, + w4: None, + vendor: None, + customer: None, + }, + ); + Ok(id) + } + + /// The validation behind [`Self::add_party`]. + pub fn check_add_party(&self, name: &str, roles: &[crate::Role]) -> Result<(), LedgerError> { + let bad = |why: String| Err(LedgerError::InvalidParty(why)); + if name.trim().is_empty() { + return bad("a party needs a name".into()); + } + crate::parties::check_text("name", name).map_err(LedgerError::InvalidParty)?; + if self.party_by_name(name).is_some() { + return bad(format!("a party named {:?} already exists", name.trim())); + } + if roles + .iter() + .any(|r| matches!(r, crate::Role::Vendor | crate::Role::Customer)) + { + return bad( + "vendor and customer roles come with their records: add_vendor or add_customer" + .into(), + ); + } + Ok(()) + } + + /// Change one of a party's facts. + /// + /// ```text + /// SOURCE: crate::parties (INVARIANTS 1, 3). + /// SCENARIOS: + /// 1. IF the party is unknown, a text holds a full TIN or is too long, + /// or a new name is blank or taken: SHOULD refuse, changing + /// nothing. + /// ``` + pub fn update_party( + &mut self, + id: crate::PartyId, + field: crate::PartyField, + ) -> Result<(), LedgerError> { + self.check_update_party(id, &field)?; + let p = self.parties.get_mut(&id).expect("checked"); + use crate::PartyField::*; + match field { + Name(n) => { + // Its records carry the same name: aging, bills, and the + // 1099 worksheet show what the party is called now. + p.name = n.trim().to_string(); + let (name, vendor, customer) = (p.name.clone(), p.vendor, p.customer); + if let Some(v) = vendor.and_then(|v| self.vendors.get_mut(&v)) { + v.name = name.clone(); + } + if let Some(c) = customer.and_then(|c| self.customers.get_mut(&c)) { + c.name = name; + } + } + DisplayName(v) => p.display_name = v, + Website(v) => p.website = v, + Address(v) => p.address = v, + Notes(v) => p.notes = v, + Archived(b) => p.archived = b, + } + Ok(()) + } + + /// The validation behind [`Self::update_party`]. + pub fn check_update_party( + &self, + id: crate::PartyId, + field: &crate::PartyField, + ) -> Result<(), LedgerError> { + use crate::PartyField::*; + let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; + let text = |f: &str, v: &Option| match v { + Some(t) => crate::parties::check_text(f, t).map_err(LedgerError::InvalidParty), + None => Ok(()), + }; + match field { + Name(n) => { + if n.trim().is_empty() { + return Err(LedgerError::InvalidParty("a party needs a name".into())); + } + crate::parties::check_text("name", n).map_err(LedgerError::InvalidParty)?; + if let Some(other) = self.party_by_name(n) + && other.id != id + { + return Err(LedgerError::InvalidParty(format!( + "a party named {:?} already exists", + n.trim() + ))); + } + } + DisplayName(v) => text("display_name", v)?, + Website(v) => text("website", v)?, + Address(v) => text("address", v)?, + Notes(v) => text("notes", v)?, + Archived(false) => { + if let Some(other) = self.party_by_name(&p.name) + && other.id != id + { + return Err(LedgerError::InvalidParty(format!( + "another party is named {:?}; rename one first", + p.name + ))); + } + } + Archived(true) => {} + } + Ok(()) + } + + /// Give a party a role, or take one away. + /// + /// ```text + /// SOURCE: crate::parties (INVARIANT 2). + /// SCENARIOS: + /// 1. IF the role is vendor or customer: SHOULD refuse (they follow + /// the records). + /// 2. IF taking away a 1099 role leaves W-9 facts with no 1099 role, + /// or taking away employee leaves W-4 facts: SHOULD refuse (clear + /// the facts first). + /// ``` + pub fn set_party_role( + &mut self, + id: crate::PartyId, + role: crate::Role, + on: bool, + ) -> Result<(), LedgerError> { + self.check_set_party_role(id, role, on)?; + let p = self.parties.get_mut(&id).expect("checked"); + if on { + p.roles.insert(role); + } else { + p.roles.remove(&role); + } + Ok(()) + } + + /// The validation behind [`Self::set_party_role`]. + pub fn check_set_party_role( + &self, + id: crate::PartyId, + role: crate::Role, + on: bool, + ) -> Result<(), LedgerError> { + let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; + if matches!(role, crate::Role::Vendor | crate::Role::Customer) { + return Err(LedgerError::InvalidParty( + "vendor and customer roles follow their records".into(), + )); + } + if !on { + let mut left = p.roles.clone(); + left.remove(&role); + if p.w9.is_some() && !left.iter().any(|r| r.gets_1099()) { + return Err(LedgerError::InvalidParty( + "clear the W-9 facts before removing the last 1099 role".into(), + )); + } + if p.w4.is_some() && role == crate::Role::Employee { + return Err(LedgerError::InvalidParty( + "clear the W-4 facts before removing the employee role".into(), + )); + } + } + Ok(()) + } + + /// Record (or clear) a party's W-9 facts. + /// + /// ```text + /// SOURCE: crate::parties (INVARIANTS 1, 2). + /// SCENARIOS: + /// 1. IF the party has no 1099 role (contractor, landlord), or the + /// facts' form is wrong (W9Facts::check): SHOULD refuse. + /// ``` + pub fn set_party_w9( + &mut self, + id: crate::PartyId, + facts: Option, + ) -> Result<(), LedgerError> { + self.check_set_party_w9(id, facts.as_ref())?; + self.parties.get_mut(&id).expect("checked").w9 = facts; + Ok(()) + } + + /// The validation behind [`Self::set_party_w9`]. + pub fn check_set_party_w9( + &self, + id: crate::PartyId, + facts: Option<&crate::W9Facts>, + ) -> Result<(), LedgerError> { + let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; + if let Some(f) = facts { + if !p.roles.iter().any(|r| r.gets_1099()) { + return Err(LedgerError::InvalidParty( + "W-9 facts need a 1099 role (contractor or landlord)".into(), + )); + } + f.check().map_err(LedgerError::InvalidParty)?; + } + Ok(()) + } + + /// Record (or clear) a party's W-4 facts. + pub fn set_party_w4( + &mut self, + id: crate::PartyId, + facts: Option, + ) -> Result<(), LedgerError> { + self.check_set_party_w4(id, facts.as_ref())?; + self.parties.get_mut(&id).expect("checked").w4 = facts; + Ok(()) + } + + /// The validation behind [`Self::set_party_w4`]. + pub fn check_set_party_w4( + &self, + id: crate::PartyId, + facts: Option<&crate::W4Facts>, + ) -> Result<(), LedgerError> { + let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; + if let Some(f) = facts { + if !p.roles.contains(&crate::Role::Employee) { + return Err(LedgerError::InvalidParty( + "W-4 facts need the employee role".into(), + )); + } + f.check().map_err(LedgerError::InvalidParty)?; + } + Ok(()) + } + + /// Tag an entry with the party it was with, or clear the tag. A party + /// with a vendor record also tags the entry's vendor (for 1099s), as + /// [`Self::set_entry_vendor`] does the other way round. Metadata: no + /// amount changes, so posted entries may be tagged. + pub fn set_entry_party( + &mut self, + entry: EntryId, + party: Option, + ) -> Result<(), LedgerError> { + if !self.entries.contains_key(&entry) { + return Err(LedgerError::UnknownEntry(entry)); + } + match party { + Some(p) => { + let vendor = self + .parties + .get(&p) + .ok_or(LedgerError::UnknownParty(p))? + .vendor; + self.entry_parties.insert(entry, p); + match vendor { + Some(v) => { + self.entry_vendors.insert(entry, v); + } + None => { + self.entry_vendors.remove(&entry); + } + } + } + None => { + self.entry_parties.remove(&entry); self.entry_vendors.remove(&entry); } } @@ -2562,6 +3020,7 @@ impl Ledger { entry_id: entry, }, ); + self.tag_customer(entry, customer_id); Ok(id) } @@ -2633,7 +3092,7 @@ impl Ledger { entry_id: entry, }, ); - self.entry_vendors.insert(entry, vendor_id); + self.tag_vendor(entry, vendor_id); Ok(id) } @@ -2647,6 +3106,30 @@ impl Ledger { self.bills.get(&id) } + /// Every invoice, by id. + pub fn invoices(&self) -> Vec<&Invoice> { + let mut v: Vec<&Invoice> = self.invoices.values().collect(); + v.sort_by_key(|i| i.id); + v + } + + /// Every bill, by id. + pub fn bills(&self) -> Vec<&Bill> { + let mut v: Vec<&Bill> = self.bills.values().collect(); + v.sort_by_key(|b| b.id); + v + } + + /// An invoice's payments: `(payment entry, amount)`, in order received. + pub fn invoice_payments(&self, id: InvoiceId) -> &[(EntryId, Decimal)] { + self.invoice_payments.get(&id).map_or(&[], Vec::as_slice) + } + + /// A bill's payments: `(payment entry, amount)`, in order paid. + pub fn bill_payments(&self, id: BillId) -> &[(EntryId, Decimal)] { + self.bill_payments.get(&id).map_or(&[], Vec::as_slice) + } + /// Record a customer payment against an invoice. Posts: Dr Cash, /// Cr A/R. Updates the invoice's paid amount and status. /// @@ -2714,10 +3197,12 @@ impl Ledger { let inv = self.invoices.get_mut(&invoice_id).expect("invoice exists"); inv.amount_paid += amount; inv.status = arap::derive_status(inv.amount_paid, inv.total()); + let customer = inv.customer_id; self.invoice_payments .entry(invoice_id) .or_default() .push((entry, amount)); + self.tag_customer(entry, customer); Ok(entry) } @@ -2792,7 +3277,7 @@ impl Ledger { .entry(bill_id) .or_default() .push((entry, amount)); - self.entry_vendors.insert(entry, bill.vendor_id); + self.tag_vendor(entry, bill.vendor_id); Ok(entry) } @@ -2828,6 +3313,7 @@ impl Ledger { entry_id, }, ); + self.tag_customer(entry_id, customer_id); Ok(()) } @@ -2860,7 +3346,7 @@ impl Ledger { entry_id, }, ); - self.entry_vendors.insert(entry_id, vendor_id); + self.tag_vendor(entry_id, vendor_id); Ok(()) } @@ -2886,6 +3372,8 @@ impl Ledger { .or_default() .push((entry_id, amount)); inv.status = arap::derive_status(inv.amount_paid, inv.total()); + let customer = inv.customer_id; + self.tag_customer(entry_id, customer); Ok(()) } @@ -2909,7 +3397,7 @@ impl Ledger { .or_default() .push((entry_id, amount)); let vendor = bill.vendor_id; - self.entry_vendors.insert(entry_id, vendor); + self.tag_vendor(entry_id, vendor); Ok(()) } @@ -3205,6 +3693,18 @@ impl Ledger { cash_account: AccountId, ) -> Result<(), LedgerError> { validate_payroll_lines(lines)?; + // A line that names its employee names a party with that role. + for line in lines { + if let Some(id) = line.employee { + let p = self.parties.get(&id).ok_or(LedgerError::UnknownParty(id))?; + if !p.roles.contains(&crate::Role::Employee) { + return Err(LedgerError::InvalidParty(format!( + "{} isn't an employee (give the party the employee role first)", + p.label() + ))); + } + } + } self.check_controls(&PAYROLL)?; self.chart .get(cash_account) diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index 3146f82..79be8f2 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -27,6 +27,7 @@ pub mod journal; pub mod kernel; pub mod ledger; pub mod money; +pub mod parties; pub mod payouts; pub mod payroll; pub mod periods; @@ -49,13 +50,17 @@ pub use assets::{ }; pub use classes::{Class, ClassId}; pub use error::LedgerError; -pub use form1099::{Form1099, Vendor1099Line, Vendor1099Report, vendor_1099_report}; +pub use form1099::{ + Form1099, PaymentAccounts, Vendor1099Line, Vendor1099Report, vendor_1099_report, + vendor_1099_report_with, +}; pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine}; pub use ledger::{ ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS, REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts, }; pub use money::{Currency, Money}; +pub use parties::{Party, PartyField, PartyId, Role, W4Facts, W9Facts}; pub use payouts::{Payout, PayoutAdjustment, PayoutLine, PayoutTotals, payout_plan}; pub use payroll::{PayrollLine, PayrollRun, PayrollRunId}; pub use periods::{ diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs new file mode 100644 index 0000000..b674f9f --- /dev/null +++ b/crates/ledger-core/src/parties.rs @@ -0,0 +1,409 @@ +//! Parties: everyone the company deals with, in one directory. +//! +//! A party is a vendor, a contractor, a customer, an employee, a member, a +//! payout platform, a tax agency, a bank or card issuer, a landlord, or +//! several of these. Vendor and customer *records* (bills, invoices, +//! payments, aging) stay where they are and link to their party; a party +//! adds who they are (names, facts) and links entries to them. +//! +//! ```text +//! GOAL: answer "who is this, and what has the company done with them?" +//! for anyone the books touch, without a CRM's sprawl. +//! SOURCE: docs/parties-and-attachments.md; DECISION (Stephen, +//! 2026-10-15); IRS truncated TINs: Treas. Reg. §301.6109-4. +//! INVARIANTS: +//! 1. No party field holds a full taxpayer identification number: any +//! run of nine digits (single spaces or hyphens between them allowed) +//! is refused. A W-9's TIN is kept as its last four digits only. +//! 2. W-9 facts need a 1099 role (contractor or landlord); W-4 facts need +//! the employee role. +//! 3. Names are unique, ignoring case and surrounding space, among parties +//! not archived. +//! 4. Every vendor and customer record links to exactly one party. +//! ``` + +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use std::collections::BTreeSet; + +/// Opaque party handle. +pub type PartyId = u64; + +/// What a party is to the company. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Role { + /// Sells to the company (has a vendor record: bills, payments). + Vendor, + /// A vendor paid for services who gets a 1099-NEC. + Contractor, + /// Buys from the company (has a customer record: invoices). + Customer, + /// Works for the company on payroll. + Employee, + /// A member (owner) of the company, as in wanderbooks.toml. + Member, + /// Pays the company out of sales (an app store, a payment processor). + PayoutPlatform, + /// Taxes the company (the IRS, a state). + TaxAgency, + /// Holds the company's money or credit (a bank, a card issuer). + FinancialInstitution, + /// Rents property to the company (gets a 1099-MISC for rent). + Landlord, +} + +impl Role { + /// Every role, in display order. + pub const ALL: [Role; 9] = [ + Role::Vendor, + Role::Contractor, + Role::Customer, + Role::Employee, + Role::Member, + Role::PayoutPlatform, + Role::TaxAgency, + Role::FinancialInstitution, + Role::Landlord, + ]; + + /// Its wire name, e.g. `payout_platform`. + pub fn key(self) -> &'static str { + match self { + Role::Vendor => "vendor", + Role::Contractor => "contractor", + Role::Customer => "customer", + Role::Employee => "employee", + Role::Member => "member", + Role::PayoutPlatform => "payout_platform", + Role::TaxAgency => "tax_agency", + Role::FinancialInstitution => "financial_institution", + Role::Landlord => "landlord", + } + } + + /// The role named `key`. + pub fn parse(key: &str) -> Option { + Role::ALL.into_iter().find(|r| r.key() == key) + } + + /// Whether this role files a 1099 (and so may carry W-9 facts). + pub fn gets_1099(self) -> bool { + matches!(self, Role::Contractor | Role::Landlord) + } +} + +/// What a W-9 said, without the number itself. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct W9Facts { + /// When it was received. + pub received: NaiveDate, + /// Line 3 federal tax classification, e.g. "individual/sole proprietor". + pub entity_type: String, + /// Line 4 exempt payee code, if any. + #[serde(default)] + pub exempt_payee_code: Option, + /// Line 4 FATCA exemption code, if any. + #[serde(default)] + pub fatca_code: Option, + /// The TIN's last four digits (exactly four digits). + pub tin_last4: String, + /// Where the form itself is kept, e.g. "wanderbooks vault" or "CPA portal". + pub kept_at: String, + /// SHA-256 of the vault record holding the full TIN, when one exists: + /// proves the vault held it without the books holding it. + #[serde(default)] + pub vault_record: Option, +} + +/// What a W-4 said, without the SSN. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct W4Facts { + /// When it was received. + pub received: NaiveDate, + /// The work state, two letters. + pub state: String, + /// Where the form itself is kept. + pub kept_at: String, +} + +/// One party. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Party { + /// Opaque handle. + pub id: PartyId, + /// Legal or trade name, as on documents. + pub name: String, + /// A shorter name to show, if any. + #[serde(default)] + pub display_name: Option, + /// Roles set by hand (vendor and customer also follow their records). + pub roles: BTreeSet, + /// Website. + #[serde(default)] + pub website: Option, + /// Business address. + #[serde(default)] + pub address: Option, + /// Notes. + #[serde(default)] + pub notes: Option, + /// No longer dealt with; history stays. + #[serde(default)] + pub archived: bool, + /// W-9 facts (contractor, landlord). + #[serde(default)] + pub w9: Option, + /// W-4 facts (employee). + #[serde(default)] + pub w4: Option, + /// Its vendor record, if it bills the company. + #[serde(default)] + pub vendor: Option, + /// Its customer record, if the company invoices it. + #[serde(default)] + pub customer: Option, +} + +impl Party { + /// The name to show: the display name, else the name. + pub fn label(&self) -> &str { + self.display_name.as_deref().unwrap_or(&self.name) + } + + /// Its roles: the ones set by hand, plus vendor and customer when it + /// has those records. + pub fn all_roles(&self) -> BTreeSet { + let mut r = self.roles.clone(); + if self.vendor.is_some() { + r.insert(Role::Vendor); + } + if self.customer.is_some() { + r.insert(Role::Customer); + } + r + } +} + +/// The key names are compared by: case and surrounding space ignored. +pub fn name_key(name: &str) -> String { + name.trim().to_lowercase() +} + +/// Whether `text` holds a run of nine digits, with at most one space or +/// hyphen between neighbors (an SSN, EIN, or ITIN however it's written). +/// Any Unicode numeral counts as a digit (full-width, Arabic-Indic, ...), +/// so a number can't slip through in another script. +pub fn has_full_tin(text: &str) -> bool { + let chars: Vec = text.chars().collect(); + let mut i = 0; + while i < chars.len() { + if chars[i].is_numeric() { + let (mut digits, mut j) = (1, i + 1); + while j < chars.len() && digits < 9 { + if chars[j].is_numeric() { + digits += 1; + j += 1; + } else if matches!(chars[j], ' ' | '-') + && chars.get(j + 1).is_some_and(|c| c.is_numeric()) + { + j += 1; + } else { + break; + } + } + if digits >= 9 { + return true; + } + } + i += 1; + } + false +} + +/// Check one party text field: not a full TIN, at most 500 characters, no +/// control characters except newline (addresses and notes run several +/// lines). Returns why not. +pub fn check_text(field: &str, text: &str) -> Result<(), String> { + if has_full_tin(text) { + return Err(format!( + "{field} looks like it holds a full taxpayer identification number; \ + the books keep only its last four digits (the full number belongs in the vault)" + )); + } + if text.chars().count() > 500 { + return Err(format!("{field} is at most 500 characters")); + } + if text.chars().any(|c| c.is_control() && c != '\n') { + return Err(format!("{field} can't contain control characters")); + } + Ok(()) +} + +impl W9Facts { + /// Check the facts' form. Returns why not. + pub fn check(&self) -> Result<(), String> { + if self.tin_last4.len() != 4 || !self.tin_last4.chars().all(|c| c.is_ascii_digit()) { + return Err("tin_last4 is exactly four digits".into()); + } + if self.entity_type.trim().is_empty() { + return Err("a W-9's entity type (line 3) is required".into()); + } + if self.kept_at.trim().is_empty() { + return Err("say where the W-9 itself is kept".into()); + } + for (f, t) in [ + ("entity_type", Some(self.entity_type.as_str())), + ("exempt_payee_code", self.exempt_payee_code.as_deref()), + ("fatca_code", self.fatca_code.as_deref()), + ("kept_at", Some(self.kept_at.as_str())), + ] { + if let Some(t) = t { + check_text(f, t)?; + } + } + if let Some(h) = &self.vault_record + && (h.len() != 64 || !h.chars().all(|c| c.is_ascii_hexdigit())) + { + return Err("vault_record is a SHA-256 in hex".into()); + } + Ok(()) + } +} + +impl W4Facts { + /// Check the facts' form. Returns why not. + pub fn check(&self) -> Result<(), String> { + if self.state.len() != 2 || !self.state.chars().all(|c| c.is_ascii_uppercase()) { + return Err("state is two capital letters, e.g. UT".into()); + } + if self.kept_at.trim().is_empty() { + return Err("say where the W-4 itself is kept".into()); + } + check_text("kept_at", &self.kept_at) + } +} + +/// One fact of a party to change ([`crate::Ledger::update_party`]). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case", tag = "field", content = "value")] +pub enum PartyField { + /// The legal or trade name. + Name(String), + /// The display name (`None` clears it). + DisplayName(Option), + /// The website. + Website(Option), + /// The business address. + Address(Option), + /// Notes. + Notes(Option), + /// Archived (no longer dealt with) or not. + Archived(bool), +} + +/// What the books did with one party over a span of dates +/// ([`activity`]). +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct Activity { + /// The party's entries, drafts included, by id. + pub entries: BTreeSet, + /// Paid to it in the span (net of refunds and reversals of payments). + pub paid: rust_decimal::Decimal, + /// Received from it in the span (net of reversals of receipts). + pub received: rust_decimal::Decimal, +} + +/// The money accounts [`activity`] counts: the cash accounts (every asset +/// account but receivables when `cash` is empty), and every liability +/// account but payables and the payroll withholdings (cards, and what +/// members paid for the company and are owed back). +pub fn money_accounts( + ledger: &crate::Ledger, + cash: &[crate::AccountId], +) -> std::collections::HashSet { + use crate::AccountType; + let not_money: std::collections::HashSet = crate::PAYROLL[2..] + .iter() + .chain([&crate::PAYABLES, &crate::RECEIVABLES]) + .filter_map(|c| ledger.role_account(c)) + .collect(); + ledger + .chart() + .all() + .filter(|a| !not_money.contains(&a.id)) + .filter(|a| match a.account_type { + AccountType::Liability => true, + AccountType::Asset => cash.is_empty() || cash.contains(&a.id), + _ => false, + }) + .map(|a| a.id) + .collect() +} + +/// One party's entries, and the money paid to and received from it in +/// `start..=end`. +/// +/// ```text +/// GOAL: one answer to "what did we pay them this year?" for the MCP +/// server and the viewer alike. +/// SOURCE: docs/parties-and-attachments.md (Parties); DECISION (Stephen, +/// 2026-10-15). +/// INVARIANTS: +/// 1. entries: every entry Ledger::entry_party names the party +/// (hand tags, vendor tags, bills and their payments, invoices and +/// their payments, and reversals of any of these carry it). +/// 2. Each posted entry dated in the span nets (credits − debits) on +/// money_accounts: positive is a payment, negative a receipt. A +/// reversal counts on its original's side, so it subtracts. +/// 3. A card charge counts as paid (the company paid them, by card); a +/// bill doesn't until it is paid. Not the 1099 amount: that is +/// form1099::vendor_1099_report_with. +/// ``` +pub fn activity( + ledger: &crate::Ledger, + party: PartyId, + (start, end): (NaiveDate, NaiveDate), + cash: &[crate::AccountId], +) -> Activity { + use crate::Side; + use rust_decimal::Decimal; + let entries: BTreeSet = (0..ledger.next_entry_id()) + .filter(|&id| ledger.entry_party(id) == Some(party)) + .collect(); + let money = money_accounts(ledger, cash); + let net_out = |e: &crate::JournalEntry| -> Decimal { + e.lines() + .iter() + .filter(|l| money.contains(&l.account)) + .map(|l| match l.side { + Side::Credit => l.amount.amount, + Side::Debit => -l.amount.amount, + }) + .sum() + }; + let (mut paid, mut received) = (Decimal::ZERO, Decimal::ZERO); + for &id in &entries { + let Some(e) = ledger.get_entry(id) else { + continue; + }; + if e.status() != crate::EntryStatus::Posted || e.date() < start || e.date() > end { + continue; + } + let net = net_out(e); + let side = match e.reversal_of().and_then(|o| ledger.get_entry(o)) { + Some(original) => net_out(original), + None => net, + }; + if side > Decimal::ZERO { + paid += net; + } else { + received -= net; + } + } + Activity { + entries, + paid, + received, + } +} diff --git a/crates/ledger-core/src/payroll.rs b/crates/ledger-core/src/payroll.rs index 6746d3d..562a89d 100644 --- a/crates/ledger-core/src/payroll.rs +++ b/crates/ledger-core/src/payroll.rs @@ -24,6 +24,10 @@ pub type PayrollRunId = u64; pub struct PayrollLine { /// Employee name, for the run record. pub employee_name: String, + /// The employee's party, if named (new runs should; runs from before + /// the party directory carry the name only). + #[serde(default)] + pub employee: Option, /// Gross pay for the period. pub gross_pay: Decimal, /// Federal income tax withheld. @@ -194,6 +198,7 @@ mod tests { fn line(name: &str) -> PayrollLine { PayrollLine { employee_name: name.to_string(), + employee: None, gross_pay: d(500_000), // 5000.00 federal_withholding: d(75_000), // 750.00 state_withholding: d(25_000), // 250.00 diff --git a/crates/ledger-core/tests/form1099.rs b/crates/ledger-core/tests/form1099.rs index 09ec9ae..64ba38f 100644 --- a/crates/ledger-core/tests/form1099.rs +++ b/crates/ledger-core/tests/form1099.rs @@ -213,3 +213,52 @@ fn forms_have_their_irs_names() { assert_eq!(Form1099::parse(f.label()), Some(f)); } } + +/// vendor_1099_report_with SCENARIOS 1–2, worked by hand. Payment sources: +/// cash 1000 and the members' financing liability 2200. Kim (1099-MISC, +/// landlord) in 2026: +/// - 1,000 rent from the bank (Dr rent, Cr 1000): +1,000 +/// - 2,400 prepaid rent from the bank (Dr 1300, Cr 1000): +2,400 (paid +/// when made; the prepaid asset is not a source, so it doesn't offset) +/// - 800 rent paid by a member (Dr rent, Cr 2200): +800 +/// - 500 rent on the card (Dr rent, Cr 2100): 0 (1099-K) +/// - 300 refunded to the bank (Dr 1000, Cr rent): −300 +/// +/// Total 3,900. The plain report (every asset a source, no financing) +/// sees 1,000 + 0 + 0 + 0 − 300 = 700: the understatement this fixes. +#[test] +fn payments_come_from_cash_and_financing_only() { + use ledger_core::{PaymentAccounts, vendor_1099_report_with}; + let mut l = Ledger::new(Currency::USD); + let acct = |l: &mut Ledger, code: &str, t| l.add_account(code, code, t, None, false).unwrap(); + let bank = acct(&mut l, "1000", AccountType::Asset); + let prepaid = acct(&mut l, "1300", AccountType::Asset); + let card = acct(&mut l, "2100", AccountType::Liability); + let members = acct(&mut l, "2200", AccountType::Liability); + let rent = acct(&mut l, "6600", AccountType::Expense); + let kim = l.add_vendor("Kim").unwrap(); + l.set_vendor_1099(kim, Some(Form1099::Misc), true).unwrap(); + for (dr, cr, amt) in [ + (rent, bank, "1000"), + (prepaid, bank, "2400"), + (rent, members, "800"), + (rent, card, "500"), + (bank, rent, "300"), + ] { + let e = l.create_entry(d(2026, 5, 1), "rent"); + let m = Money::new(dec(amt), Currency::USD); + l.add_line(e, dr, Side::Debit, m).unwrap(); + l.add_line(e, cr, Side::Credit, m).unwrap(); + l.post_entry(e).unwrap(); + l.set_entry_vendor(e, Some(kim)).unwrap(); + } + let sources = PaymentAccounts { + cash: vec![bank], + financing: vec![members], + }; + let r = vendor_1099_report_with(&l, 2026, None, &sources).unwrap(); + assert_eq!(r.vendors[0].paid, dec("3900")); + assert!(r.vendors[0].reportable, "3,900 is over 2,000"); + let plain = vendor_1099_report(&l, 2026, None).unwrap(); + assert_eq!(plain.vendors[0].paid, dec("700")); +} diff --git a/crates/ledger-core/tests/spec_assets_payroll.rs b/crates/ledger-core/tests/spec_assets_payroll.rs index 2b1325b..43e71fb 100644 --- a/crates/ledger-core/tests/spec_assets_payroll.rs +++ b/crates/ledger-core/tests/spec_assets_payroll.rs @@ -196,6 +196,7 @@ fn pline( ) -> PayrollLine { PayrollLine { employee_name: name.to_string(), + employee: None, gross_pay: d(gross), federal_withholding: d(fed), state_withholding: d(state), @@ -1208,6 +1209,7 @@ fn arb_line() -> impl Strategy { let z = |(v, keep): (i64, bool)| Decimal::new(if keep { v } else { 0 }, 2); PayrollLine { employee_name: "E".to_string(), + employee: None, gross_pay: Decimal::new(gross, 2), federal_withholding: z(w[0]), state_withholding: z(w[1]), diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs new file mode 100644 index 0000000..df2a9e2 --- /dev/null +++ b/crates/ledger-core/tests/spec_parties.rs @@ -0,0 +1,1137 @@ +//! Spec-derived tests for the party directory. +//! +//! Oracles: the spec block of `ledger_core::parties` (INVARIANTS 1–4), the +//! doc comments of `has_full_tin`, `check_text`, `W9Facts`/`W4Facts` and +//! their `check`, `Role::gets_1099`, `Party::label`/`all_roles`, +//! `name_key`, and the `Ledger` party methods (`add_party`, +//! `update_party`, `set_party_role`, `set_party_w9`/`w4`, +//! `set_entry_party`, `set_entry_vendor`, the vendor/customer auto-link, +//! `validate_payroll`), plus `docs/parties-and-attachments.md` "Parties". +//! Every expected value is worked by hand from those texts; nothing is read +//! back from the code under test and then asserted. + +use std::collections::{BTreeMap, BTreeSet}; +use std::str::FromStr; + +use chrono::NaiveDate; +use ledger_core::parties::{check_text, has_full_tin, name_key}; +use ledger_core::{ + AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId, + PayrollLine, Role, Side, W4Facts, W9Facts, +}; +use proptest::prelude::*; +use rust_decimal::Decimal; + +// ---------------------------------------------------------------- helpers + +fn d(s: &str) -> Decimal { + Decimal::from_str(s).expect("decimal literal") +} + +fn usd(s: &str) -> Money { + Money::new(d(s), Currency::USD) +} + +fn date(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).expect("valid date") +} + +struct Fx { + cash: AccountId, + posted: EntryId, + draft: EntryId, +} + +/// A USD ledger with cash, capital, an expense, one posted entry +/// (Dr 1000 / Cr 3000 $100.00) and one draft entry. +fn fixture() -> (Ledger, Fx) { + let mut l = Ledger::new(Currency::USD); + let cash = l + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let capital = l + .add_account("3000", "Owner Capital", AccountType::Equity, None, false) + .unwrap(); + let expense = l + .add_account( + "6050", + "Domain & Hosting", + AccountType::Expense, + None, + false, + ) + .unwrap(); + let posted = l.try_create_entry(date(2026, 1, 1), "Opening").unwrap(); + l.add_line(posted, cash, Side::Debit, usd("100.00")) + .unwrap(); + l.add_line(posted, capital, Side::Credit, usd("100.00")) + .unwrap(); + l.post_entry(posted).unwrap(); + let draft = l.try_create_entry(date(2026, 1, 2), "Hosting").unwrap(); + l.add_line(draft, expense, Side::Debit, usd("12.00")) + .unwrap(); + l.add_line(draft, cash, Side::Credit, usd("12.00")).unwrap(); + ( + l, + Fx { + cash, + posted, + draft, + }, + ) +} + +fn w9(last4: &str) -> W9Facts { + W9Facts { + received: date(2026, 3, 1), + entity_type: "individual/sole proprietor".to_string(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: last4.to_string(), + kept_at: "wanderbooks vault".to_string(), + vault_record: None, + } +} + +fn w4(state: &str) -> W4Facts { + W4Facts { + received: date(2026, 3, 1), + state: state.to_string(), + kept_at: "CPA portal".to_string(), + } +} + +fn party(l: &Ledger, id: PartyId) -> &Party { + l.get_party(id).expect("party exists") +} + +/// Run `f` on `l`; it must fail and leave `l` equal to before. +fn refused( + l: &mut Ledger, + what: &str, + f: impl FnOnce(&mut Ledger) -> Result, +) { + let before = l.clone(); + let r = f(l); + assert!(r.is_err(), "{what}: expected a refusal, got {:?}", r.ok()); + assert!(*l == before, "{what}: a refusal changed the ledger"); +} + +fn bare_party(name: &str) -> Party { + Party { + id: 1, + name: name.to_string(), + display_name: None, + roles: BTreeSet::new(), + website: None, + address: None, + notes: None, + archived: false, + w9: None, + w4: None, + vendor: None, + customer: None, + } +} + +fn pay_line(name: &str, employee: Option) -> PayrollLine { + PayrollLine { + employee_name: name.to_string(), + employee, + gross_pay: d("1000.00"), + federal_withholding: d("100.00"), + state_withholding: d("0"), + ss_employee: d("62.00"), + medicare_employee: d("14.50"), + ss_employer: d("62.00"), + medicare_employer: d("14.50"), + other_deductions: d("0"), + } +} + +// ------------------------------------------------- has_full_tin truth table + +/// Oracle: `has_full_tin` doc ("a run of nine digits, with at most one +/// space or hyphen between neighbors") and INVARIANT 1. +#[test] +fn has_full_tin_truth_table() { + let cases: &[(&str, bool)] = &[ + ("123456789", true), + ("123-45-6789", true), // SSN + ("12-3456789", true), // EIN + ("1234 5678 9", true), // single spaces + ("123 45-6789", true), // mixed single separators + ("1-2-3-4-5-6-7-8-9", true), + ("TIN 123456789 on file", true), // inside longer text + ("abc123456789def", true), + ("12 3456789", false), // two spaces break the run: 2 + 7 digits + ("12--3456789", false), // two hyphens + ("12 -3456789", false), // space and hyphen between neighbors + ("12\t3456789", false), // a tab is not a space + ("12345678", false), // eight digits + ("123-45-678", false), + ("1234", false), + ("", false), + ("Acme LLC", false), + ("Suite 100, 84101", false), // 3 + 5 digits, separated by ", " + ("-123456789-", true), + ]; + for (text, want) in cases { + assert_eq!(has_full_tin(text), *want, "has_full_tin({text:?})"); + } +} + +/// Oracle: INVARIANT 1 says "any run of nine digits"; a run of ten digits +/// holds a run of nine. +#[test] +fn has_full_tin_ten_digits_hold_nine() { + assert!(has_full_tin("1234567890")); + assert!(has_full_tin("801-555-12345")); +} + +/// Oracle: INVARIANT 1 ("No party field holds a full taxpayer +/// identification number"). Full-width digits are Unicode decimal digits; +/// a TIN typed with them is still a full TIN. +#[test] +fn has_full_tin_unicode_digits() { + assert!(has_full_tin("123456789")); + assert!(has_full_tin("١٢٣٤٥٦٧٨٩")); // Arabic-Indic digits +} + +// ---------------------------------------------------------------- check_text + +/// Oracle: `check_text` doc: not a full TIN, at most 500 characters, no +/// control characters. +#[test] +fn check_text_rules() { + assert!(check_text("notes", "Pays by ACH, net 30").is_ok()); + assert!(check_text("notes", "").is_ok()); + assert!(check_text("notes", &"a".repeat(500)).is_ok()); + assert!(check_text("notes", &"a".repeat(501)).is_err()); + // Characters, not bytes: 500 two-byte characters are 500 characters. + assert!(check_text("notes", &"é".repeat(500)).is_ok()); + assert!(check_text("notes", &"é".repeat(501)).is_err()); + assert!(check_text("notes", "nul\0").is_err()); + assert!(check_text("notes", "EIN 12-3456789").is_err()); + assert!(check_text("notes", "last four 6789").is_ok()); + let why = check_text("notes", "123456789").unwrap_err(); + assert!(!why.is_empty(), "a refusal says why"); +} + +// ---------------------------------------------------------------- facts + +/// Oracle: `W9Facts.tin_last4` doc ("exactly four digits") and +/// INVARIANT 1. +#[test] +fn w9_check_tin_last4_is_exactly_four_digits() { + assert!(w9("6789").check().is_ok()); + assert!(w9("0000").check().is_ok()); + for bad in ["", "678", "56789", "67a9", "67 9", "123456789", "6789 "] { + assert!(w9(bad).check().is_err(), "tin_last4 {bad:?}"); + } +} + +/// Oracle: INVARIANT 1: no party field (the W-9 facts' text included) +/// holds a full TIN. +#[test] +fn w9_check_refuses_full_tin_in_any_text() { + let mut f = w9("6789"); + f.entity_type = "LLC 12-3456789".to_string(); + assert!(f.check().is_err()); + let mut f = w9("6789"); + f.kept_at = "drawer 123456789".to_string(); + assert!(f.check().is_err()); + let mut f = w9("6789"); + f.exempt_payee_code = Some("123-45-6789".to_string()); + assert!(f.check().is_err()); + let mut f = w9("6789"); + f.fatca_code = Some("123456789".to_string()); + assert!(f.check().is_err()); + let mut f = w9("6789"); + f.exempt_payee_code = Some("5".to_string()); + f.fatca_code = Some("A".to_string()); + assert!(f.check().is_ok()); +} + +/// Oracle: `W4Facts.state` doc ("two letters") and INVARIANT 1. +#[test] +fn w4_check_state_two_letters() { + assert!(w4("UT").check().is_ok()); + assert!(w4("CA").check().is_ok()); + for bad in ["", "U", "UTA", "U1", "12", "U "] { + assert!(w4(bad).check().is_err(), "state {bad:?}"); + } + let mut f = w4("UT"); + f.kept_at = "SSN 123-45-6789".to_string(); + assert!(f.check().is_err()); +} + +// ---------------------------------------------------------------- roles + +/// Oracle: `Role::gets_1099` doc and INVARIANT 2 ("a 1099 role +/// (contractor or landlord)"). +#[test] +fn gets_1099_is_contractor_and_landlord_only() { + for r in Role::ALL { + let want = matches!(r, Role::Contractor | Role::Landlord); + assert_eq!(r.gets_1099(), want, "{r:?}"); + } +} + +/// Oracle: `Role::ALL` ("every role"), `Role::key` (wire name, e.g. +/// `payout_platform`), `Role::parse` ("the role named key"). +#[test] +fn role_keys_round_trip() { + let all: BTreeSet = Role::ALL.into_iter().collect(); + assert_eq!(all.len(), 9, "nine distinct roles"); + for r in [ + Role::Vendor, + Role::Contractor, + Role::Customer, + Role::Employee, + Role::Member, + Role::PayoutPlatform, + Role::TaxAgency, + Role::FinancialInstitution, + Role::Landlord, + ] { + assert!(all.contains(&r)); + assert_eq!(Role::parse(r.key()), Some(r)); + } + assert_eq!(Role::PayoutPlatform.key(), "payout_platform"); + assert_eq!(Role::TaxAgency.key(), "tax_agency"); + assert_eq!(Role::FinancialInstitution.key(), "financial_institution"); + assert_eq!(Role::Contractor.key(), "contractor"); + assert_eq!(Role::parse("boss"), None); + assert_eq!(Role::parse(""), None); +} + +// ---------------------------------------------------------------- Party + +/// Oracle: `Party::label` doc: the display name, else the name. +#[test] +fn label_prefers_display_name() { + let mut p = bare_party("Anthropic, PBC"); + assert_eq!(p.label(), "Anthropic, PBC"); + p.display_name = Some("Anthropic".to_string()); + assert_eq!(p.label(), "Anthropic"); +} + +/// Oracle: `Party::all_roles` doc: hand roles plus vendor and customer +/// when it has those records. +#[test] +fn all_roles_adds_record_roles() { + let mut p = bare_party("Acme"); + p.roles.insert(Role::Contractor); + assert_eq!(p.all_roles(), BTreeSet::from([Role::Contractor])); + p.vendor = Some(7); + assert_eq!( + p.all_roles(), + BTreeSet::from([Role::Contractor, Role::Vendor]) + ); + p.customer = Some(3); + assert_eq!( + p.all_roles(), + BTreeSet::from([Role::Contractor, Role::Vendor, Role::Customer]) + ); + p.vendor = None; + assert_eq!( + p.all_roles(), + BTreeSet::from([Role::Contractor, Role::Customer]) + ); +} + +/// Oracle: `name_key` doc: case and surrounding space ignored (inner +/// space is not surrounding space). +#[test] +fn name_key_ignores_case_and_surrounding_space_only() { + assert_eq!(name_key("Acme"), name_key(" ACME ")); + assert_eq!(name_key("acme llc"), name_key("Acme LLC\t")); + assert_ne!(name_key("Acme"), name_key("Ac me")); + assert_ne!(name_key("Acme"), name_key("Acme Inc")); +} + +// ---------------------------------------------------------------- add_party + +/// Oracle: add_party GOAL; parties() "every party, in id order"; +/// party_by_name ignoring case and surrounding space. +#[test] +fn add_party_creates_a_record() { + let (mut l, _) = fixture(); + let p = l + .add_party("Internal Revenue Service", &[Role::TaxAgency]) + .unwrap(); + let got = party(&l, p); + assert_eq!(got.id, p); + assert_eq!(got.name, "Internal Revenue Service"); + assert_eq!(got.roles, BTreeSet::from([Role::TaxAgency])); + assert!(!got.archived); + assert_eq!(got.w9, None); + assert_eq!(got.w4, None); + assert_eq!(got.vendor, None); + assert_eq!(got.customer, None); + assert_eq!( + l.party_by_name(" internal revenue SERVICE ").map(|x| x.id), + Some(p) + ); + let q = l + .add_party("Utah State Tax Commission", &[Role::TaxAgency]) + .unwrap(); + assert_ne!(p, q); + let ids: Vec = l.parties().map(|x| x.id).collect(); + let mut sorted = ids.clone(); + sorted.sort(); + assert_eq!(ids, sorted, "parties() is in id order"); + assert!(ids.contains(&p) && ids.contains(&q)); +} + +/// Oracle: add_party SCENARIO 1. +#[test] +fn add_party_refuses_blank_tin_or_taken_names() { + let (mut l, _) = fixture(); + l.add_party("Apple", &[Role::PayoutPlatform]).unwrap(); + refused(&mut l, "blank", |l| l.add_party("", &[])); + refused(&mut l, "spaces", |l| l.add_party(" ", &[])); + refused(&mut l, "TIN", |l| l.add_party("Bob 123-45-6789", &[])); + refused(&mut l, "same name", |l| l.add_party("Apple", &[])); + refused(&mut l, "case", |l| l.add_party("APPLE", &[])); + refused(&mut l, "space", |l| l.add_party(" apple ", &[])); + assert!(l.check_add_party("Apple", &[]).is_err()); + assert!(l.check_add_party("Google", &[]).is_ok()); +} + +/// Oracle: add_party SCENARIO 2. +#[test] +fn add_party_refuses_vendor_and_customer_roles() { + let (mut l, _) = fixture(); + refused(&mut l, "vendor", |l| l.add_party("Acme", &[Role::Vendor])); + refused(&mut l, "customer", |l| { + l.add_party("Acme", &[Role::Member, Role::Customer]) + }); + assert!(l.check_add_party("Acme", &[Role::Vendor]).is_err()); +} + +/// Oracle: INVARIANT 3 ("among parties not archived"): an archived +/// party's name is free again. +#[test] +fn add_party_may_reuse_an_archived_name() { + let (mut l, _) = fixture(); + let old = l.add_party("Landlord Co", &[Role::Landlord]).unwrap(); + l.update_party(old, PartyField::Archived(true)).unwrap(); + let new = l.add_party("landlord co", &[Role::Landlord]).unwrap(); + assert_ne!(old, new); + assert_eq!(l.party_by_name("Landlord Co").map(|p| p.id), Some(new)); + assert!(party(&l, old).archived); +} + +/// Oracle: check_add_party is "the validation behind add_party": it +/// agrees with add_party and changes nothing. +#[test] +fn check_add_party_agrees_with_add_party() { + let (mut l, _) = fixture(); + l.add_party("Taken", &[]).unwrap(); + for (name, roles) in [ + ("Taken", vec![]), + ("Fresh", vec![Role::Member]), + ("", vec![]), + ("Fresh2", vec![Role::Customer]), + ("x 123456789", vec![]), + ] { + let before = l.clone(); + let check = l.check_add_party(name, &roles); + assert!(l == before); + let mut trial = l.clone(); + let real = trial.add_party(name, &roles); + assert_eq!(check.is_ok(), real.is_ok(), "{name:?}"); + } +} + +// ---------------------------------------------------------------- update_party + +/// Oracle: PartyField docs; update_party "change one of a party's facts". +#[test] +fn update_party_changes_one_fact() { + let (mut l, _) = fixture(); + let p = l.add_party("Anthropic, PBC", &[Role::Contractor]).unwrap(); + l.update_party(p, PartyField::DisplayName(Some("Anthropic".into()))) + .unwrap(); + assert_eq!(party(&l, p).display_name.as_deref(), Some("Anthropic")); + assert_eq!(party(&l, p).label(), "Anthropic"); + l.update_party(p, PartyField::Website(Some("https://anthropic.com".into()))) + .unwrap(); + l.update_party( + p, + PartyField::Address(Some("548 Market St, San Francisco".into())), + ) + .unwrap(); + l.update_party(p, PartyField::Notes(Some("API billing".into()))) + .unwrap(); + let got = party(&l, p); + assert_eq!(got.website.as_deref(), Some("https://anthropic.com")); + assert_eq!(got.address.as_deref(), Some("548 Market St, San Francisco")); + assert_eq!(got.notes.as_deref(), Some("API billing")); + assert_eq!(got.name, "Anthropic, PBC"); + l.update_party(p, PartyField::DisplayName(None)).unwrap(); + assert_eq!(party(&l, p).display_name, None); + assert_eq!(party(&l, p).label(), "Anthropic, PBC"); + l.update_party(p, PartyField::Website(None)).unwrap(); + assert_eq!(party(&l, p).website, None); + l.update_party(p, PartyField::Name("Anthropic PBC".into())) + .unwrap(); + assert_eq!(party(&l, p).name, "Anthropic PBC"); + assert_eq!(l.party_by_name("anthropic pbc").map(|x| x.id), Some(p)); + assert!(l.party_by_name("Anthropic, PBC").is_none()); + l.update_party(p, PartyField::Archived(true)).unwrap(); + assert!(party(&l, p).archived); + assert!( + l.party_by_name("Anthropic PBC").is_none(), + "party_by_name skips archived" + ); + l.update_party(p, PartyField::Archived(false)).unwrap(); + assert!(!party(&l, p).archived); +} + +/// Oracle: update_party SCENARIO 1. +#[test] +fn update_party_refusals_change_nothing() { + let (mut l, _) = fixture(); + let a = l.add_party("Alpha", &[]).unwrap(); + l.add_party("Beta", &[]).unwrap(); + let unknown = l.next_party_id() + 10; + refused(&mut l, "unknown", |l| { + l.update_party(unknown, PartyField::Notes(None)) + }); + refused(&mut l, "TIN in notes", |l| { + l.update_party(a, PartyField::Notes(Some("SSN 123-45-6789".into()))) + }); + refused(&mut l, "TIN in website", |l| { + l.update_party(a, PartyField::Website(Some("x.com/123456789".into()))) + }); + refused(&mut l, "TIN in address", |l| { + l.update_party(a, PartyField::Address(Some("12-3456789 Main".into()))) + }); + refused(&mut l, "TIN in display name", |l| { + l.update_party(a, PartyField::DisplayName(Some("123456789".into()))) + }); + refused(&mut l, "TIN in name", |l| { + l.update_party(a, PartyField::Name("Alpha 123456789".into())) + }); + refused(&mut l, "too long", |l| { + l.update_party(a, PartyField::Notes(Some("n".repeat(501)))) + }); + refused(&mut l, "control char", |l| { + l.update_party(a, PartyField::Notes(Some("a\u{7}b".into()))) + }); + refused(&mut l, "blank name", |l| { + l.update_party(a, PartyField::Name(" ".into())) + }); + refused(&mut l, "taken name", |l| { + l.update_party(a, PartyField::Name("Beta".into())) + }); + refused(&mut l, "taken name, case/space", |l| { + l.update_party(a, PartyField::Name(" BETA ".into())) + }); + assert!( + l.check_update_party(a, &PartyField::Name("beta".into())) + .is_err() + ); + assert!( + l.check_update_party(a, &PartyField::Name("Gamma".into())) + .is_ok() + ); +} + +/// Oracle: INVARIANT 3: un-archiving a party whose name another live party +/// now holds would make two live parties share a name. +#[test] +fn unarchiving_into_a_taken_name_is_refused() { + let (mut l, _) = fixture(); + let old = l.add_party("Acme", &[]).unwrap(); + l.update_party(old, PartyField::Archived(true)).unwrap(); + l.add_party("ACME", &[]).unwrap(); + refused(&mut l, "unarchive", |l| { + l.update_party(old, PartyField::Archived(false)) + }); +} + +/// Oracle: INVARIANT 3 counts only parties not archived: renaming onto an +/// archived party's name is fine. +#[test] +fn rename_onto_archived_name_is_allowed() { + let (mut l, _) = fixture(); + let old = l.add_party("Acme", &[]).unwrap(); + l.update_party(old, PartyField::Archived(true)).unwrap(); + let b = l.add_party("Beta", &[]).unwrap(); + l.update_party(b, PartyField::Name("acme".into())).unwrap(); + assert_eq!(l.party_by_name("Acme").map(|p| p.id), Some(b)); +} + +// ---------------------------------------------------------------- roles + facts + +/// Oracle: set_party_role SCENARIO 1. +#[test] +fn set_party_role_refuses_record_roles() { + let (mut l, _) = fixture(); + let p = l.add_party("Acme", &[]).unwrap(); + for on in [true, false] { + refused(&mut l, "vendor", |l| l.set_party_role(p, Role::Vendor, on)); + refused(&mut l, "customer", |l| { + l.set_party_role(p, Role::Customer, on) + }); + } + let unknown = l.next_party_id() + 5; + refused(&mut l, "unknown party", |l| { + l.set_party_role(unknown, Role::Member, true) + }); +} + +/// Oracle: "give a party a role, or take one away". +#[test] +fn set_party_role_gives_and_takes() { + let (mut l, _) = fixture(); + let p = l.add_party("Kat", &[]).unwrap(); + l.set_party_role(p, Role::Member, true).unwrap(); + l.set_party_role(p, Role::Employee, true).unwrap(); + assert_eq!( + party(&l, p).roles, + BTreeSet::from([Role::Member, Role::Employee]) + ); + l.set_party_role(p, Role::Member, false).unwrap(); + assert_eq!(party(&l, p).roles, BTreeSet::from([Role::Employee])); +} + +/// Oracle: INVARIANT 2 and set_party_w9 SCENARIO 1: W-9 facts need a 1099 +/// role. +#[test] +fn w9_needs_a_1099_role() { + let (mut l, _) = fixture(); + let p = l.add_party("Dev Contractor", &[Role::Member]).unwrap(); + refused(&mut l, "no 1099 role", |l| { + l.set_party_w9(p, Some(w9("6789"))) + }); + assert!(l.check_set_party_w9(p, Some(&w9("6789"))).is_err()); + l.set_party_role(p, Role::Contractor, true).unwrap(); + refused(&mut l, "bad facts", |l| l.set_party_w9(p, Some(w9("67")))); + l.set_party_w9(p, Some(w9("6789"))).unwrap(); + assert_eq!(party(&l, p).w9, Some(w9("6789"))); + let unknown = l.next_party_id() + 3; + refused(&mut l, "unknown", |l| { + l.set_party_w9(unknown, Some(w9("6789"))) + }); + l.set_party_w9(p, None).unwrap(); + assert_eq!(party(&l, p).w9, None); + + let landlord = l.add_party("Office Landlord", &[Role::Landlord]).unwrap(); + l.set_party_w9(landlord, Some(w9("1111"))).unwrap(); + assert_eq!(party(&l, landlord).w9, Some(w9("1111"))); +} + +/// Oracle: set_party_role SCENARIO 2 (and INVARIANT 2). +#[test] +fn removing_the_last_1099_role_with_w9_is_refused() { + let (mut l, _) = fixture(); + let p = l + .add_party("Both", &[Role::Contractor, Role::Landlord]) + .unwrap(); + l.set_party_w9(p, Some(w9("6789"))).unwrap(); + // Another 1099 role remains: allowed. + l.set_party_role(p, Role::Contractor, false).unwrap(); + // The last one: refused. + refused(&mut l, "last 1099 role", |l| { + l.set_party_role(p, Role::Landlord, false) + }); + assert!(l.check_set_party_role(p, Role::Landlord, false).is_err()); + // Clear the facts first, then it goes. + l.set_party_w9(p, None).unwrap(); + l.set_party_role(p, Role::Landlord, false).unwrap(); + assert!(party(&l, p).roles.is_empty()); +} + +/// Oracle: INVARIANT 2 (W-4 facts need employee) and set_party_role +/// SCENARIO 2. +#[test] +fn w4_needs_employee() { + let (mut l, _) = fixture(); + let p = l.add_party("Alice", &[Role::Contractor]).unwrap(); + refused(&mut l, "no employee role", |l| { + l.set_party_w4(p, Some(w4("UT"))) + }); + assert!(l.check_set_party_w4(p, Some(&w4("UT"))).is_err()); + l.set_party_role(p, Role::Employee, true).unwrap(); + refused(&mut l, "bad state", |l| l.set_party_w4(p, Some(w4("Utah")))); + l.set_party_w4(p, Some(w4("UT"))).unwrap(); + assert_eq!(party(&l, p).w4, Some(w4("UT"))); + refused(&mut l, "drop employee with W-4", |l| { + l.set_party_role(p, Role::Employee, false) + }); + l.set_party_w4(p, None).unwrap(); + l.set_party_role(p, Role::Employee, false).unwrap(); + assert_eq!(party(&l, p).roles, BTreeSet::from([Role::Contractor])); +} + +// ---------------------------------------------------------------- auto-link + +/// Oracle: the auto-link doc ("the party not archived with that name ... +/// that has no such record yet, else a new party"); party_of_vendor / +/// party_of_customer; all_roles. +#[test] +fn vendor_and_customer_with_the_same_name_share_one_party() { + let (mut l, _) = fixture(); + let v = l.add_vendor("Acme").unwrap(); + let pv = l.party_of_vendor(v).expect("vendor has a party"); + assert_eq!(party(&l, pv).vendor, Some(v)); + assert!(party(&l, pv).all_roles().contains(&Role::Vendor)); + let c = l.add_customer(" acme ").unwrap(); + let pc = l.party_of_customer(c).expect("customer has a party"); + assert_eq!(pc, pv, "same name: one party"); + let got = party(&l, pv); + assert_eq!(got.vendor, Some(v)); + assert_eq!(got.customer, Some(c)); + assert!(got.all_roles().contains(&Role::Customer)); + assert!(got.all_roles().contains(&Role::Vendor)); + assert_eq!(l.parties().count(), 1); +} + +/// Oracle: the auto-link doc: a hand-added party with the name and no +/// vendor record absorbs the new vendor. +#[test] +fn hand_added_party_absorbs_vendor_ignoring_case_and_space() { + let (mut l, _) = fixture(); + let p = l.add_party("Gamma Design", &[Role::Contractor]).unwrap(); + let v = l.add_vendor(" GAMMA design ").unwrap(); + assert_eq!(l.party_of_vendor(v), Some(p)); + assert_eq!(party(&l, p).vendor, Some(v)); + assert_eq!( + party(&l, p).all_roles(), + BTreeSet::from([Role::Contractor, Role::Vendor]) + ); +} + +/// Oracle: the auto-link doc ("not archived"). +#[test] +fn archived_parties_do_not_absorb() { + let (mut l, _) = fixture(); + let p = l.add_party("Beta", &[]).unwrap(); + l.update_party(p, PartyField::Archived(true)).unwrap(); + let v = l.add_vendor("Beta").unwrap(); + let pv = l.party_of_vendor(v).unwrap(); + assert_ne!(pv, p); + assert_eq!(party(&l, p).vendor, None); + assert!(!party(&l, pv).archived); +} + +/// Oracle: the auto-link doc ("that has no such record yet, else a new +/// party"). +#[test] +fn a_second_vendor_with_the_same_name_gets_a_new_party() { + let (mut l, _) = fixture(); + let v1 = l.add_vendor("Delta").unwrap(); + let v2 = l.add_vendor("Delta").unwrap(); + assert_ne!(v1, v2); + let p1 = l.party_of_vendor(v1).unwrap(); + let p2 = l.party_of_vendor(v2).unwrap(); + assert_ne!(p1, p2); + assert_eq!(party(&l, p1).vendor, Some(v1)); + assert_eq!(party(&l, p2).vendor, Some(v2)); + assert_eq!( + party(&l, p2).name, + "Delta (2)", + "INVARIANT 3: names stay unique" + ); +} + +/// Oracle: INVARIANT 3 ("Names are unique ... among parties not +/// archived") applied after the auto-link's "else a new party". +#[test] +fn invariant_3_holds_after_a_second_same_name_vendor() { + let (mut l, _) = fixture(); + l.add_vendor("Delta").unwrap(); + let r = l.add_vendor("Delta"); + if r.is_ok() { + let live: Vec = l + .parties() + .filter(|p| !p.archived) + .map(|p| name_key(&p.name)) + .collect(); + let set: BTreeSet<&String> = live.iter().collect(); + assert_eq!(set.len(), live.len(), "live names unique: {live:?}"); + } +} + +/// Oracle: INVARIANT 4 after mixed auto-linking. +#[test] +fn every_record_links_to_exactly_one_party() { + let (mut l, _) = fixture(); + l.add_party("Echo", &[]).unwrap(); + for n in ["Echo", "Foxtrot", "echo", "Golf"] { + l.add_vendor(n).unwrap(); + } + for n in ["Golf", "Hotel", "ECHO"] { + l.add_customer(n).unwrap(); + } + check_invariants(&l); +} + +/// Oracle: check_text doc ("no control characters except newline"): +/// addresses run several lines; a tab is refused. +#[test] +fn check_text_newline_yes_tab_no() { + assert!(check_text("address", "line one\nline two").is_ok()); + assert!(check_text("notes", "tab\there").is_err()); +} + +/// Oracle: INVARIANT 1 ("No party field holds a full taxpayer +/// identification number: any run of nine digits ... is refused"): the +/// party the auto-link makes for a vendor is a party; its name is a party +/// field. +#[test] +fn add_vendor_name_with_full_tin_is_refused() { + let (mut l, _) = fixture(); + let before = l.clone(); + if l.add_vendor("Bob 123-45-6789").is_ok() { + assert!( + l.parties().all(|p| !has_full_tin(&p.name)), + "a party holds a full TIN" + ); + } else { + assert!(l == before); + } + let mut l = before; + if l.add_customer("Carol 123456789").is_ok() { + assert!(l.parties().all(|p| !has_full_tin(&p.name))); + } +} + +// ---------------------------------------------------------------- entry tags + +/// Oracle: set_entry_party doc: a party with a vendor record also tags the +/// entry's vendor; metadata, so posted entries may be tagged and no amount +/// changes. +#[test] +fn set_entry_party_tags_the_vendor() { + let (mut l, fx) = fixture(); + let v = l.add_vendor("Hosting Co").unwrap(); + let p = l.party_of_vendor(v).unwrap(); + let cash_before = l.account_balance(fx.cash).unwrap(); + l.set_entry_party(fx.posted, Some(p)).unwrap(); + assert_eq!(l.entry_party(fx.posted), Some(p)); + assert_eq!(l.entry_vendor(fx.posted), Some(v)); + assert_eq!(l.account_balance(fx.cash).unwrap(), cash_before); + l.set_entry_party(fx.draft, Some(p)).unwrap(); + assert_eq!(l.entry_vendor(fx.draft), Some(v)); + l.set_entry_party(fx.posted, None).unwrap(); + assert_eq!(l.entry_party(fx.posted), None); +} + +/// Oracle: set_entry_party doc ("as set_entry_vendor does the other way +/// round") and the task's "set_entry_vendor now also tags the party". +#[test] +fn set_entry_vendor_tags_the_party() { + let (mut l, fx) = fixture(); + let v = l.add_vendor("Registrar").unwrap(); + let p = l.party_of_vendor(v).unwrap(); + l.set_entry_vendor(fx.posted, Some(v)).unwrap(); + assert_eq!(l.entry_vendor(fx.posted), Some(v)); + assert_eq!(l.entry_party(fx.posted), Some(p)); +} + +/// Oracle: docs "an entry has one counterparty": retagging an entry with a +/// party that has no vendor record leaves no other party's vendor tag. +#[test] +fn retagging_to_a_party_without_vendor_drops_the_old_vendor() { + let (mut l, fx) = fixture(); + let v = l.add_vendor("Old Vendor").unwrap(); + let irs = l.add_party("IRS", &[Role::TaxAgency]).unwrap(); + l.set_entry_vendor(fx.posted, Some(v)).unwrap(); + l.set_entry_party(fx.posted, Some(irs)).unwrap(); + assert_eq!(l.entry_party(fx.posted), Some(irs)); + assert_eq!(l.entry_vendor(fx.posted), None); +} + +/// Oracle: set_entry_vendor SCENARIO 1 and "refusals change nothing". +#[test] +fn entry_tag_refusals() { + let (mut l, fx) = fixture(); + let p = l.add_party("Someone", &[]).unwrap(); + let missing_entry = l.next_entry_id() + 9; + let missing_party = l.next_party_id() + 9; + refused(&mut l, "unknown entry", |l| { + l.set_entry_party(missing_entry, Some(p)) + }); + refused(&mut l, "unknown party", |l| { + l.set_entry_party(fx.posted, Some(missing_party)) + }); + refused(&mut l, "unknown vendor", |l| { + l.set_entry_vendor(fx.posted, Some(9999)) + }); + refused(&mut l, "unknown entry (vendor)", |l| { + l.set_entry_vendor(missing_entry, None) + }); +} + +// ---------------------------------------------------------------- payroll + +/// Oracle: validate_payroll: "a line naming an employee needs a party with +/// the employee role"; payroll.rs: runs from before carry the name only. +#[test] +fn payroll_employee_must_have_the_employee_role() { + let (mut l, fx) = fixture(); + let alice = l.add_party("Alice", &[Role::Employee]).unwrap(); + let bob = l.add_party("Bob", &[Role::Contractor]).unwrap(); + let unknown = l.next_party_id() + 4; + let day = date(2026, 2, 27); + assert!( + l.validate_payroll(day, &[pay_line("Alice", Some(alice))], fx.cash) + .is_ok() + ); + assert!( + l.validate_payroll(day, &[pay_line("Old", None)], fx.cash) + .is_ok() + ); + assert!( + l.validate_payroll(day, &[pay_line("Bob", Some(bob))], fx.cash) + .is_err() + ); + assert!( + l.validate_payroll(day, &[pay_line("Ghost", Some(unknown))], fx.cash) + .is_err() + ); + assert!( + l.validate_payroll( + day, + &[pay_line("Alice", Some(alice)), pay_line("Bob", Some(bob))], + fx.cash + ) + .is_err() + ); + refused(&mut l, "run with non-employee", |l| { + l.run_payroll(day, vec![pay_line("Bob", Some(bob))], fx.cash) + }); + let run = l + .run_payroll(day, vec![pay_line("Alice", Some(alice))], fx.cash) + .unwrap(); + let rec = l.get_payroll_run(run).unwrap(); + assert_eq!(rec.lines[0].employee, Some(alice)); +} + +// ---------------------------------------------------------------- invariants + +/// INVARIANTS 1–4 plus the vendor/party tag sync, over the whole ledger. +fn check_invariants(l: &Ledger) { + let parties: Vec<&Party> = l.parties().collect(); + // 1. No full TIN in any party field; tin_last4 four digits. + for p in &parties { + let mut texts = vec![p.name.clone()]; + texts.extend(p.display_name.clone()); + texts.extend(p.website.clone()); + texts.extend(p.address.clone()); + texts.extend(p.notes.clone()); + if let Some(f) = &p.w9 { + texts.push(f.entity_type.clone()); + texts.push(f.kept_at.clone()); + texts.extend(f.exempt_payee_code.clone()); + texts.extend(f.fatca_code.clone()); + assert!( + f.tin_last4.len() == 4 && f.tin_last4.chars().all(|c| c.is_ascii_digit()), + "INV1 tin_last4 {:?}", + f.tin_last4 + ); + } + if let Some(f) = &p.w4 { + texts.push(f.state.clone()); + texts.push(f.kept_at.clone()); + } + for t in texts { + assert!(!has_full_tin(&t), "INV1: party {} holds {t:?}", p.id); + } + // 2. + if p.w9.is_some() { + assert!(p.roles.iter().any(|r| r.gets_1099()), "INV2 w9 on {}", p.id); + } + if p.w4.is_some() { + assert!(p.roles.contains(&Role::Employee), "INV2 w4 on {}", p.id); + } + } + // 3. + let mut live: BTreeMap> = BTreeMap::new(); + for p in parties.iter().filter(|p| !p.archived) { + live.entry(name_key(&p.name)).or_default().push(p); + } + for (k, group) in &live { + assert!( + group.len() == 1, + "INV3: live name {k:?} shared by {:?}", + group.iter().map(|p| p.id).collect::>() + ); + } + // 4. + for v in l.list_vendors() { + let owners: Vec = parties + .iter() + .filter(|p| p.vendor == Some(v.id)) + .map(|p| p.id) + .collect(); + assert_eq!(owners.len(), 1, "INV4 vendor {}", v.id); + assert_eq!(l.party_of_vendor(v.id), Some(owners[0])); + } + for c in l.list_customers() { + let owners: Vec = parties + .iter() + .filter(|p| p.customer == Some(c.id)) + .map(|p| p.id) + .collect(); + assert_eq!(owners.len(), 1, "INV4 customer {}", c.id); + assert_eq!(l.party_of_customer(c.id), Some(owners[0])); + } + // Tag sync: an entry's vendor tag belongs to its party. + for e in 0..l.next_entry_id() { + if let Some(v) = l.entry_vendor(e) { + assert_eq!( + l.entry_party(e), + l.party_of_vendor(v), + "entry {e}: vendor tag and party tag disagree" + ); + } + } +} + +// ---------------------------------------------------------------- proptest + +const NAMES: &[&str] = &[ + "Acme", + " acme ", + "ACME", + "Beta", + "beta", + "Gamma", + "", + " ", + "Tin 123456789", + "Delta", +]; + +/// Indices into NAMES used for vendor/customer records (all of them). +const RECORD_NAMES: &[usize] = &[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]; + +#[derive(Clone, Debug)] +enum Cmd { + AddParty(usize, Vec), + Update(usize, PartyField), + Role(usize, Role, bool), + W9(usize, Option), + W4(usize, Option), + EntryParty(usize, Option), + EntryVendor(usize, Option), + Vendor(usize), + Customer(usize), +} + +fn role_strategy() -> impl Strategy { + prop::sample::select(Role::ALL.to_vec()) +} + +fn text_strategy() -> impl Strategy> { + prop::option::of(prop::sample::select(vec![ + "ok".to_string(), + "SSN 123-45-6789".to_string(), + "x".repeat(501), + "bell\u{7}".to_string(), + "Suite 100".to_string(), + ])) +} + +fn field_strategy() -> impl Strategy { + prop_oneof![ + (0..NAMES.len()).prop_map(|i| PartyField::Name(NAMES[i].to_string())), + text_strategy().prop_map(PartyField::DisplayName), + text_strategy().prop_map(PartyField::Website), + text_strategy().prop_map(PartyField::Address), + text_strategy().prop_map(PartyField::Notes), + any::().prop_map(PartyField::Archived), + ] +} + +fn cmd_strategy() -> impl Strategy { + prop_oneof![ + (0..NAMES.len(), prop::collection::vec(role_strategy(), 0..3)) + .prop_map(|(n, r)| Cmd::AddParty(n, r)), + (0..8usize, field_strategy()).prop_map(|(p, f)| Cmd::Update(p, f)), + (0..8usize, role_strategy(), any::()).prop_map(|(p, r, on)| Cmd::Role(p, r, on)), + ( + 0..8usize, + prop::option::of(prop::sample::select(vec!["6789", "67", "123456789"])) + ) + .prop_map(|(p, f)| Cmd::W9(p, f.map(w9))), + ( + 0..8usize, + prop::option::of(prop::sample::select(vec!["UT", "Utah"])) + ) + .prop_map(|(p, f)| Cmd::W4(p, f.map(w4))), + (0..3usize, prop::option::of(0..8usize)).prop_map(|(e, p)| Cmd::EntryParty(e, p)), + (0..3usize, prop::option::of(0..5usize)).prop_map(|(e, v)| Cmd::EntryVendor(e, v)), + prop::sample::select(RECORD_NAMES.to_vec()).prop_map(Cmd::Vendor), + prop::sample::select(RECORD_NAMES.to_vec()).prop_map(Cmd::Customer), + ] +} + +/// Apply `cmd`, returning whether it was accepted. Party and vendor +/// indices pick among existing ids (or one past them, to hit "unknown"). +fn apply(l: &mut Ledger, fx: &Fx, cmd: &Cmd) -> bool { + let party_ids: Vec = l.parties().map(|p| p.id).collect(); + let pick_party = |i: usize| -> PartyId { + party_ids + .get(i) + .copied() + .unwrap_or_else(|| l.next_party_id() + i as u64) + }; + let vendor_ids: Vec = l.list_vendors().iter().map(|v| v.id).collect(); + let pick_vendor = |i: usize| -> u64 { vendor_ids.get(i).copied().unwrap_or(10_000 + i as u64) }; + let entries = [fx.posted, fx.draft, 9_999]; + match cmd { + Cmd::AddParty(n, roles) => l.add_party(NAMES[*n], roles).is_ok(), + Cmd::Update(p, f) => l.update_party(pick_party(*p), f.clone()).is_ok(), + Cmd::Role(p, r, on) => l.set_party_role(pick_party(*p), *r, *on).is_ok(), + Cmd::W9(p, f) => l.set_party_w9(pick_party(*p), f.clone()).is_ok(), + Cmd::W4(p, f) => l.set_party_w4(pick_party(*p), f.clone()).is_ok(), + Cmd::EntryParty(e, p) => l.set_entry_party(entries[*e], p.map(pick_party)).is_ok(), + Cmd::EntryVendor(e, v) => l.set_entry_vendor(entries[*e], v.map(pick_vendor)).is_ok(), + Cmd::Vendor(n) => l.add_vendor(NAMES[*n]).is_ok(), + Cmd::Customer(n) => l.add_customer(NAMES[*n]).is_ok(), + } +} + +proptest! { + #![proptest_config(ProptestConfig::with_cases(256))] + + /// Oracle: INVARIANTS 1–4 hold after every accepted command; every + /// refusal "changes nothing" (the add/update/role SCENARIOS). + #[test] + fn random_commands_keep_the_invariants(cmds in prop::collection::vec(cmd_strategy(), 1..40)) { + let (mut l, fx) = fixture(); + for cmd in &cmds { + let before = l.clone(); + let ok = apply(&mut l, &fx, cmd); + if ok { + check_invariants(&l); + } else { + prop_assert!(l == before, "refused {:?} changed the ledger", cmd); + } + } + } +} + +/// Oracle: W9Facts.vault_record "SHA-256 of the vault record": a hash is +/// hex, and hex can hold nine digits in a row; that is not a TIN. +#[test] +fn a_vault_record_hash_with_nine_digits_is_accepted() { + let facts = W9Facts { + received: chrono::NaiveDate::from_ymd_opt(2026, 10, 15).unwrap(), + entity_type: "individual/sole proprietor".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: "6789".into(), + kept_at: "wanderbooks vault".into(), + vault_record: Some(format!("123456789{}", "a".repeat(55))), + }; + assert_eq!(facts.check(), Ok(())); +} diff --git a/crates/ledger-core/tests/spec_roles.rs b/crates/ledger-core/tests/spec_roles.rs index 53ba7db..48f1f8b 100644 --- a/crates/ledger-core/tests/spec_roles.rs +++ b/crates/ledger-core/tests/spec_roles.rs @@ -224,6 +224,7 @@ fn refuses(l: &mut Ledger, key: &str, account: Option) -> LedgerError fn alice() -> PayrollLine { PayrollLine { employee_name: "Alice".to_string(), + employee: None, gross_pay: dec("5000.00"), federal_withholding: dec("600.00"), state_withholding: dec("250.00"), diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index 6b9e60e..bac9c0f 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -22,6 +22,7 @@ pub struct PageState { pub reports: pages::reports::State, pub accounts: pages::accounts::State, pub assets: pages::assets::State, + pub parties: pages::parties::State, pub documents: pages::documents::State, pub journal: pages::journal::State, } @@ -123,6 +124,7 @@ impl App { "reports" => Route::Reports(ReportKind::ProfitAndLoss), "accounts" => Route::Accounts, "assets" => Route::Assets, + "parties" => Route::Parties, "documents" => Route::Documents, "company" => Route::Company, "journal" => Route::Journal, @@ -191,6 +193,12 @@ impl App { } Route::Asset(asset_id) } + T::Party { party_id } => { + if b.ledger.get_party(party_id).is_none() { + return; + } + Route::Party(party_id) + } T::Search { query } => { let st = &mut self.state.transactions; st.query = query; diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index d37caa1..eafd3c9 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -2,7 +2,9 @@ //! and recurring entries due, open past periods, uncategorized tax lines, //! 1099 vendors without a W-9, overdue bills and invoices, money owed to //! owners, fixed asset depreciation not posted, assets without a tag or a -//! tax basis, and legal rules (like the 1099 threshold) due for an update. Each item says what's wrong, where to look, and the prompt that +//! tax basis, contractors and landlords paid without W-9 facts, and legal +//! rules (like the 1099 threshold) due for an update. Each item says +//! what's wrong, where to look, and the prompt that //! asks the agent to fix it (the GUI never changes the books itself). use crate::books::{Attention, Books, Severity}; @@ -38,7 +40,14 @@ use rust_decimal::Decimal; /// disagreeing today (unregistered_cost != 0: Problem); an active /// asset in service with no tag (Todo, one per asset); an active /// asset in service with no tax basis (Asset::tax_basis is None: -/// Todo, one per asset, its prompt naming books.set_asset_tax_basis). +/// Todo, one per asset, its prompt naming books.set_asset_tax_basis); +/// a party not archived whose W-9 is Missing (pages::parties:: +/// w9_status: it files a 1099, has no W-9 facts, and its vendor +/// record doesn't say one is on file) and that was paid this year +/// (PartyInfo::paid_ytd > 0), +/// unless its vendor is already a 1099 vendor without a W-9 above +/// (Todo, one per party, its prompt from +/// pages::parties::collect_w9_prompt, naming books.set_w9). /// 3. Nothing else: books with none of those yield no items. /// 4. Every item's prompt starts "In wanderbooks," or names /// wanderbooks, and its amounts are the books' exact amounts. @@ -229,6 +238,8 @@ pub fn find(b: &Books) -> Vec { if today.month() == 1 { years.insert(0, today.year() - 1); } + // Vendors the "needs a W-9" item already names this year. + let mut asked_w9 = std::collections::HashSet::new(); for year in years { // A year whose threshold isn't known is flagged by the stale-rule // item above; never guess it here. @@ -236,6 +247,10 @@ pub fn find(b: &Books) -> Vec { continue; }; for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) { + if year == today.year() { + asked_w9.insert(v.vendor_id); + } + let party = b.ledger.party_of_vendor(v.vendor_id); out.push(Attention { severity: Severity::Problem, title: format!("{} needs a W-9", v.name), @@ -244,7 +259,7 @@ pub fn find(b: &Books) -> Vec { fmt::dollars(v.paid), fmt::dollars(r.threshold) ), - target: Some(Route::Company), + target: Some(party.map_or(Route::Company, Route::Party)), prompt: format!( "Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.", v.name @@ -253,6 +268,34 @@ pub fn find(b: &Books) -> Vec { } } + for p in b.ledger.parties().filter(|p| { + !p.archived + && crate::pages::parties::w9_status(b, p) == crate::pages::parties::W9Status::Missing + && !p.vendor.is_some_and(|v| asked_w9.contains(&v)) + }) { + let Some(info) = b.party(p.id).filter(|i| i.paid_ytd > Decimal::ZERO) else { + continue; + }; + out.push(Attention { + severity: Severity::Todo, + title: format!("Collect a W-9 from {}", p.label()), + detail: format!( + "Paid {} in {} as {}, with no W-9 facts recorded: collect a W-9 before the next payment.", + fmt::dollars(info.paid_ytd), + today.year(), + if p.roles.contains(&ledger_core::Role::Landlord) + && !p.roles.contains(&ledger_core::Role::Contractor) + { + "a landlord" + } else { + "a contractor" + } + ), + target: Some(Route::Party(p.id)), + prompt: crate::pages::parties::collect_w9_prompt(b, p), + }); + } + let overdue = |rows: Vec| -> (usize, Decimal) { let late: Vec = rows .iter() diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 4664d11..086754c 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -8,8 +8,8 @@ use ledger_config::BooksSettings; use ledger_core::analysis::{balance_history, balance_series}; use ledger_core::reports::{balance_sheet_as_of, profit_and_loss_for_period}; use ledger_core::{ - AccountId, AccountType, AssetId, AssetRegister, AssetStatus, EntryId, EntryStatus, Ledger, - Side, fixed_asset_register, + AccountId, AccountType, AssetId, AssetRegister, AssetStatus, BillId, EntryId, EntryStatus, + InvoiceId, Ledger, Party, PartyId, PayrollRunId, Side, Vendor1099Line, fixed_asset_register, }; use ledger_store::{Attachment, JournalLine, Snapshot}; use rust_decimal::Decimal; @@ -42,8 +42,12 @@ pub struct EntryInfo { /// Net effect on cash accounts: positive in, negative out, zero if the /// entry doesn't touch cash. pub cash_effect: Decimal, - /// The customer or vendor, from invoices, bills, and vendor tags. + /// The counterparty's name: its directory party's label (see + /// [`PartyInfo`] for which entries are a party's), else its vendor's + /// or invoice customer's name. pub party: Option, + /// The directory party the entry is with (see [`PartyInfo`]). + pub party_id: Option, /// Lower-cased text for search: description, accounts, vendor, amount. pub haystack: String, } @@ -81,6 +85,62 @@ impl AssetTrail { } } +/// One party's dealings with the company: its entries, documents, and +/// this year's totals, computed once per refresh. +/// +/// ```text +/// GOAL: answer "who is this, and what has the company done with them?" +/// with the books' own numbers. +/// SOURCE: docs/parties-and-attachments.md (Parties → Viewer); the +/// module's rule (every number from ledger-core; this indexes and +/// sums); DECISION (the year and the payment accounts, below). +/// DEFINITIONS: +/// The party's entries: Ledger::entry_party names it (core tags hand +/// tags, vendor tags, bills, invoices, their payments, reversals). +/// The year: the calendar year of `today`, Jan 1 through today (the +/// year 1099s are counted in). +/// Money: ledger_core::parties::activity with Books::cash_accounts +/// (the same numbers books.party reports). +/// INVARIANTS: +/// 1. entries: every entry that is the party's, drafts included, in +/// Books::entries order (newest first). +/// 2. paid_ytd = activity's paid − received over the year, exactly. +/// Positive: paid to them, net; negative: received from them, net. +/// 3. invoiced_ytd = Σ Invoice::total() over its customer record's +/// invoices dated in the year; billed_ytd the same for its vendor +/// record's bills. +/// 4. open_invoices = Σ Invoice::balance() over its customer record's +/// invoices; open_bills = Σ Bill::balance() over its vendor record's +/// bills (any date: what is still owed today). +/// 5. form_1099: its vendor record's line in +/// vendor_1099_report_with(ledger, year, None, cash and financing +/// accounts), when it has a vendor +/// record in that report and the report knows the year's threshold +/// (threshold_1099 is then that threshold). +/// 6. payroll_runs: the runs with a line whose employee is the party, +/// or (older runs, no party on the line) whose employee_name is the +/// party's name ignoring case and surrounding space. +/// 7. events: indexes into Books::journal of party.* events with its +/// id, entry.party_set naming it, and its records' vendor.added, +/// customer.added, vendor.1099_set, and entry.vendor_set events. +/// ``` +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PartyInfo { + pub id: PartyId, + pub entries: Vec, + pub paid_ytd: Decimal, + pub invoiced_ytd: Decimal, + pub billed_ytd: Decimal, + pub open_invoices: Decimal, + pub open_bills: Decimal, + pub invoices: Vec, + pub bills: Vec, + pub payroll_runs: Vec, + pub form_1099: Option, + pub threshold_1099: Option, + pub events: Vec, +} + /// Depreciation due and not yet in the books. /// /// ```text @@ -154,6 +214,8 @@ pub struct Books { pub asset_trails: HashMap, /// The asset each depreciation, disposal, or purchase entry is about. pub entry_assets: HashMap, + /// Every party's dealings with the company, by party id. + pub parties: BTreeMap, } /// Whether what's on disk is what the journal says. @@ -288,7 +350,6 @@ impl Books { } } let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64); - let parties = parties(&ledger, &journal); let mut fresh = HashSet::new(); let closings = ledger.closing_entry_ids(); let mut entries = Vec::new(); @@ -345,7 +406,13 @@ impl Books { Side::Credit => -l.amount.amount, }) .sum(); - let party = vendor.clone().or_else(|| parties.get(&id).cloned()); + // Core tags every entry with its counterparty (hand tags, + // vendor tags, bills, invoices, their payments, reversals). + let party_id = ledger.entry_party(id); + let party = party_id + .and_then(|p| ledger.get_party(p)) + .map(|p| p.label().to_string()) + .or_else(|| vendor.clone()); let mut haystack = format!( "{} {} #{} {}", e.description(), @@ -362,6 +429,12 @@ impl Books { haystack.push(' '); haystack.push_str(v); } + if let Some(p) = party_id.and_then(|p| ledger.get_party(p)) + && p.display_name.is_some() + { + haystack.push(' '); + haystack.push_str(&p.name); + } entries.push(EntryInfo { id, date: e.date(), @@ -378,6 +451,7 @@ impl Books { missing_receipt, cash_effect, party, + party_id, haystack: haystack.to_lowercase(), }); } @@ -413,7 +487,9 @@ impl Books { integrity, asset_trails, entry_assets, + parties: BTreeMap::new(), }; + books.parties = books.party_infos(); books.kpis = books.compute_kpis(); books.attention = crate::attention::find(&books); Ok(books) @@ -578,6 +654,140 @@ impl Books { } } + /// One party's dealings (None for an unknown party). + pub fn party(&self, id: PartyId) -> Option<&PartyInfo> { + self.parties.get(&id) + } + + /// A party's label (its display name, else its name). + pub fn party_label(&self, id: PartyId) -> String { + self.ledger + .get_party(id) + .map(|p| p.label().to_string()) + .unwrap_or_else(|| format!("party {id}")) + } + + /// Whether a party files a 1099: it has a 1099 role (contractor, + /// landlord), or its vendor record is marked for a 1099. + pub fn files_1099(&self, p: &Party) -> bool { + p.roles.iter().any(|r| r.gets_1099()) + || p.vendor + .and_then(|v| self.ledger.get_vendor(v)) + .is_some_and(|v| v.form_1099.is_some()) + } + + /// Every party's [`PartyInfo`]. + fn party_infos(&self) -> BTreeMap { + let l = &self.ledger; + let year = self.today.year(); + let year_start = NaiveDate::from_ymd_opt(year, 1, 1).expect("valid"); + let in_year = |d: NaiveDate| d >= year_start && d <= self.today; + let mut out: BTreeMap = l + .parties() + .map(|p| { + ( + p.id, + PartyInfo { + id: p.id, + ..Default::default() + }, + ) + }) + .collect(); + for (id, info) in out.iter_mut() { + let a = ledger_core::parties::activity( + l, + *id, + (year_start, self.today), + &self.cash_accounts, + ); + info.entries = self + .entries + .iter() + .filter(|e| a.entries.contains(&e.id)) + .map(|e| e.id) + .collect(); + info.paid_ytd = a.paid - a.received; + } + for inv in l.invoices() { + let id = inv.id; + if let Some(info) = l + .party_of_customer(inv.customer_id) + .and_then(|p| out.get_mut(&p)) + { + info.invoices.push(id); + info.open_invoices += inv.balance(); + if in_year(inv.date) { + info.invoiced_ytd += inv.total(); + } + } + } + for bill in l.bills() { + let id = bill.id; + if let Some(info) = l + .party_of_vendor(bill.vendor_id) + .and_then(|p| out.get_mut(&p)) + { + info.bills.push(id); + info.open_bills += bill.balance(); + if in_year(bill.date) { + info.billed_ytd += bill.total(); + } + } + } + let sources = ledger_core::PaymentAccounts { + cash: self.cash_accounts.clone(), + financing: self.financing_accounts.clone(), + }; + if let Ok(r) = ledger_core::vendor_1099_report_with(l, year, None, &sources) { + for v in &r.vendors { + if let Some(info) = l.party_of_vendor(v.vendor_id).and_then(|p| out.get_mut(&p)) { + info.form_1099 = Some(v.clone()); + info.threshold_1099 = Some(r.threshold); + } + } + } + for run in l.list_payroll_runs() { + for p in l.parties() { + let names = |x: &ledger_core::PayrollLine| match x.employee { + Some(e) => e == p.id, + None => { + ledger_core::parties::name_key(&x.employee_name) + == ledger_core::parties::name_key(&p.name) + } + }; + if run.lines.iter().any(names) + && let Some(info) = out.get_mut(&p.id) + && !info.payroll_runs.contains(&run.id) + { + info.payroll_runs.push(run.id); + } + } + } + let u = |v: &Value, k: &str| v.get(k).and_then(|v| v.as_u64()); + for (i, line) in self.journal.iter().enumerate() { + let p = &line.payload; + let t = line.line_type.as_str(); + let party = if t.starts_with("party.") { + u(p, "id") + } else { + match t { + "entry.party_set" => u(p, "party_id"), + "vendor.added" => u(p, "id").and_then(|v| l.party_of_vendor(v)), + "customer.added" => u(p, "id").and_then(|c| l.party_of_customer(c)), + "vendor.1099_set" | "entry.vendor_set" => { + u(p, "vendor_id").and_then(|v| l.party_of_vendor(v)) + } + _ => None, + } + }; + if let Some(info) = party.and_then(|p| out.get_mut(&p)) { + info.events.push(i); + } + } + out + } + /// Depreciation due through the last finished month on or before /// `as_of` and not yet posted (see [`DepreciationDue`]). pub fn depreciation_due(&self, as_of: NaiveDate) -> DepreciationDue { @@ -617,37 +827,6 @@ impl Books { } } -/// Customer names for entries created by invoices and their payments, -/// from the journal. (Bills and bill payments need nothing here: the -/// ledger tags their entries with the vendor, which takes precedence.) -fn parties(ledger: &Ledger, journal: &[JournalLine]) -> HashMap { - let mut out = HashMap::new(); - let customer = |id: Option| { - id.and_then(|c| ledger.get_customer(c)) - .map(|c| c.name.clone()) - }; - let u = |p: &Value, k: &str| p.get(k).and_then(|v| v.as_u64()); - for line in journal { - let p = &line.payload; - let Some(entry) = u(p, "entry_id") else { - continue; - }; - let name = match line.line_type.as_str() { - "invoice.created" => customer(u(p, "customer_id")), - "invoice.payment_received" => customer( - u(p, "invoice_id") - .and_then(|i| ledger.get_invoice(i)) - .map(|i| i.customer_id), - ), - _ => None, - }; - if let Some(n) = name { - out.insert(entry, n); - } - } - out -} - /// Every registered asset's trail, from the journal (depreciation, /// disposal, its own events) and the entries (the purchase). fn asset_trails(ledger: &Ledger, journal: &[JournalLine]) -> HashMap { diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index 59491ce..daf2692 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -250,6 +250,94 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { .map(|n| s.add_customer(n)) .collect::>()?; + // The party directory: who they are, beyond the vendor and customer + // records (roles, W-9 facts with only the TIN's last four, sites). + { + use ledger_core::{PartyField, Role, W9Facts}; + let party_of = |s: &Store, v: u64| s.ledger().party_of_vendor(v).expect("linked"); + let sam_p = party_of(&s, sam); + s.set_party_role(sam_p, Role::Contractor, true)?; + s.set_party_w9( + sam_p, + Some(W9Facts { + received: start + Days::new(20), + entity_type: "Individual/sole proprietor".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: "4821".into(), + kept_at: "wanderbooks vault".into(), + vault_record: None, + }), + )?; + s.update_party( + sam_p, + PartyField::Notes(Some( + "Illustrator for client projects; invoices after each delivery.".into(), + )), + )?; + let riley_p = party_of(&s, riley); + s.set_party_role(riley_p, Role::Contractor, true)?; + s.update_party(riley_p, PartyField::Website(Some("rileychen.dev".into())))?; + let lofts = party_of(&s, landlord); + s.set_party_role(lofts, Role::Landlord, true)?; + s.set_party_w9( + lofts, + Some(W9Facts { + received: start + Days::new(3), + entity_type: "LLC taxed as a partnership".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: "3307".into(), + kept_at: "CPA portal".into(), + vault_record: None, + }), + )?; + s.update_party( + lofts, + PartyField::Address(Some("1200 NW Everett St, Portland, OR 97209".into())), + )?; + s.update_party(lofts, PartyField::DisplayName(Some("Everett Lofts".into())))?; + if let Some(fern) = s.ledger().party_of_customer(customers[0]) { + s.update_party( + fern, + PartyField::Website(Some("fernandfield.coffee".into())), + )?; + s.update_party( + fern, + PartyField::Address(Some("88 SE Division St, Portland, OR 97202".into())), + )?; + } + let irs = s.add_party("Internal Revenue Service", &[Role::TaxAgency])?; + s.update_party(irs, PartyField::DisplayName(Some("IRS".into())))?; + s.update_party(irs, PartyField::Website(Some("irs.gov".into())))?; + let odr = s.add_party("Oregon Department of Revenue", &[Role::TaxAgency])?; + s.update_party(odr, PartyField::Website(Some("oregon.gov/dor".into())))?; + let old = s.add_party("Etsy, Inc.", &[Role::PayoutPlatform])?; + s.update_party( + old, + PartyField::Notes(Some("Sold templates here before moving to Gumroad.".into())), + )?; + s.update_party(old, PartyField::Archived(true))?; + } + let gumroad = s.add_party("Gumroad, Inc.", &[ledger_core::Role::PayoutPlatform])?; + s.update_party( + gumroad, + ledger_core::PartyField::DisplayName(Some("Gumroad".into())), + )?; + s.update_party( + gumroad, + ledger_core::PartyField::Website(Some("gumroad.com".into())), + )?; + let chase = s.add_party( + "JPMorgan Chase Bank, N.A.", + &[ledger_core::Role::FinancialInstitution], + )?; + s.update_party( + chase, + ledger_core::PartyField::DisplayName(Some("Chase".into())), + )?; + let alex = s.add_party("Alex Rivera", &[ledger_core::Role::Member])?; + // Formation documents. s.file_document( "formation", @@ -562,7 +650,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { // Pay the card in full on the 25th. let date = day(m, 25); if in_range(date) && card_balance > Decimal::ZERO { - post( + let paid = post( &mut s, date, "Credit card payment", @@ -582,6 +670,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { None, None, )?; + s.set_entry_party(paid, Some(chase))?; card_balance = Decimal::ZERO; } // Client invoices: two or three a month, net 30. @@ -657,6 +746,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { None, )?; s.post_entry(e)?; + s.set_entry_party(e, Some(gumroad))?; } } // Contractors. @@ -715,7 +805,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { if month_index >= 3 { let date = day(m, 28); if in_range(date) { - post( + let draw = post( &mut s, date, "Owner draw", @@ -726,6 +816,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { None, None, )?; + s.set_entry_party(draw, Some(alex))?; } } if month_index % 3 == 2 { diff --git a/crates/ledger-gui/src/nav.rs b/crates/ledger-gui/src/nav.rs index ffafeea..cdcc199 100644 --- a/crates/ledger-gui/src/nav.rs +++ b/crates/ledger-gui/src/nav.rs @@ -1,6 +1,6 @@ //! Where you are and how you got there: a browser-style history. -use ledger_core::{AccountId, AssetId, EntryId}; +use ledger_core::{AccountId, AssetId, EntryId, PartyId}; #[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub enum ReportKind { @@ -63,6 +63,10 @@ pub enum Route { Assets, /// One fixed asset. Asset(AssetId), + /// The party directory. + Parties, + /// One party. + Party(PartyId), Documents, Document(u64), Company, @@ -81,6 +85,7 @@ impl Route { Route::Reports(_) => Section::Reports, Route::Accounts | Route::Account(_) => Section::Accounts, Route::Assets | Route::Asset(_) => Section::Assets, + Route::Parties | Route::Party(_) => Section::Parties, Route::Documents | Route::Document(_) => Section::Documents, Route::Company => Section::Company, Route::Journal | Route::JournalEvent(_) => Section::Journal, @@ -97,13 +102,14 @@ pub enum Section { Reports, Accounts, Assets, + Parties, Documents, Company, Journal, } impl Section { - pub const ALL: [Section; 10] = [ + pub const ALL: [Section; 11] = [ Section::Overview, Section::Cash, Section::Profit, @@ -111,6 +117,7 @@ impl Section { Section::Reports, Section::Accounts, Section::Assets, + Section::Parties, Section::Documents, Section::Company, Section::Journal, @@ -125,6 +132,7 @@ impl Section { Section::Reports => "Reports", Section::Accounts => "Accounts", Section::Assets => "Assets", + Section::Parties => "Parties", Section::Documents => "Documents", Section::Company => "Company", Section::Journal => "Journal", @@ -141,6 +149,7 @@ impl Section { Section::Reports => i::FILE_TEXT, Section::Accounts => i::TREE_STRUCTURE, Section::Assets => i::PACKAGE, + Section::Parties => i::ADDRESS_BOOK, Section::Documents => i::FILES, Section::Company => i::BUILDINGS, Section::Journal => i::SCROLL, @@ -156,6 +165,7 @@ impl Section { Section::Reports => Route::Reports(ReportKind::ProfitAndLoss), Section::Accounts => Route::Accounts, Section::Assets => Route::Assets, + Section::Parties => Route::Parties, Section::Documents => Route::Documents, Section::Company => Route::Company, Section::Journal => Route::Journal, diff --git a/crates/ledger-gui/src/pages/assets.rs b/crates/ledger-gui/src/pages/assets.rs index 603a0e9..63bf850 100644 --- a/crates/ledger-gui/src/pages/assets.rs +++ b/crates/ledger-gui/src/pages/assets.rs @@ -281,7 +281,7 @@ pub fn status_text(disposed: Option<(NaiveDate, Decimal)>) -> String { } } -fn notice_box(ui: &mut Ui, n: &Notice) { +pub(crate) fn notice_box(ui: &mut Ui, n: &Notice) { let p = pal(ui.ctx()); let (fg, bg) = if n.problem { (p.warning, p.warning_soft) @@ -310,7 +310,7 @@ fn notice_box(ui: &mut Ui, n: &Notice) { /// One painted table row: `cells` are (text, width, right-aligned, /// color); the cell whose width is 0 takes what's left. A second line /// (`sub`) goes under the flexible cell. -fn row( +pub(crate) fn row( ui: &mut Ui, w: f32, cells: &[(String, f32, bool, Color32)], @@ -1345,7 +1345,7 @@ fn tax_card(ui: &mut Ui, a: &Asset) { } /// A two-column grid of facts; `add` gets the label painter. -fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) { +pub(crate) fn facts(ui: &mut Ui, add: impl FnOnce(&mut Ui, &dyn Fn(&mut Ui, &str))) { let p = pal(ui.ctx()); egui::Grid::new(ui.next_auto_id()) .num_columns(2) diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index 87016b7..fe9568c 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -183,7 +183,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { assets(ui, b, shell); schedules(ui, b); recurring(ui, b); - parties(ui, b); + parties(ui, b, shell); }); } @@ -421,8 +421,18 @@ fn recurring(ui: &mut Ui, b: &Books) { ui.add_space(16.0); } -fn parties(ui: &mut Ui, b: &Books) { +fn parties(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); + let mut go: Option = None; + // A record's name opens its party. + let mut name = |ui: &mut Ui, text: &str, party: Option| { + let r = widgets::link(ui, RichText::new(text).font(theme::semibold(13.0))); + if let Some(id) = party + && r.on_hover_text("Open this party").clicked() + { + go = Some(crate::nav::Route::Party(id)); + } + }; let year = chrono::Datelike::year(&b.today); let r1099 = vendor_1099_report(&b.ledger, year, None).ok(); columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { @@ -444,7 +454,7 @@ fn parties(ui: &mut Ui, b: &Books) { .map(|x| x.paid) .unwrap_or_default(); ui.horizontal_wrapped(|ui| { - ui.label(RichText::new(&v.name).font(theme::semibold(13.0))); + name(ui, &v.name, b.ledger.party_of_vendor(v.id)); if let Some(f) = v.form_1099 { widgets::pill(ui, f.label(), p.accent, p.accent_soft); if v.tin_on_file { @@ -490,7 +500,7 @@ fn parties(ui: &mut Ui, b: &Books) { .map(|i| i.balance()) .sum(); ui.horizontal_wrapped(|ui| { - ui.label(RichText::new(&c.name).font(theme::semibold(13.0))); + name(ui, &c.name, b.ledger.party_of_customer(c.id)); ui.with_layout( egui::Layout::right_to_left(egui::Align::Center), |ui| { @@ -509,4 +519,7 @@ fn parties(ui: &mut Ui, b: &Books) { ); } }); + if let Some(r) = go { + shell.go(r); + } } diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index 7592768..7db2a36 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -51,7 +51,9 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { } }; widgets::pill(ui, e.origin, p.muted, p.hover); - if let Some(v) = &e.vendor { + if let Some(v) = &e.vendor + && e.party_id.is_none() + { widgets::pill(ui, &format!("{} {v}", icon::STOREFRONT), p.muted, p.hover); } if e.missing_receipt { @@ -85,6 +87,26 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { shell.go(Route::Entry(r)); } } + if let Some(party) = e.party_id.and_then(|id| b.ledger.get_party(id)) { + ui.add_space(4.0); + if widgets::link( + ui, + RichText::new(format!( + "{} {}", + super::parties::party_icon(party), + party.label() + )) + .color(p.accent), + ) + .on_hover_text(format!( + "The party this entry is with ({}): open them", + super::parties::roles_text(party).to_lowercase() + )) + .clicked() + { + shell.go(Route::Party(party.id)); + } + } if let Some(&asset) = b.entry_assets.get(&e.id) { ui.add_space(4.0); if widgets::link( diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 19b1e97..17fe22e 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -92,6 +92,74 @@ fn tax_basis_text(b: &Books, asset_id: Option, p: &Value) -> String { text } +/// What an attachment link's subject (`{"kind": .., "id": ..}`) is, in +/// words, and where it opens. +fn subject_text(b: &Books, subject: Option<&Value>) -> (String, Option) { + let kind = subject + .and_then(|s| s.get("kind")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let id = subject.and_then(|s| s.get("id")).and_then(|v| v.as_u64()); + match (kind, id) { + ("entry", Some(e)) => (format!("#{e}"), Some(Route::Entry(e))), + ("account", Some(a)) => (b.account_label(a), Some(Route::Account(a))), + ("asset", Some(a)) => (b.asset_label(a), Some(Route::Asset(a))), + ("party", Some(p)) => (b.party_label(p), Some(Route::Party(p))), + ("company", _) => ("the company".into(), Some(Route::Company)), + ("payroll_run", Some(i)) => (format!("payroll run {i}"), None), + (k, Some(i)) if !k.is_empty() => (format!("{} {i}", k.replace('_', " ")), None), + _ => ("a subject".into(), None), + } +} + +/// A party's label, or "a party" when the event names none. +fn party_name(b: &Books, id: Option) -> String { + match id { + Some(id) => b.party_label(id), + None => "a party".to_string(), + } +} + +/// "Display name of Gumroad, Inc. set to Gumroad", "Archived Etsy": one +/// `party.updated` event in words. Names are today's (the display name's +/// change names the legal name, so it reads the same afterwards). +fn party_update_text(b: &Books, id: Option, change: Option<&Value>) -> String { + let name = party_name(b, id); + let legal = id + .and_then(|i| b.ledger.get_party(i)) + .map_or_else(|| name.clone(), |p| p.name.clone()); + let field = change + .and_then(|c| c.get("field")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + let value = change.and_then(|c| c.get("value")); + let text = value.and_then(|v| v.as_str()); + let words = match field { + "name" => "Name", + "display_name" => "Display name", + "website" => "Website", + "address" => "Address", + "notes" => "Notes", + _ => "", + }; + match (field, text) { + ("archived", _) => { + if value.and_then(|v| v.as_bool()).unwrap_or(false) { + format!("Archived {name}") + } else { + format!("Unarchived {name}") + } + } + ("name", Some(t)) => format!("Renamed a party to {t}"), + ("display_name", Some(t)) => format!("Display name of {legal} set to {t}"), + ("display_name", None) => format!("Display name of {legal} cleared"), + ("notes", Some(_)) => format!("Notes of {name} updated"), + ("", _) => format!("Updated {name}"), + (_, Some(t)) => format!("{words} of {name} set to {t}"), + (_, None) => format!("{words} of {name} cleared"), + } +} + /// A sentence for a meaningful event; `None` for the intermediate steps of /// building an entry (created, line added), which the posted event covers. pub fn describe(b: &Books, line: &JournalLine) -> Option { @@ -177,6 +245,67 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { ), entry_id.map(Route::Entry), ), + "attachment.linked" | "attachment.unlinked" => { + let (what, route) = subject_text(b, p.get("subject")); + let name = attachment_name(b, att); + Some(Event { + icon: if line.line_type == "attachment.linked" { + icon::LINK + } else { + icon::LINK_BREAK + }, + text: if line.line_type == "attachment.linked" { + format!("Linked {name} to {what}") + } else { + format!("Unlinked {name} from {what}: {}", s(p, "reason")) + }, + route: Some(Route::Document(att)), + also: route, + }) + } + "statement.recorded" => { + let account = p.get("account_id").and_then(|v| v.as_u64()); + let closing = s(p, "closing_balance") + .parse() + .map(fmt::dollars) + .unwrap_or_default(); + Some(Event { + icon: icon::FILE_TEXT, + text: format!( + "Statement for {}, {} to {}: closing balance {closing}", + account.map_or("an account".into(), |a| b.account_label(a)), + s(p, "period_start"), + s(p, "period_end") + ), + route: Some(Route::Document(att)), + also: account.map(Route::Account), + }) + } + "reconciliation.completed" => { + let account = p.get("account_id").and_then(|v| v.as_u64()); + let balance = s(p, "statement_balance") + .parse() + .map(fmt::dollars) + .unwrap_or_default(); + Some(Event { + icon: icon::CHECK_SQUARE, + text: format!( + "Reconciled {} through {} at {balance}", + account.map_or("an account".into(), |a| b.account_label(a)), + s(p, "statement_date") + ), + route: account.map(Route::Account), + also: p + .get("attachment_id") + .and_then(|v| v.as_u64()) + .map(Route::Document), + }) + } + "reconciliation.voided" => ev( + icon::PROHIBIT, + format!("Voided reconciliation {id}: {}", s(p, "reason")), + None, + ), "attachment.withdrawn" => ev( icon::ARCHIVE, format!("Withdrew {}: {}", attachment_name(b, att), s(p, "reason")), @@ -236,24 +365,169 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { "vendor.added" => ev( icon::STOREFRONT, format!("Added vendor {}", s(p, "name")), - Some(Route::Company), + Some( + p.get("id") + .and_then(|v| v.as_u64()) + .and_then(|v| b.ledger.party_of_vendor(v)) + .map_or(Route::Company, Route::Party), + ), ), "customer.added" => ev( icon::USER_PLUS, format!("Added customer {}", s(p, "name")), - Some(Route::Company), + Some( + p.get("id") + .and_then(|v| v.as_u64()) + .and_then(|c| b.ledger.party_of_customer(c)) + .map_or(Route::Company, Route::Party), + ), ), "class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None), - "vendor.1099_set" => ev( - icon::IDENTIFICATION_CARD, - "Updated a vendor's 1099 details".into(), - Some(Route::Company), - ), - "entry.vendor_set" => ev( - icon::STOREFRONT, - format!("Tagged #{} with a vendor", entry_id.unwrap_or(0)), - entry_id.map(Route::Entry), - ), + "party.added" => { + let roles: Vec = p + .get("roles") + .and_then(|v| v.as_array()) + .map(|rs| { + rs.iter() + .filter_map(|r| r.as_str().and_then(ledger_core::Role::parse)) + .map(|r| super::parties::role_label(r).to_lowercase()) + .collect() + }) + .unwrap_or_default(); + let name = match s(p, "name") { + "" => party_name(b, p.get("id").and_then(|v| v.as_u64())), + n => n.to_string(), + }; + ev( + icon::ADDRESS_BOOK, + if roles.is_empty() { + format!("Added party {name}") + } else { + format!("Added party {name} ({})", roles.join(", ")) + }, + p.get("id").and_then(|v| v.as_u64()).map(Route::Party), + ) + } + "party.updated" => { + let pid = p.get("id").and_then(|v| v.as_u64()); + ev( + icon::PENCIL_SIMPLE, + party_update_text(b, pid, p.get("change")), + pid.map(Route::Party), + ) + } + "party.role_set" => { + let pid = p.get("id").and_then(|v| v.as_u64()); + let name = party_name(b, pid); + let role = ledger_core::Role::parse(s(p, "role")); + let on = p.get("on").and_then(|v| v.as_bool()).unwrap_or(true); + let text = match (role, on) { + (Some(r), true) => format!("{name} is now {}", super::parties::role_noun(r)), + (Some(r), false) => format!("{name} is no longer {}", super::parties::role_noun(r)), + (None, _) => format!("Changed the roles of {name}"), + }; + ev(icon::USER_GEAR, text, pid.map(Route::Party)) + } + "party.w9_set" => { + let pid = p.get("id").and_then(|v| v.as_u64()); + let name = party_name(b, pid); + let text = match p.get("facts").filter(|f| !f.is_null()) { + Some(f) => { + let mut t = format!("W-9 for {name} recorded"); + if let Some(d) = f.get("received").and_then(|v| v.as_str()) { + t.push_str(&format!(", received {d}")); + } + if let Some(e) = f.get("entity_type").and_then(|v| v.as_str()) { + t.push_str(&format!(": {e}")); + } + if let Some(l4) = f.get("tin_last4").and_then(|v| v.as_str()) { + t.push_str(&format!(", TIN ending {l4}")); + } + t + } + None => format!("W-9 facts for {name} cleared"), + }; + ev(icon::IDENTIFICATION_CARD, text, pid.map(Route::Party)) + } + "party.w4_set" => { + let pid = p.get("id").and_then(|v| v.as_u64()); + let name = party_name(b, pid); + let text = match p.get("facts").filter(|f| !f.is_null()) { + Some(f) => { + let mut t = format!("W-4 for {name} recorded"); + if let Some(d) = f.get("received").and_then(|v| v.as_str()) { + t.push_str(&format!(", received {d}")); + } + if let Some(st) = f.get("state").and_then(|v| v.as_str()) { + t.push_str(&format!(" (works in {st})")); + } + t + } + None => format!("W-4 facts for {name} cleared"), + }; + ev(icon::IDENTIFICATION_BADGE, text, pid.map(Route::Party)) + } + "entry.party_set" => { + let pid = p.get("party_id").and_then(|v| v.as_u64()); + let e = entry_id.unwrap_or(0); + Some(Event { + icon: icon::ADDRESS_BOOK, + text: match pid { + Some(_) => format!("Tagged #{e} with {}", party_name(b, pid)), + None => format!("Cleared the party of #{e}"), + }, + route: entry_id.map(Route::Entry), + also: pid.map(Route::Party), + }) + } + "vendor.1099_set" => { + let vendor = p.get("vendor_id").and_then(|v| v.as_u64()); + let name = vendor + .and_then(|v| b.ledger.get_vendor(v)) + .map(|v| v.name.clone()); + let form = p + .get("form_1099") + .and_then(|v| v.as_str()) + .and_then(ledger_core::Form1099::parse) + .map(|f| f.label()); + let w9 = p.get("tin_on_file").and_then(|v| v.as_bool()); + let text = match (name, form, w9) { + (Some(n), Some(f), Some(w)) => format!( + "1099 details of {n}: {f}, W-9 {}", + if w { "on file" } else { "not on file" } + ), + (Some(n), None, Some(_)) => format!("{n} no longer gets a 1099"), + (Some(n), _, None) => format!("Updated the 1099 details of {n}"), + (None, ..) => "Updated a vendor's 1099 details".into(), + }; + ev( + icon::IDENTIFICATION_CARD, + text, + Some( + vendor + .and_then(|v| b.ledger.party_of_vendor(v)) + .map_or(Route::Company, Route::Party), + ), + ) + } + "entry.vendor_set" => { + let vendor = p.get("vendor_id").and_then(|v| v.as_u64()); + let e = entry_id.unwrap_or(0); + Some(Event { + icon: icon::STOREFRONT, + text: match vendor.and_then(|v| b.ledger.get_vendor(v)) { + Some(v) => format!("Tagged #{e} with vendor {}", v.name), + None if vendor.is_none() && p.get("vendor_id").is_some() => { + format!("Cleared the vendor of #{e}") + } + None => format!("Tagged #{e} with a vendor"), + }, + route: entry_id.map(Route::Entry), + also: vendor + .and_then(|v| b.ledger.party_of_vendor(v)) + .map(Route::Party), + }) + } "invoice.created" => ev( icon::INVOICE, format!("Invoice {id} created"), diff --git a/crates/ledger-gui/src/pages/mod.rs b/crates/ledger-gui/src/pages/mod.rs index f1f620a..cab0ab2 100644 --- a/crates/ledger-gui/src/pages/mod.rs +++ b/crates/ledger-gui/src/pages/mod.rs @@ -8,6 +8,7 @@ pub mod documents; pub mod entry; pub mod journal; pub mod overview; +pub mod parties; pub mod profit; pub mod reports; pub mod transactions; @@ -55,6 +56,8 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) { Route::Account(id) => accounts::show_account(ui, b, *id, shell), Route::Assets => assets::show_register(ui, b, shell), Route::Asset(id) => assets::show_asset(ui, b, *id, shell), + Route::Parties => parties::show_list(ui, b, shell), + Route::Party(id) => parties::show_party(ui, b, *id, shell), Route::Documents => documents::show(ui, b, None, shell), Route::Document(id) => documents::show(ui, b, Some(*id), shell), Route::Company => company::show(ui, b, shell), diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs new file mode 100644 index 0000000..2d7b278 --- /dev/null +++ b/crates/ledger-gui/src/pages/parties.rs @@ -0,0 +1,1182 @@ +//! Parties: everyone the company deals with (vendors, contractors, +//! customers, employees, members, payout platforms, tax agencies, banks, +//! landlords), and one party completely: who they are, their W-9 or W-4 +//! facts (never more of a TIN than its last four digits), and everything +//! the company did with them. Every number is [`crate::books::PartyInfo`]'s +//! (built once per refresh from ledger-core) or ledger-core's own 1099 +//! worksheet; this module only arranges them. + +use super::assets::{Notice, facts, notice_box, row}; +use super::page; +use crate::app::Shell; +use crate::books::{Books, PartyInfo}; +use crate::fmt; +use crate::nav::{ReportKind, Route}; +use crate::theme::{self, pal}; +use crate::widgets; +use chrono::{Datelike, NaiveDate}; +use egui::{CornerRadius, RichText, Stroke, Ui, vec2}; +use egui_phosphor::regular as icon; +use ledger_core::{Party, PartyId, Role}; +use rust_decimal::Decimal; + +/// The directory's filters, kept across navigation. +#[derive(Default)] +pub struct State { + /// Only parties with this role (None: every role). + pub role: Option, + /// Archived parties listed too. + pub show_archived: bool, + /// Search text: name or display name. + pub query: String, +} + +/// A role in words, capitalized: "Payout platform". +pub fn role_label(r: Role) -> &'static str { + match r { + Role::Vendor => "Vendor", + Role::Contractor => "Contractor", + Role::Customer => "Customer", + Role::Employee => "Employee", + Role::Member => "Member", + Role::PayoutPlatform => "Payout platform", + Role::TaxAgency => "Tax agency", + Role::FinancialInstitution => "Bank or card issuer", + Role::Landlord => "Landlord", + } +} + +/// A role with its article, for a sentence: "a contractor", "an employee". +pub fn role_noun(r: Role) -> &'static str { + match r { + Role::Vendor => "a vendor", + Role::Contractor => "a contractor", + Role::Customer => "a customer", + Role::Employee => "an employee", + Role::Member => "a member", + Role::PayoutPlatform => "a payout platform", + Role::TaxAgency => "a tax agency", + Role::FinancialInstitution => "a bank or card issuer", + Role::Landlord => "a landlord", + } +} + +/// Every role a party has ([`Party::all_roles`]), in display order: +/// "Vendor, contractor"; "No role yet" when none. +pub fn roles_text(p: &Party) -> String { + let roles: Vec<&str> = p.all_roles().into_iter().map(role_label).collect(); + if roles.is_empty() { + return "No role yet".into(); + } + let mut out = roles.join(", ").to_lowercase(); + if let Some(first) = out.get(..1) { + out = first.to_uppercase() + &out[1..]; + } + out +} + +/// The icon for a party, by its most telling role. +pub fn party_icon(p: &Party) -> &'static str { + let r = p.all_roles(); + let has = |x| r.contains(&x); + if has(Role::TaxAgency) { + icon::BANK + } else if has(Role::FinancialInstitution) { + icon::BUILDING_OFFICE + } else if has(Role::PayoutPlatform) { + icon::DEVICE_MOBILE + } else if has(Role::Landlord) { + icon::HOUSE_LINE + } else if has(Role::Member) { + icon::CROWN + } else if has(Role::Employee) || has(Role::Contractor) { + icon::USER + } else if has(Role::Customer) { + icon::HANDSHAKE + } else if has(Role::Vendor) { + icon::STOREFRONT + } else { + icon::ADDRESS_BOOK + } +} + +/// The last four of a TIN, masked the way a truncated TIN is printed: +/// "•••-••-1234". The books never hold more than these four digits. +pub fn masked_tin(last4: &str) -> String { + format!("•••-••-{last4}") +} + +/// Where a party stands on its W-9. +/// +/// ```text +/// SOURCE: ledger_core::parties (W9Facts; a 1099 role carries them); +/// docs/parties-and-attachments.md (a contractor paid without +/// W-9 facts needs attention). +/// INVARIANTS: +/// 1. OnFile when the party has W-9 facts (whatever its roles). +/// 2. Otherwise NotNeeded when it doesn't file a 1099 +/// (Books::files_1099 is false). +/// 3. Otherwise MarkedOnFile when its vendor record says a W-9 is on +/// file (books from before the directory), else Missing. +/// ``` +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum W9Status { + NotNeeded, + OnFile { last4: String, received: NaiveDate }, + MarkedOnFile, + Missing, +} + +impl W9Status { + /// For the directory's W-9 column: "On file · •••-••-1234", + /// "Missing", "Marked on file", or empty. + pub fn text(&self) -> String { + match self { + W9Status::NotNeeded => String::new(), + W9Status::OnFile { last4, .. } => format!("On file · {}", masked_tin(last4)), + W9Status::MarkedOnFile => "Marked on file, facts not recorded".into(), + W9Status::Missing => "Missing".into(), + } + } +} + +/// A party's [`W9Status`]. +pub fn w9_status(b: &Books, p: &Party) -> W9Status { + if let Some(w) = &p.w9 { + return W9Status::OnFile { + last4: w.tin_last4.clone(), + received: w.received, + }; + } + if !b.files_1099(p) { + return W9Status::NotNeeded; + } + let marked = p + .vendor + .and_then(|v| b.ledger.get_vendor(v)) + .is_some_and(|v| v.tin_on_file); + if marked { + W9Status::MarkedOnFile + } else { + W9Status::Missing + } +} + +/// The 1099 role a party files under, in words ("contractor", +/// "landlord"), for prompts. +fn form_role(p: &Party) -> &'static str { + if p.roles.contains(&Role::Landlord) && !p.roles.contains(&Role::Contractor) { + "landlord" + } else { + "contractor" + } +} + +/// The agent prompt that collects and records a party's W-9. +pub fn collect_w9_prompt(b: &Books, p: &Party) -> String { + let paid = b.party(p.id).map(|i| i.paid_ytd).unwrap_or_default(); + let paid = if paid > Decimal::ZERO { + format!(", paid {} so far in {}", fmt::dollars(paid), b.today.year()) + } else { + String::new() + }; + format!( + "In wanderbooks, {} (party {}) is a {}{paid} with no W-9 facts recorded. Remind me to collect a W-9 before the next payment. Once I have it, record what it says with books.set_w9: the date received, the entity type (line 3), any exempt payee or FATCA codes (line 4), the last four digits of the TIN only (the full TIN goes in the vault, never the books), and where the form is kept. Then show me the party.", + p.label(), + p.id, + form_role(p) + ) +} + +/// The parties the directory lists for `st`, by label (ignoring case): +/// archived ones only when shown, those with the chosen role (any of +/// [`Party::all_roles`]), and those whose name or display name holds +/// every word of the query (ignoring case). +pub fn listed<'a>(b: &'a Books, st: &State) -> Vec<&'a Party> { + let q = st.query.trim().to_lowercase(); + let mut out: Vec<&Party> = b + .ledger + .parties() + .filter(|p| st.show_archived || !p.archived) + .filter(|p| st.role.is_none_or(|r| p.all_roles().contains(&r))) + .filter(|p| { + let hay = format!( + "{} {}", + p.name.to_lowercase(), + p.display_name.as_deref().unwrap_or("").to_lowercase() + ); + q.split_whitespace().all(|w| hay.contains(w)) + }) + .collect(); + out.sort_by(|a, b| { + a.label() + .to_lowercase() + .cmp(&b.label().to_lowercase()) + .then(a.id.cmp(&b.id)) + }); + out +} + +/// Each role some party (archived ones when shown) has, with how many. +pub fn role_counts(b: &Books, show_archived: bool) -> Vec<(Role, usize)> { + Role::ALL + .into_iter() + .map(|r| { + ( + r, + b.ledger + .parties() + .filter(|p| (show_archived || !p.archived) && p.all_roles().contains(&r)) + .count(), + ) + }) + .filter(|(_, n)| *n > 0) + .collect() +} + +/// "Paid this year" in words and color: paid out plain, received net +/// with a plus (in green), as the transactions list shows cash. +fn paid_cell(paid: Decimal, p: &theme::Palette) -> (String, egui::Color32) { + if paid < Decimal::ZERO { + (format!("+{}", fmt::money(-paid)), p.positive) + } else if paid.is_zero() { + ("—".into(), p.faint) + } else { + (fmt::money(paid), p.text) + } +} + +/// A filter chip. +fn chip(ui: &mut Ui, text: &str, on: bool) -> bool { + let p = pal(ui.ctx()); + ui.add( + egui::Button::new( + RichText::new(text) + .font(if on { + theme::semibold(12.5) + } else { + theme::regular(12.5) + }) + .color(if on { p.accent } else { p.muted }), + ) + .fill(if on { p.accent_soft } else { p.surface }) + .stroke(Stroke::new(1.0, if on { p.accent_soft } else { p.border })) + .corner_radius(CornerRadius::same(255)) + .min_size(vec2(0.0, 26.0)), + ) + .clicked() +} + +/// The party directory. +pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let year = b.today.year(); + page(ui, "parties", |ui| { + widgets::page_header( + ui, + "Parties", + &format!( + "Everyone the company deals with — vendors, contractors, customers, platforms, agencies — and what it paid each in {year}. Click one for everything the books hold about them." + ), + ); + if b.ledger.parties().next().is_none() { + widgets::card(ui, |ui| { + widgets::empty( + ui, + icon::ADDRESS_BOOK, + "No parties yet", + "Vendors, customers, contractors, and anyone else the company deals with appear here once the bookkeeper adds them.", + ) + }); + return; + } + let st = &mut shell.state.parties; + let narrow = ui.available_width() < 720.0; + ui.horizontal_wrapped(|ui| { + ui.add( + egui::TextEdit::singleline(&mut st.query) + .hint_text(format!("{} Search names", icon::MAGNIFYING_GLASS)) + .desired_width(if narrow { ui.available_width() } else { 280.0 }), + ); + let archived = b.ledger.parties().filter(|x| x.archived).count(); + if archived > 0 { + ui.add_space(8.0); + ui.checkbox(&mut st.show_archived, format!("Show archived ({archived})")); + } + }); + ui.add_space(8.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 6.0); + let all = b + .ledger + .parties() + .filter(|x| st.show_archived || !x.archived) + .count(); + if chip(ui, &format!("All {all}"), st.role.is_none()) { + st.role = None; + } + for (r, n) in role_counts(b, st.show_archived) { + let on = st.role == Some(r); + if chip(ui, &format!("{} {n}", role_label(r)), on) { + st.role = if on { None } else { Some(r) }; + } + } + }); + ui.add_space(12.0); + let rows = listed(b, st); + let missing: Vec<&Party> = b + .ledger + .parties() + .filter(|x| !x.archived && w9_status(b, x) == W9Status::Missing) + .collect(); + if !missing.is_empty() { + let names: Vec<&str> = missing.iter().map(|x| x.label()).collect(); + notice_box( + ui, + &Notice { + problem: true, + text: format!( + "No W-9 on file for {}: collect one before the next payment.", + names.join(", ") + ), + }, + ); + ui.add_space(4.0); + } + if rows.is_empty() { + widgets::card(ui, |ui| { + widgets::empty( + ui, + icon::MAGNIFYING_GLASS, + "No matching parties", + "Try a different search or role.", + ) + }); + return; + } + let mut go = None; + widgets::card(ui, |ui| { + let amt = widgets::AMOUNT_MIN + 24.0; + // Narrow, the roles and W-9 columns give way and the entry + // count goes, so the amount stays in view. + let avail = ui.available_width(); + let roles_w = (avail * 0.2).clamp(140.0, 210.0); + let w9_w = (avail * 0.2).clamp(160.0, 230.0); + let n_w = if avail >= 860.0 { 76.0 } else { 0.0 }; + let fixed = roles_w + w9_w + n_w + amt; + let w = avail.max(fixed + 200.0); + // Cells without the entry count when it has no room. + let cells = |c: [(String, f32, bool, egui::Color32); 5]| -> Vec<_> { + c.into_iter() + .enumerate() + .filter(|(i, _)| *i != 3 || n_w > 0.0) + .map(|(_, c)| c) + .collect() + }; + widgets::min_width(ui, "party_list", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &cells([ + head("Name", 0.0, false), + head("Roles", roles_w, false), + head("W-9", w9_w, false), + head("Entries", n_w, true), + head(&format!("Paid in {year}"), amt, true), + ]), + None, + true, + false, + ); + widgets::rule(ui); + for x in &rows { + let info = b.party(x.id).cloned().unwrap_or_default(); + let text = if x.archived { p.muted } else { p.text }; + let status = w9_status(b, x); + let w9_color = match status { + W9Status::Missing => p.warning, + W9Status::MarkedOnFile => p.muted, + _ => text, + }; + let mut sub = Vec::new(); + if x.display_name.is_some() { + sub.push(x.name.clone()); + } + if let Some(site) = &x.website { + sub.push(site.clone()); + } + if x.archived { + sub.push("archived".into()); + } + if sub.is_empty() { + sub.push(format!("Party {}", x.id)); + } + let sub = sub.join(" · "); + let (paid, paid_color) = paid_cell(info.paid_ytd, p); + let resp = row( + ui, + w, + &cells([ + (x.label().to_string(), 0.0, false, text), + (roles_text(x), roles_w, false, p.muted), + (status.text(), w9_w, false, w9_color), + (info.entries.len().to_string(), n_w, true, p.muted), + (paid, amt, true, paid_color), + ]), + Some((sub.as_str(), p.faint)), + false, + true, + ); + let resp = resp.on_hover_text(if info.paid_ytd < Decimal::ZERO { + format!( + "Open this party. On net the company received {} from them in {year}.", + fmt::dollars(-info.paid_ytd) + ) + } else { + "Open this party".to_string() + }); + resp.context_menu(|ui| { + widgets::copy_item(ui, "Copy name", &x.name); + widgets::copy_item(ui, "Copy party id", &x.id.to_string()); + widgets::copy_item(ui, "Copy paid this year", &info.paid_ytd.to_string()); + }); + if resp.clicked() { + go = Some(x.id); + } + } + }); + }); + ui.add_space(4.0); + ui.add( + egui::Label::new( + RichText::new(format!( + "Paid in {year}: what the party's posted entries since January 1 moved out of cash, cards, and what's owed back to whoever paid for the company; a plus is money received from them. The 1099 amount, on each party's page, is the 1099 worksheet's." + )) + .font(theme::regular(11.5)) + .color(p.faint), + ) + .wrap(), + ); + if let Some(id) = go { + shell.go(Route::Party(id)); + } + }); +} + +/// A number tile on a party's page: label, amount, how it's computed, +/// and the line under it. +type Tile = (String, Decimal, String, Option<(String, egui::Color32)>); + +/// One party, completely. +pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let Some(party) = b.ledger.get_party(id) else { + page(ui, "party", |ui| { + widgets::empty( + ui, + icon::ADDRESS_BOOK, + "No such party", + "It isn't in these books.", + ); + }); + return; + }; + let info = b.party(id).cloned().unwrap_or_default(); + let year = b.today.year(); + let roles = party.all_roles(); + let mut go: Option = None; + page(ui, "party", |ui| { + ui.horizontal(|ui| { + if widgets::link( + ui, + RichText::new(format!("{} Parties", icon::ARROW_LEFT)) + .font(theme::regular(12.5)) + .color(p.muted), + ) + .clicked() + { + go = Some(Route::Parties); + } + widgets::copyable( + ui, + RichText::new(format!("· Party {id}")) + .font(theme::regular(12.5)) + .color(p.muted), + &id.to_string(), + ); + }); + ui.add_space(4.0); + widgets::copyable( + ui, + RichText::new(party.label()).font(theme::bold(26.0)), + party.label(), + ); + if party.display_name.is_some() { + ui.horizontal(|ui| { + ui.label( + RichText::new("Legal name") + .font(theme::regular(12.5)) + .color(p.muted), + ); + widgets::copyable(ui, RichText::new(&party.name), &party.name); + }); + } + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + if roles.is_empty() { + widgets::pill(ui, "No role yet", p.muted, p.hover); + } + for r in &roles { + if r.gets_1099() { + widgets::pill(ui, role_label(*r), p.accent, p.accent_soft); + } else { + widgets::pill(ui, role_label(*r), p.muted, p.hover); + } + } + if party.archived { + widgets::pill(ui, "Archived", p.warning, p.warning_soft) + .on_hover_text("No longer dealt with; its history stays."); + } + }); + ui.add_space(14.0); + + // This year's totals. + let mut tiles: Vec = Vec::new(); + let received = info.paid_ytd < Decimal::ZERO; + tiles.push(( + if received { + format!("Received in {year}") + } else { + format!("Paid in {year}") + }, + info.paid_ytd.abs(), + "Over this party's posted entries since January 1: credits less debits to cash, cards, and what's owed back to whoever paid for the company (received, when more came in than went out).".into(), + Some((fmt::count(info.entries.len(), "entry", "entries"), p.muted)), + )); + if party.customer.is_some() { + tiles.push(( + format!("Invoiced in {year}"), + info.invoiced_ytd, + "The totals of its invoices dated since January 1.".into(), + None, + )); + tiles.push(( + "Open invoices".into(), + info.open_invoices, + "What its invoices still have owing to the company today.".into(), + None, + )); + } + if party.vendor.is_some() { + tiles.push(( + "Open bills".into(), + info.open_bills, + "What the company still owes on its bills today.".into(), + (!info.bills.is_empty()).then(|| { + ( + format!("{} billed in {year}", fmt::dollars(info.billed_ytd)), + p.muted, + ) + }), + )); + } + if let Some(l) = &info.form_1099 { + let threshold = info.threshold_1099.unwrap_or_default(); + let sub = match l.form { + Some(_) if l.reportable => ( + format!("Reportable · threshold {}", fmt::dollars(threshold)), + p.warning, + ), + Some(_) => ( + format!("Under the {} threshold", fmt::dollars(threshold)), + p.muted, + ), + None => ("Not marked for a 1099".into(), p.muted), + }; + tiles.push(( + format!("1099 amount, {year}"), + l.paid, + "Cash paid in the calendar year, from the 1099 worksheet (ledger-core): posted entries tagged with its vendor record, net of refunds and reversals. Card payments aren't counted (the card issuer reports them).".into(), + Some(sub), + )); + } + let n = tiles.len(); + super::grid(ui, n, 190.0, 5, |i, ui| { + let (label, value, how, sub) = &tiles[i]; + let r = widgets::kpi( + ui, + widgets::Kpi { + label, + value: fmt::dollars(*value), + sub: sub.clone(), + spark: None, + how, + }, + ); + r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &value.to_string())); + }); + + super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| { + if col == 0 { + facts_card(ui, party, &mut go); + } else { + let mut any = false; + if b.files_1099(party) || party.w9.is_some() { + w9_card(ui, b, party); + ui.add_space(16.0); + any = true; + } + if roles.contains(&Role::Employee) || party.w4.is_some() { + w4_card(ui, party); + ui.add_space(16.0); + any = true; + } + if !any || party.vendor.is_some() || party.customer.is_some() { + records_card(ui, b, party, &info, &mut go); + } + } + }); + + entries_card(ui, b, &info, &mut go); + ui.add_space(14.0); + if !info.invoices.is_empty() || !info.bills.is_empty() { + documents_card(ui, b, &info, &mut go); + ui.add_space(14.0); + } + if !info.payroll_runs.is_empty() { + payroll_card(ui, b, party, &info, &mut go); + ui.add_space(14.0); + } + widgets::titled_card( + ui, + "History", + |_| {}, + |ui| { + if info.events.is_empty() { + ui.label(RichText::new("No journal events name it.").color(p.muted)); + } + for &i in &info.events { + let line = &b.journal[i]; + let ev = super::journal::describe_any(b, line); + ui.horizontal(|ui| { + ui.label(RichText::new(ev.icon).color(p.muted)); + if widgets::link_truncated( + ui, + RichText::new(&ev.text).font(theme::regular(12.5)), + ) + .clicked() + { + go = Some(Route::JournalEvent(line.seq)); + } + ui.label( + RichText::new(format!( + "event {} · {}", + line.seq, + fmt::timestamp(&line.ts) + )) + .font(theme::regular(11.0)) + .color(p.faint), + ); + }); + } + }, + ); + }); + if let Some(r) = go { + shell.go(r); + } +} + +/// Who they are: names, website, address, notes, ids. +fn facts_card(ui: &mut Ui, party: &Party, go: &mut Option) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Facts", + |_| {}, + |ui| { + facts(ui, |ui, k| { + let text = |ui: &mut Ui, v: &Option| match v { + Some(t) => { + widgets::copyable(ui, RichText::new(t), t); + } + None => { + ui.label(RichText::new("not recorded").color(p.faint)); + } + }; + k(ui, "Name"); + widgets::copyable(ui, RichText::new(&party.name), &party.name); + ui.end_row(); + k(ui, "Display name"); + text(ui, &party.display_name); + ui.end_row(); + k(ui, "Website"); + text(ui, &party.website); + ui.end_row(); + k(ui, "Address"); + match &party.address { + Some(a) => { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 0.0; + widgets::copyable(ui, RichText::new(a), a); + }); + } + None => { + ui.label(RichText::new("not recorded").color(p.faint)); + } + } + ui.end_row(); + k(ui, "Party id"); + widgets::copyable( + ui, + RichText::new(party.id.to_string()), + &party.id.to_string(), + ); + ui.end_row(); + if let Some(v) = party.vendor { + k(ui, "Vendor record"); + if widgets::link(ui, RichText::new(format!("vendor {v}")).color(p.accent)) + .on_hover_text("Vendors and customers are listed on the Company page") + .clicked() + { + *go = Some(Route::Company); + } + ui.end_row(); + } + if let Some(c) = party.customer { + k(ui, "Customer record"); + if widgets::link(ui, RichText::new(format!("customer {c}")).color(p.accent)) + .on_hover_text("Vendors and customers are listed on the Company page") + .clicked() + { + *go = Some(Route::Company); + } + ui.end_row(); + } + }); + if let Some(n) = &party.notes { + ui.add_space(8.0); + ui.label( + RichText::new("Notes") + .font(theme::regular(12.5)) + .color(p.muted), + ); + ui.add(egui::Label::new(RichText::new(n).font(theme::regular(13.0))).wrap()); + } + }, + ); +} + +/// The W-9: what it said (never more of the TIN than its last four), or +/// that it's missing. +fn w9_card(ui: &mut Ui, b: &Books, party: &Party) { + let p = pal(ui.ctx()); + let status = w9_status(b, party); + widgets::titled_card( + ui, + "W-9", + |ui| match &status { + W9Status::OnFile { .. } => { + widgets::pill(ui, "On file", p.positive, p.positive_soft); + } + W9Status::Missing => { + widgets::pill(ui, "Missing", p.warning, p.warning_soft); + } + W9Status::MarkedOnFile => { + widgets::pill(ui, "Facts not recorded", p.muted, p.hover); + } + W9Status::NotNeeded => {} + }, + |ui| { + let Some(w) = &party.w9 else { + let text = if status == W9Status::MarkedOnFile { + "Its vendor record says a W-9 is on file, but what the form says isn't recorded here. Record its facts so the 1099 worksheet can be checked against it." + } else { + "No W-9 on file. Collect one before the next payment: the 1099 the company files for them needs their taxpayer identification number." + }; + ui.add( + egui::Label::new(RichText::new(text).font(theme::regular(12.5)).color( + if status == W9Status::Missing { + p.warning + } else { + p.muted + }, + )) + .wrap(), + ); + ui.add_space(6.0); + widgets::copy_prompt(ui, &collect_w9_prompt(b, party)); + return; + }; + facts(ui, |ui, k| { + k(ui, "Received"); + ui.label(fmt::date(w.received)); + ui.end_row(); + k(ui, "Entity type"); + ui.label(&w.entity_type); + ui.end_row(); + k(ui, "Exempt payee code"); + ui.label(w.exempt_payee_code.as_deref().unwrap_or("none")); + ui.end_row(); + k(ui, "FATCA code"); + ui.label(w.fatca_code.as_deref().unwrap_or("none")); + ui.end_row(); + k(ui, "TIN"); + ui.label(RichText::new(masked_tin(&w.tin_last4)).monospace()) + .on_hover_text( + "Only the last four digits are in the books; the full number is kept in the vault.", + ); + ui.end_row(); + k(ui, "Kept at"); + ui.label(&w.kept_at); + ui.end_row(); + if let Some(h) = &w.vault_record { + k(ui, "Vault record"); + widgets::copyable( + ui, + RichText::new(format!("sha256 {}…", &h[..h.len().min(12)])).monospace(), + h, + ); + ui.end_row(); + } + }); + }, + ); +} + +/// The W-4: what it said (never the SSN), or that it's missing. +fn w4_card(ui: &mut Ui, party: &Party) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "W-4", + |ui| { + if party.w4.is_some() { + widgets::pill(ui, "On file", p.positive, p.positive_soft); + } else { + widgets::pill(ui, "Missing", p.warning, p.warning_soft); + } + }, + |ui| match &party.w4 { + Some(w) => facts(ui, |ui, k| { + k(ui, "Received"); + ui.label(fmt::date(w.received)); + ui.end_row(); + k(ui, "Work state"); + ui.label(&w.state); + ui.end_row(); + k(ui, "Kept at"); + ui.label(&w.kept_at); + ui.end_row(); + }), + None => { + ui.add( + egui::Label::new( + RichText::new("No W-4 facts recorded for this employee.") + .font(theme::regular(12.5)) + .color(p.warning), + ) + .wrap(), + ); + } + }, + ); +} + +/// Its vendor and customer records, and the 1099 worksheet's line. +fn records_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mut Option) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Linked records", + |_| {}, + |ui| { + let vendor = party.vendor.and_then(|v| b.ledger.get_vendor(v)); + let customer = party.customer.and_then(|c| b.ledger.get_customer(c)); + if vendor.is_none() && customer.is_none() { + ui.add( + egui::Label::new( + RichText::new( + "No vendor or customer record: it isn't billed or invoiced, only tagged on entries.", + ) + .font(theme::regular(12.5)) + .color(p.muted), + ) + .wrap(), + ); + return; + } + facts(ui, |ui, k| { + if let Some(v) = vendor { + k(ui, "Vendor"); + ui.horizontal_wrapped(|ui| { + ui.label(format!("{} · vendor {}", v.name, v.id)); + if let Some(f) = v.form_1099 { + widgets::pill(ui, f.label(), p.accent, p.accent_soft); + } + }); + ui.end_row(); + k(ui, "Bills"); + ui.label(fmt::count(info.bills.len(), "bill", "bills")); + ui.end_row(); + } + if let Some(c) = customer { + k(ui, "Customer"); + ui.label(format!("{} · customer {}", c.name, c.id)); + ui.end_row(); + k(ui, "Invoices"); + ui.label(fmt::count(info.invoices.len(), "invoice", "invoices")); + ui.end_row(); + } + }); + if info.form_1099.is_some() { + ui.add_space(6.0); + if widgets::link( + ui, + RichText::new(format!("{} The 1099 worksheet", icon::FILE_TEXT)) + .color(p.accent), + ) + .clicked() + { + *go = Some(Route::Reports(ReportKind::Vendor1099)); + } + } + }, + ); +} + +/// Every entry tagged with the party, newest first. +fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Entries", + |ui| { + ui.label( + RichText::new(fmt::count(info.entries.len(), "entry", "entries")) + .font(theme::regular(12.0)) + .color(p.muted), + ); + }, + |ui| { + if info.entries.is_empty() { + ui.label( + RichText::new("No entry in the books is tagged with them yet.").color(p.muted), + ); + return; + } + let amt = widgets::AMOUNT_MIN + 16.0; + let w = ui.available_width().max(104.0 + 64.0 + 150.0 + amt + 200.0); + widgets::min_width(ui, "party_entries", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Date", 104.0, false), + head("Entry", 64.0, false), + head("Description", 0.0, false), + head("Kind", 150.0, false), + head("Amount", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + for id in &info.entries { + let Some(e) = b.entry(*id) else { + continue; + }; + let draft = e.status != ledger_core::EntryStatus::Posted; + let text = if draft { p.muted } else { p.text }; + let accounts: Vec = + e.accounts.iter().map(|a| b.account_name(*a)).collect(); + let sub = accounts.join(" → "); + let kind = if draft { + format!("{} · draft", e.origin) + } else { + e.origin.to_string() + }; + let resp = row( + ui, + w, + &[ + (fmt::date(e.date), 104.0, false, text), + (format!("#{}", e.id), 64.0, false, p.muted), + (e.description.clone(), 0.0, false, text), + (kind, 150.0, false, if draft { p.warning } else { p.muted }), + (fmt::money(e.amount), amt, true, text), + ], + Some((sub.as_str(), p.faint)), + false, + true, + ); + resp.context_menu(|ui| { + widgets::copy_item(ui, "Copy entry number", &format!("#{}", e.id)); + widgets::copy_item(ui, "Copy amount", &e.amount.to_string()); + }); + if resp.on_hover_text("Open this entry").clicked() { + *go = Some(Route::Entry(e.id)); + } + } + }); + }, + ); +} + +/// Its invoices and bills. +fn documents_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Invoices and bills", + |_| {}, + |ui| { + let amt = widgets::AMOUNT_MIN + 16.0; + let w = ui + .available_width() + .max(110.0 + 104.0 * 2.0 + 90.0 + amt * 2.0 + 60.0); + widgets::min_width(ui, "party_docs", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Document", 0.0, false), + head("Date", 104.0, false), + head("Due", 104.0, false), + head("Status", 90.0, false), + head("Total", amt, true), + head("Still owed", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + let invoices = info + .invoices + .iter() + .filter_map(|i| b.ledger.get_invoice(*i)) + .map(|i| { + ( + format!("Invoice {}", i.id), + i.date, + i.due_date, + i.status, + i.total(), + i.balance(), + i.entry_id, + ) + }); + let bills = info + .bills + .iter() + .filter_map(|i| b.ledger.get_bill(*i)) + .map(|i| { + ( + format!("Bill {}", i.id), + i.date, + i.due_date, + i.status, + i.total(), + i.balance(), + i.entry_id, + ) + }); + let mut docs: Vec<_> = invoices.chain(bills).collect(); + docs.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0))); + for (name, date, due, status, total, balance, entry) in docs { + let late = !balance.is_zero() && due < b.today; + let resp = row( + ui, + w, + &[ + (name, 0.0, false, p.text), + (fmt::date(date), 104.0, false, p.text), + ( + fmt::date(due), + 104.0, + false, + if late { p.warning } else { p.text }, + ), + (fmt::words(&format!("{status:?}")), 90.0, false, p.muted), + (fmt::money(total), amt, true, p.text), + (fmt::money(balance), amt, true, p.text), + ], + None, + false, + true, + ); + if resp.on_hover_text("Open its entry").clicked() { + *go = Some(Route::Entry(entry)); + } + } + }); + }, + ); +} + +/// The payroll runs that name them, with their line of each. +fn payroll_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mut Option) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "Payroll runs", + |_| {}, + |ui| { + let amt = widgets::AMOUNT_MIN + 16.0; + let w = ui.available_width().max(104.0 + 80.0 + amt * 2.0 + 160.0); + widgets::min_width(ui, "party_payroll", w, |ui| { + let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Pay date", 104.0, false), + head("Run", 80.0, false), + head("Employee", 0.0, false), + head("Gross", amt, true), + head("Net", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + let key = ledger_core::parties::name_key(&party.name); + for run in b + .ledger + .list_payroll_runs() + .into_iter() + .filter(|r| info.payroll_runs.contains(&r.id)) + { + for l in run.lines.iter().filter(|l| match l.employee { + Some(e) => e == party.id, + None => ledger_core::parties::name_key(&l.employee_name) == key, + }) { + let resp = row( + ui, + w, + &[ + (fmt::date(run.date), 104.0, false, p.text), + (format!("Run {}", run.id), 80.0, false, p.muted), + (l.employee_name.clone(), 0.0, false, p.text), + (fmt::money(l.gross_pay), amt, true, p.text), + (fmt::money(l.net_pay()), amt, true, p.text), + ], + None, + false, + true, + ); + if resp.on_hover_text("Open the payroll entry").clicked() { + *go = Some(Route::Entry(run.entry_id)); + } + } + } + }); + }, + ); +} diff --git a/crates/ledger-gui/src/pages/transactions.rs b/crates/ledger-gui/src/pages/transactions.rs index 4d2d1a6..42c9f34 100644 --- a/crates/ledger-gui/src/pages/transactions.rs +++ b/crates/ledger-gui/src/pages/transactions.rs @@ -78,7 +78,7 @@ fn list(ui: &mut Ui, b: &Books, selected: Option, shell: &mut Shell<'_>) { ui.add( egui::TextEdit::singleline(&mut st.query) .hint_text(format!( - "{} Search description, account, vendor, amount", + "{} Search description, account, party, amount", icon::MAGNIFYING_GLASS )) .desired_width(if narrow { ui.available_width() } else { 340.0 }), @@ -204,10 +204,11 @@ fn row(ui: &mut Ui, b: &Books, e: &EntryInfo, on: bool, since_update: f64) -> eg max_w, ); let accounts: Vec = e.accounts.iter().map(|a| b.account_name(*a)).collect(); - let mut sub = format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → ")); - if let Some(v) = &e.party { - sub.push_str(&format!(" · {v}")); - } + // The party first: it's what's cut last when the row is narrow. + let sub = match &e.party { + Some(v) => format!("#{} · {} · {v} · {}", e.id, e.origin, accounts.join(" → ")), + None => format!("#{} · {} · {}", e.id, e.origin, accounts.join(" → ")), + }; widgets::clipped_text( &painter, egui::pos2(text_left, rect.center().y + 10.0), diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index 389a6f0..b8e8106 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -1,6 +1,7 @@ //! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text, -//! amount, or #id), fixed assets (by tag, serial, or description), vendors, -//! and documents, ranked by a simple fuzzy match. +//! amount, or #id), fixed assets (by tag, serial, or description), parties +//! (vendors and customers among them, by name or display name), and +//! documents, ranked by a simple fuzzy match. use crate::books::Books; use crate::fmt; @@ -224,6 +225,15 @@ pub fn fuzzy(hay: &str, query: &str) -> Option { /// tag, serial, and description; a query found whole in its tag or /// serial (ignoring case) adds 500, in its description 50. /// 3. Every asset result opens Route::Asset(its id). +/// INVARIANTS (parties): +/// 4. A party whose name or display name equals the trimmed query, +/// ignoring case, scores 1500: above an entry's exact #id, below an +/// exact asset tag. +/// 5. Otherwise a party is listed when the query fuzzily matches its +/// name and display name; a query found whole in either (ignoring +/// case) adds 500. +/// 6. Every party result opens Route::Party(its id); archived parties +/// are found too, and say so. /// ``` pub fn search(b: &Books, query: &str) -> Vec { let q = query.trim(); @@ -329,13 +339,34 @@ pub fn search(b: &Books, query: &str) -> Vec { }); } } - for v in b.ledger.list_vendors() { - if let Some(score) = fuzzy(&v.name, q) { + for p in b.ledger.parties() { + let display = p.display_name.as_deref().unwrap_or(""); + let exact = |t: &str| !t.is_empty() && t.trim().to_lowercase() == ql; + let whole = |t: &str| !t.is_empty() && t.to_lowercase().contains(&ql); + let score = if exact(&p.name) || exact(display) { + Some(1500) + } else { + fuzzy(&format!("{} {display}", p.name), q).map(|s| { + s + if whole(&p.name) || whole(display) { + 500 + } else { + 0 + } + }) + }; + if let Some(score) = score { + let mut detail = vec![crate::pages::parties::roles_text(p)]; + if p.display_name.is_some() { + detail.push(p.name.clone()); + } + if p.archived { + detail.push("archived".into()); + } out.push(Item { - icon: icon::STOREFRONT, - title: v.name.clone(), - detail: "Vendor".into(), - route: Route::Company, + icon: crate::pages::parties::party_icon(p), + title: p.label().to_string(), + detail: detail.join(" · "), + route: Route::Party(p.id), score, }); } @@ -378,6 +409,7 @@ pub fn describe(b: &Books, r: &Route) -> String { .map(|a| a.file_name.clone()) .unwrap_or_default(), Route::Asset(id) => b.asset_label(*id), + Route::Party(id) => b.party_label(*id), Route::JournalEvent(seq) => format!("Event {seq}"), _ => String::new(), } diff --git a/crates/ledger-gui/tests/parties.rs b/crates/ledger-gui/tests/parties.rs new file mode 100644 index 0000000..df5523e --- /dev/null +++ b/crates/ledger-gui/tests/parties.rs @@ -0,0 +1,630 @@ +//! Parties in the viewer: each party's totals are the books' numbers +//! (ledger-core's records and 1099 worksheet, summed exactly as +//! `PartyInfo` defines); search, books.show, the journal, the entry page, +//! and "Needs attention" know parties. + +use chrono::NaiveDate; +use egui_kittest::Harness; +use ledger_core::arap::DocumentLine; +use ledger_core::{ + AccountType, Currency, EntryId, Form1099, Money, PartyField, PartyId, PayrollLine, Role, Side, + W4Facts, W9Facts, vendor_1099_report, +}; +use ledger_gui::books::Books; +use ledger_gui::nav::{Route, Section}; +use ledger_gui::pages::journal::describe; +use ledger_gui::pages::parties::{ + State, W9Status, listed, masked_tin, role_counts, roles_text, w9_status, +}; +use ledger_gui::{App, Source, attention, data, palette}; +use ledger_store::Store; +use ledger_store::viewer::ViewerTarget; +use rust_decimal::Decimal; +use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; + +static DIRS: AtomicUsize = AtomicUsize::new(0); + +struct TempDir(PathBuf); + +impl TempDir { + fn new(tag: &str) -> TempDir { + let n = DIRS.fetch_add(1, Ordering::Relaxed); + let d = std::env::temp_dir().join(format!( + "ledger-gui-parties-{tag}-{}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + TempDir(d) + } +} + +impl Drop for TempDir { + fn drop(&mut self) { + let _ = std::fs::remove_dir_all(&self.0); + } +} + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} + +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} + +fn usd(c: i64) -> Money { + Money::new(cents(c), Currency::USD) +} + +fn today() -> NaiveDate { + d(2026, 10, 15) +} + +fn load(dir: &Path) -> Books { + data::load_once(&Source { + data_dir: dir.join("books-data"), + books_toml: None, + today: Some(today()), + }) + .unwrap() +} + +fn doc(c: i64) -> Vec { + vec![DocumentLine { + description: "work".into(), + amount: cents(c), + }] +} + +fn w9(last4: &str) -> W9Facts { + W9Facts { + received: d(2026, 9, 1), + entity_type: "Individual/sole proprietor".into(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: last4.into(), + kept_at: "wanderbooks vault".into(), + vault_record: None, + } +} + +struct Fixture { + dir: TempDir, + books: Books, + /// The store's own ledger, reopened from disk (the oracle's input). + oracle: ledger_core::Ledger, + sam: PartyId, + sam_vendor: u64, + riley: PartyId, + acme: PartyId, + gumroad: PartyId, + irs: PartyId, + pat: PartyId, + old: PartyId, + bill_entry: EntryId, + invoice_entry: EntryId, + reversal: EntryId, + draft: EntryId, + remit: EntryId, +} + +/// Sam (a vendor marked for a 1099-NEC, no W-9) paid by cash, card, and +/// an owner, last year and this, with a payment reversed, a bill partly +/// paid, and a draft; Riley (a contractor, no vendor record) paid by +/// cash; Acme (a customer) invoiced this year and last, partly paid; +/// Gumroad paying out; the IRS paid a payroll remittance; Pat on payroll +/// (one run by party, one older run by name); Old Co paid, then archived. +fn fixture(tag: &str) -> Fixture { + let dir = TempDir::new(tag); + let data = dir.0.join("books-data"); + let mut s = Store::create(&data, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let card = s + .add_account("2050", "Business Card", AccountType::Liability, None, false) + .unwrap(); + let owed = s + .add_account( + "2100", + "Due to Shareholders", + AccountType::Liability, + None, + false, + ) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let sales = s + .add_account("4100", "Template Sales", AccountType::Revenue, None, false) + .unwrap(); + let fees = s + .add_account("6000", "Platform Fees", AccountType::Expense, None, false) + .unwrap(); + let labor = s + .add_account("6050", "Contract Labor", AccountType::Expense, None, false) + .unwrap(); + let e = s.create_entry(d(2025, 1, 1), "Owner contribution").unwrap(); + s.add_line(e, cash, Side::Debit, usd(10_000_000)).unwrap(); + s.add_line(e, capital, Side::Credit, usd(10_000_000)) + .unwrap(); + s.post_entry(e).unwrap(); + let pay = |s: &mut Store, date, what: &str, cr, c| { + let e = s.create_entry(date, what).unwrap(); + s.add_line(e, labor, Side::Debit, usd(c)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + s.post_entry(e).unwrap(); + e + }; + + let sam_vendor = s.add_vendor("Sam Okafor").unwrap(); + s.set_vendor_1099(sam_vendor, Some(Form1099::Nec), false) + .unwrap(); + let sam = s.ledger().party_of_vendor(sam_vendor).unwrap(); + s.set_party_role(sam, Role::Contractor, true).unwrap(); + let riley = s.add_party("Riley Chen", &[Role::Contractor]).unwrap(); + let acme_c = s.add_customer("Acme Buyer").unwrap(); + let acme = s.ledger().party_of_customer(acme_c).unwrap(); + let gumroad = s + .add_party("Gumroad, Inc.", &[Role::PayoutPlatform]) + .unwrap(); + s.update_party(gumroad, PartyField::DisplayName(Some("Gumroad".into()))) + .unwrap(); + let irs = s + .add_party("Internal Revenue Service", &[Role::TaxAgency]) + .unwrap(); + let pat = s.add_party("Pat Lee", &[Role::Employee]).unwrap(); + s.set_party_w4( + pat, + Some(W4Facts { + received: d(2026, 1, 2), + state: "UT".into(), + kept_at: "payroll provider".into(), + }), + ) + .unwrap(); + let old = s.add_party("Old Co", &[Role::Contractor]).unwrap(); + + // Sam: last year (not this year's), then cash, card, an owner, a + // reversed payment, a bill partly paid, and a draft. + let e = pay(&mut s, d(2025, 12, 15), "Sam, December", cash, 50_000); + s.set_entry_vendor(e, Some(sam_vendor)).unwrap(); + let e = pay(&mut s, d(2026, 2, 1), "Sam, February", cash, 100_000); + s.set_entry_vendor(e, Some(sam_vendor)).unwrap(); + let e = pay(&mut s, d(2026, 3, 1), "Sam, by card", card, 20_000); + s.set_entry_party(e, Some(sam)).unwrap(); + let e = pay(&mut s, d(2026, 4, 1), "Sam, paid by Stephen", owed, 30_000); + s.set_entry_vendor(e, Some(sam_vendor)).unwrap(); + let e = pay(&mut s, d(2026, 5, 1), "Sam, twice by mistake", cash, 25_000); + s.set_entry_vendor(e, Some(sam_vendor)).unwrap(); + let reversal = s.reverse_entry(e, d(2026, 5, 2), "Paid twice").unwrap(); + let bill = s + .create_bill(sam_vendor, d(2026, 6, 1), d(2026, 7, 1), doc(80_000)) + .unwrap(); + let bill_entry = s.ledger().get_bill(bill).unwrap().entry_id; + s.pay_bill(bill, d(2026, 6, 15), cents(50_000), cash) + .unwrap(); + let draft = s.create_entry(d(2026, 10, 1), "Sam, October").unwrap(); + s.add_line(draft, labor, Side::Debit, usd(9_900)).unwrap(); + s.add_line(draft, cash, Side::Credit, usd(9_900)).unwrap(); + s.set_entry_party(draft, Some(sam)).unwrap(); + + // Riley, by cash, tagged by party. + let e = pay(&mut s, d(2026, 7, 1), "Riley, July", cash, 70_000); + s.set_entry_party(e, Some(riley)).unwrap(); + + // Acme: last year's invoice still open, this year's partly paid. + s.create_invoice(acme_c, d(2025, 12, 1), d(2025, 12, 31), doc(40_000)) + .unwrap(); + let inv = s + .create_invoice(acme_c, d(2026, 2, 10), d(2026, 3, 10), doc(100_000)) + .unwrap(); + let invoice_entry = s.ledger().get_invoice(inv).unwrap().entry_id; + s.receive_payment(inv, d(2026, 3, 1), cents(60_000), cash) + .unwrap(); + + // Gumroad's payout: net to cash, fee expensed, gross revenue. + let e = s.create_entry(d(2026, 8, 1), "Gumroad payout").unwrap(); + s.add_line(e, cash, Side::Debit, usd(9_000)).unwrap(); + s.add_line(e, fees, Side::Debit, usd(1_000)).unwrap(); + s.add_line(e, sales, Side::Credit, usd(10_000)).unwrap(); + s.post_entry(e).unwrap(); + s.set_entry_party(e, Some(gumroad)).unwrap(); + + // Payroll: Pat by party, then an older-style line by name; the + // remittance goes to the IRS. + let line = |employee: Option| PayrollLine { + employee_name: "Pat Lee".into(), + employee, + gross_pay: cents(100_000), + federal_withholding: cents(10_000), + state_withholding: cents(3_000), + ss_employee: cents(6_200), + medicare_employee: cents(1_450), + ss_employer: cents(6_200), + medicare_employer: cents(1_450), + other_deductions: Decimal::ZERO, + }; + s.run_payroll(d(2026, 8, 15), vec![line(Some(pat))], cash) + .unwrap(); + s.run_payroll(d(2026, 8, 31), vec![line(None)], cash) + .unwrap(); + let remit = s.remit_payroll_taxes(d(2026, 9, 5), cash).unwrap(); + s.set_entry_party(remit, Some(irs)).unwrap(); + + // Old Co: paid, then archived. + let e = pay(&mut s, d(2026, 1, 20), "Old Co", cash, 12_300); + s.set_entry_party(e, Some(old)).unwrap(); + s.update_party(old, PartyField::Archived(true)).unwrap(); + drop(s); + let oracle = Store::open(&data).unwrap().ledger().clone(); + Fixture { + books: load(&dir.0), + dir, + oracle, + sam, + sam_vendor, + riley, + acme, + gumroad, + irs, + pat, + old, + bill_entry, + invoice_entry, + reversal, + draft, + remit, + } +} + +#[test] +fn each_partys_totals_are_the_books_numbers() { + let f = fixture("totals"); + let b = &f.books; + let o = &f.oracle; + let info = |p| b.party(p).cloned().unwrap(); + + // Sam: this year's cash 1,000 + card 200 + owner 300 + (250 − 250 + // reversed) + 500 of the bill paid. Last year's 500, the bill itself, + // and the draft move nothing this year. + let sam = info(f.sam); + assert_eq!(sam.paid_ytd, cents(200_000)); + let bill = o.get_bill(1).unwrap(); + assert_eq!(sam.billed_ytd, bill.total()); + assert_eq!(sam.open_bills, bill.balance()); + assert_eq!(sam.open_bills, cents(30_000)); + assert_eq!(sam.bills, vec![1]); + // The 1099 amount is ledger-core's worksheet line, exactly. + let r = vendor_1099_report(o, 2026, None).unwrap(); + let line = r + .vendors + .iter() + .find(|v| v.vendor_id == f.sam_vendor) + .unwrap(); + assert_eq!(sam.form_1099.as_ref(), Some(line)); + assert_eq!(sam.threshold_1099, Some(r.threshold)); + // (Cash only: card and owner-paid aren't in it.) + assert_eq!(line.paid, cents(150_000)); + // Every entry tagged, drafts included, newest first. + assert!(sam.entries.contains(&f.bill_entry)); + assert!(sam.entries.contains(&f.reversal)); + assert!(sam.entries.contains(&f.draft)); + assert_eq!(sam.entries.len(), 9, "{:?}", sam.entries); + let dates: Vec = sam + .entries + .iter() + .map(|e| b.entry(*e).unwrap().date) + .collect(); + assert!(dates.windows(2).all(|w| w[0] >= w[1]), "{dates:?}"); + + // Riley: 700 by cash. + assert_eq!(info(f.riley).paid_ytd, cents(70_000)); + + // Acme: received 600 on net (negative paid); invoiced this year 1,000; + // still owes last year's 400 and this year's 400. + let acme = info(f.acme); + assert_eq!(acme.paid_ytd, cents(-60_000)); + let invoices: Vec<_> = (1..o.next_invoice_id()) + .filter_map(|i| o.get_invoice(i)) + .collect(); + assert_eq!( + acme.invoiced_ytd, + invoices + .iter() + .filter(|i| i.date >= d(2026, 1, 1)) + .map(|i| i.total()) + .sum::() + ); + assert_eq!( + acme.open_invoices, + invoices.iter().map(|i| i.balance()).sum::() + ); + assert_eq!(acme.open_invoices, cents(80_000)); + assert_eq!(acme.invoices.len(), 2); + assert!(acme.form_1099.is_none()); + + // Gumroad: the net deposit came in. + assert_eq!(info(f.gumroad).paid_ytd, cents(-9_000)); + + // The IRS: the remittance's cash, from its own lines. + let remit = o.get_entry(f.remit).unwrap(); + let cash = o.chart().find_by_code("1000").unwrap().id; + let out: Decimal = remit + .lines() + .iter() + .filter(|l| l.account == cash && l.side == Side::Credit) + .map(|l| l.amount.amount) + .sum(); + assert!(out > Decimal::ZERO); + assert_eq!(info(f.irs).paid_ytd, out); + + // Pat: both runs (one by party, one by name), no tagged entries. + let pat = info(f.pat); + assert_eq!(pat.payroll_runs.len(), 2); + assert!(pat.entries.is_empty()); + assert_eq!(pat.paid_ytd, Decimal::ZERO); + + // Every entry's party is the one whose list holds it. + for e in &b.entries { + if let Some(p) = e.party_id { + assert!(info(p).entries.contains(&e.id), "#{}", e.id); + assert_eq!(e.party.as_deref(), Some(b.party_label(p).as_str())); + } + } + assert_eq!(b.entry(f.bill_entry).unwrap().party_id, Some(f.sam)); + assert_eq!(b.entry(f.invoice_entry).unwrap().party_id, Some(f.acme)); + assert_eq!(b.entry(f.reversal).unwrap().party_id, Some(f.sam)); +} + +#[test] +fn the_directory_lists_filters_and_hides_archived() { + let f = fixture("list"); + let b = &f.books; + let names = |st: &State| -> Vec { + listed(b, st) + .iter() + .map(|p| p.label().to_string()) + .collect() + }; + let mut st = State::default(); + assert_eq!( + names(&st), + vec![ + "Acme Buyer", + "Gumroad", + "Internal Revenue Service", + "Pat Lee", + "Riley Chen", + "Sam Okafor" + ] + ); + st.show_archived = true; + assert!(names(&st).contains(&"Old Co".to_string())); + st.role = Some(Role::Contractor); + assert_eq!(names(&st), vec!["Old Co", "Riley Chen", "Sam Okafor"]); + st.show_archived = false; + st.query = " sam ".into(); + assert_eq!(names(&st), vec!["Sam Okafor"]); + st.role = None; + st.query = "gumroad inc".into(); + assert_eq!(names(&st), vec!["Gumroad"]); + let counts = role_counts(b, false); + assert!(counts.contains(&(Role::Contractor, 2))); + assert!(counts.contains(&(Role::Vendor, 1))); + assert!(!counts.iter().any(|(r, _)| *r == Role::Landlord)); + let sam = b.ledger.get_party(f.sam).unwrap(); + assert_eq!(roles_text(sam), "Vendor, contractor"); + assert_eq!(w9_status(b, sam), W9Status::Missing); + assert_eq!( + w9_status(b, b.ledger.get_party(f.gumroad).unwrap()), + W9Status::NotNeeded + ); + assert_eq!(masked_tin("1234"), "•••-••-1234"); +} + +#[test] +fn search_finds_parties_by_name() { + let f = fixture("search"); + let b = &f.books; + for q in ["Gumroad", "gumroad, inc.", " GUMROAD "] { + let items = palette::search(b, q); + assert_eq!(items[0].route, Route::Party(f.gumroad), "{q}"); + assert_eq!(items[0].score, 1500, "{q}"); + } + let first_party = |q: &str| { + palette::search(b, q) + .into_iter() + .find(|i| matches!(i.route, Route::Party(_))) + }; + assert_eq!(first_party("rile").unwrap().route, Route::Party(f.riley)); + // Vendors and customers are found as their parties. + assert_eq!( + palette::search(b, "Sam Okafor")[0].route, + Route::Party(f.sam) + ); + assert_eq!(first_party("acme").unwrap().route, Route::Party(f.acme)); + // Archived parties are found, and say so. + let old = first_party("Old Co").unwrap(); + assert_eq!(old.route, Route::Party(f.old)); + assert!(old.detail.contains("archived"), "{}", old.detail); + assert_eq!(palette::describe(b, &Route::Party(f.gumroad)), "Gumroad"); + // Transactions search by party name too. + let sam_entry = b.entry(f.bill_entry).unwrap(); + assert!(sam_entry.haystack.contains("sam okafor")); +} + +#[test] +fn routes_and_books_show_open_parties() { + let f = fixture("routes"); + assert_eq!(Route::Parties.section(), Section::Parties); + assert_eq!(Route::Party(f.sam).section(), Section::Parties); + assert_eq!(Section::Parties.home(), Route::Parties); + assert!(Section::ALL.contains(&Section::Parties)); + let books = load(&f.dir.0); + let mut h = Harness::builder() + .with_size(egui::vec2(1440.0, 920.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); + h.state_mut().show(ViewerTarget::Page { + page: "parties".into(), + }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Parties); + h.state_mut().show(ViewerTarget::Party { + party_id: f.gumroad, + }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Party(f.gumroad)); + // An unknown party is ignored. + h.state_mut().show(ViewerTarget::Party { party_id: 999 }); + h.run_steps(1); + assert_eq!(h.state_mut().route(), &Route::Party(f.gumroad)); + // Every party's page, and the entry page of a tagged entry, render. + for p in [f.sam, f.riley, f.acme, f.gumroad, f.irs, f.pat, f.old, 999] { + h.state_mut().goto(Route::Party(p)); + h.run_steps(2); + } + h.state_mut().goto(Route::Entry(f.bill_entry)); + h.run_steps(2); + h.state_mut().state_mut().parties.show_archived = true; + h.state_mut().goto(Route::Parties); + h.run_steps(2); +} + +#[test] +fn journal_text_for_party_events() { + let f = fixture("journal"); + let b = &f.books; + let texts: Vec<(String, Option, Option)> = b + .journal + .iter() + .filter(|l| l.line_type.starts_with("party.") || l.line_type == "entry.party_set") + .filter_map(|l| describe(b, l)) + .map(|e| (e.text, e.route, e.also)) + .collect(); + let has = |t: &str, route: Option| { + assert!( + texts.iter().any(|(x, r, _)| x == t && *r == route), + "no {t:?} ({route:?}) in {texts:#?}" + ); + }; + has( + "Added party Riley Chen (contractor)", + Some(Route::Party(f.riley)), + ); + has( + "Added party Gumroad, Inc. (payout platform)", + Some(Route::Party(f.gumroad)), + ); + has( + "Display name of Gumroad, Inc. set to Gumroad", + Some(Route::Party(f.gumroad)), + ); + has("Sam Okafor is now a contractor", Some(Route::Party(f.sam))); + has( + "W-4 for Pat Lee recorded, received 2026-01-02 (works in UT)", + Some(Route::Party(f.pat)), + ); + has("Archived Old Co", Some(Route::Party(f.old))); + let tag = texts + .iter() + .find(|(x, _, _)| x.starts_with("Tagged #") && x.ends_with("with Gumroad")) + .expect("entry.party_set text"); + assert!(matches!(tag.1, Some(Route::Entry(_)))); + assert_eq!(tag.2, Some(Route::Party(f.gumroad))); + // Party history: its own events and its records'. + let sam = b.party(f.sam).unwrap(); + let kinds: Vec<&str> = sam + .events + .iter() + .map(|&i| b.journal[i].line_type.as_str()) + .collect(); + for k in [ + "vendor.added", + "vendor.1099_set", + "party.role_set", + "entry.vendor_set", + "entry.party_set", + ] { + assert!(kinds.contains(&k), "{k} not in {kinds:?}"); + } +} + +#[test] +fn w9_text_never_shows_more_than_the_last_four() { + let f = fixture("w9text"); + let mut s = Store::open(&f.dir.0.join("books-data")).unwrap(); + s.set_party_w9(f.riley, Some(w9("6789"))).unwrap(); + s.set_party_w9(f.riley, None).unwrap(); + drop(s); + let b = load(&f.dir.0); + let texts: Vec = b + .journal + .iter() + .filter(|l| l.line_type == "party.w9_set") + .filter_map(|l| describe(&b, l)) + .map(|e| e.text) + .collect(); + assert_eq!( + texts, + vec![ + "W-9 for Riley Chen recorded, received 2026-09-01: Individual/sole proprietor, TIN ending 6789", + "W-9 facts for Riley Chen cleared", + ] + ); +} + +#[test] +fn attention_asks_for_a_w9_until_its_facts_are_recorded() { + let f = fixture("attention"); + let items = attention::find(&f.books); + let item = |name: &str| { + items + .iter() + .find(|a| a.title == format!("Collect a W-9 from {name}")) + }; + let riley = item("Riley Chen").expect("Riley's W-9 item"); + assert_eq!(riley.target, Some(Route::Party(f.riley))); + assert!(riley.detail.contains("$700.00"), "{}", riley.detail); + assert!(riley.prompt.starts_with("In wanderbooks,")); + assert!(riley.prompt.contains("books.set_w9")); + assert!(riley.prompt.contains("last four")); + assert!(item("Sam Okafor").is_some(), "Sam is paid, no W-9"); + assert!(item("Old Co").is_none(), "archived"); + assert!(item("Gumroad").is_none(), "no 1099 role"); + // The existing items are still found. + assert!( + items + .iter() + .any(|a| a.title == "$300.00 owed on Due to Shareholders"), + "{:#?}", + items.iter().map(|a| &a.title).collect::>() + ); + + // Recording the facts clears it. + let mut s = Store::open(&f.dir.0.join("books-data")).unwrap(); + s.set_party_w9(f.riley, Some(w9("1234"))).unwrap(); + drop(s); + let b = load(&f.dir.0); + let items = attention::find(&b); + assert!( + !items + .iter() + .any(|a| a.title == "Collect a W-9 from Riley Chen") + ); + assert!( + items + .iter() + .any(|a| a.title == "Collect a W-9 from Sam Okafor") + ); + assert_eq!( + w9_status(&b, b.ledger.get_party(f.riley).unwrap()), + W9Status::OnFile { + last4: "1234".into(), + received: d(2026, 9, 1) + } + ); +} diff --git a/crates/ledger-gui/tests/read_only.rs b/crates/ledger-gui/tests/read_only.rs index 48e1282..79d5d13 100644 --- a/crates/ledger-gui/tests/read_only.rs +++ b/crates/ledger-gui/tests/read_only.rs @@ -134,6 +134,7 @@ fn the_viewer_never_writes_into_the_books() { .next() .map(|a| a.id) .unwrap(); + let party = books.ledger.parties().next().map(|p| p.id).unwrap(); drop(books); // Follow live for a moment. @@ -174,6 +175,8 @@ fn the_viewer_never_writes_into_the_books() { Route::Account(cash), Route::Assets, Route::Asset(asset), + Route::Parties, + Route::Party(party), Route::Document(doc), Route::JournalEvent(event), Route::Documents, diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index 37f9785..ca006b1 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -51,6 +51,19 @@ fn screenshots() { .next() .map(|a| a.id) .unwrap_or(1); + // A party with the most to show: W-9 facts first, else any. + let party = std::env::var("SHOT_PARTY") + .ok() + .and_then(|p| p.parse().ok()) + .or_else(|| { + loaded + .ledger + .parties() + .find(|p| p.w9.is_some() && p.vendor.is_some()) + .or_else(|| loaded.ledger.parties().next()) + .map(|p| p.id) + }) + .unwrap_or(1); drop(loaded); let routes: Vec<(&str, Route)> = vec![ ("overview", Route::Overview), @@ -82,6 +95,8 @@ fn screenshots() { ("account", Route::Account(first_cash)), ("assets", Route::Assets), ("asset", Route::Asset(first_asset)), + ("parties", Route::Parties), + ("party", Route::Party(party)), ("document", Route::Document(last_doc)), ("event", Route::JournalEvent(last_event)), ("documents", Route::Documents), diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index 12bc9c3..cf45e3a 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -614,6 +614,7 @@ fn more_origins_and_parties() { today, vec![ledger_core::PayrollLine { employee_name: "Pat".into(), + employee: None, gross_pay: Decimal::new(100_000, 2), federal_withholding: Decimal::new(10_000, 2), state_withholding: Decimal::new(3_000, 2), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index 9752b28..bdf482d 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -23,9 +23,9 @@ //! preview MUST change nothing. //! 5. An entry the receipt policy covers MUST NOT post, or keep posted, //! without a receipt (crate::policy). -//! 6. A reference (account, vendor, class) given as a number that names -//! one thing by id and another by code or name is refused, never -//! guessed. +//! 6. A reference (account, vendor, class, party) given as a number +//! that names one thing by id and another by code or name is +//! refused, never guessed. //! 7. The server MUST answer every request with exactly one response //! whose id is the request's, and keep serving after any tool error. //! ``` @@ -54,7 +54,7 @@ use rust_decimal::Decimal; use std::sync::{Arc, Mutex}; /// The agent operating contract, delivered via initialize. -const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor (books.draft_entry vendor, or books.set_entry_vendor; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; +const INSTRUCTIONS: &str = "You are the bookkeeping agent for these books. The engine enforces the accounting; you enforce the workflow.\n\nRULES\n- Money is exact decimal. Always pass amounts as decimal strings (\"123.45\"), never floats.\n- Never post directly. The flow is draft (books.draft_entry) -> add lines (books.add_line) -> preview (books.post_entry without confirmed) -> review the preview -> post (books.post_entry with confirmed=true).\n- Every entry must balance: debits equal credits, exactly. Every line needs a real account from books.chart. The engine rejects anything else.\n- Receipts: any entry moving more than $25.00 in debits needs at least one receipt attached (books.attach_receipt) before posting. The server rejects the post otherwise. This is enforced, not a suggestion.\n- Never invent accounts. If the account you need does not exist, add it with books.add_account first.\n- Corrections go through books.reverse_entry. Posted entries are immutable; there is no edit.\n- Receipt corrections: receipts can change, but nothing is erased. To replace one, attach the new receipt, then books.unlink_receipt the old (with a reason); retire unwanted receipts or documents with books.withdraw_attachment (with a reason). The journal keeps every step.\n- After posting, check books.digest to review what changed, so a human can review too.\n- Amounts in every response are decimal strings. Treat them as exact.\n- Fixed assets: register with books.register_asset, inspect with books.asset_schedule and books.list_assets, post monthly depreciation with books.run_depreciation (exempt from the receipt policy; the schedule is the evidence), and dispose with books.dispose_asset (preview first; the bill of sale is the receipt). Depreciation is straight-line, book basis only - tax depreciation stays with the CPA. Every asset gets a tag for the label on the thing itself (books.register_asset generates the next one, like WL-0001, unless the human has their own); record the manufacturer's serial number too, change either with books.label_asset, and print label sheets with books.asset_labels (save the PDF; print at 100% on Avery 5160/8160). Asset tools take the tag in place of the id. Record every asset's federal tax basis beside its book value (books.set_asset_tax_basis, or tax_basis_rule on books.register_asset): cost for a purchase; for a member's contribution, the member's adjusted basis (what they paid, less depreciation they took) with rule personal_use_conversion (their personal property: tax basis is the lesser of value and their basis) or carryover (their business property). Book value stays fair market value. books.export fixed_asset_register is the register for the CPA; books.show asset opens one in the viewer.\n- Prepaid expenses and deferred revenue: record the original payment (with its receipt), then books.create_schedule; books.run_schedules posts the monthly amortization (preview first; exempt from the receipt policy like depreciation). Recurring entries (books.create_recurring, books.run_recurring) only ever create drafts: attach each actual receipt and post through the normal preview.\n- Platform payouts (app stores, payment processors, marketplaces): never book the net deposit as revenue. books.record_payout grosses it up (gross revenue, fees, adjustments) into a draft and refuses unless it reconciles to the cent; attach the payout statement, then preview and post.\n- The viewer: the human usually has the read-only viewer open beside you (`just gui`). When they ask to see something, and after you post, call books.show so it's on their screen.\n- Parties: keep the directory current. Every counterparty (vendor, contractor, landlord, customer, payout platform, tax agency, bank or card issuer, member, employee) gets a party: books.add_party with its roles (books.add_vendor and books.add_customer link their record to the party of the same name, or make one). Tag every entry with its party (books.draft_entry party, or books.set_entry_party; a party with a vendor record tags the vendor for 1099s too). Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. Never store a full TIN or SSN in the books, only the last four digits: the engine refuses any nine-digit number in a party field. books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file; never store a TIN), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA.\n- Rules that change with the law: wanderbooks knows some legal amounts (like the 1099 threshold) only through a given year and never guesses past it. When a tool refuses with \"wanderbooks knows the ... only through\", or books.digest lists stale_rules: check today's date, find the current figure from the official source (the refusal names the vendored source file) or ask the human (owner, CPA), confirm it with them, and pass it explicitly. Then update the rule for everyone: open a pull request at https://github.com/swaits/wanderbooks with the new value and its official text (best), or at least an issue; if you can't reach GitHub, give the human the issue text to file.\n- Control roles: invoices, bills, payroll, and closes post to the account filling each role (receivables, revenue, payables, general expense, retained earnings, payroll expense and withholdings); books.roles lists them. A role uses its default code (1200, 4000, 2000, 6999, 3999, 6100, 6110, 2110-2150) until it is assigned one of the chart's own accounts with books.assign_role (journaled). If the chart uses a default code for a different kind of account, the engine refuses and says which. It can't tell an account of the right type but another purpose (a hosting expense at payroll's 6100): check books.roles before running payroll, bills, or invoices. Never assign roles or renumber the human's accounts on your own: explain the conflict, and when they decide, assign the role to their account (best: their numbering stays) or books.renumber_account the account (its history stays).\n- Settings: wanderbooks.toml beside the books holds the company's facts and settings (name, fiscal year, tax form, members and their capital accounts, basis, receipt threshold, cash flow roles, close target); books.settings shows what is in force and lists problems, settings that disagree with the chart. Edit the file when the human tells you a company fact, when a tool refuses naming a wanderbooks.toml setting, when books.settings or books.digest lists a settings problem, and after books.renumber_account lists config_mentions. The server reloads the file whenever it changes, with no restart: after each edit, call books.settings and check reload_error is null (a broken edit leaves the last good settings in force) and problems is empty. Never put a member's SSN or any TIN in it."; /// Max receipt/document upload: 25 MB. const MAX_UPLOAD_BYTES: usize = 25 * 1024 * 1024; @@ -71,6 +71,25 @@ pub struct BooksServer { #[cfg(test)] pub use crate::settings::CashRoles; +/// Where payments to vendors come from, for the 1099 worksheet: the +/// `[books] cash_accounts` and `financing_accounts` in the chart (codes the +/// chart lacks are skipped; `books.settings` reports them). +fn payment_accounts( + ledger: &ledger_core::Ledger, + roles: &crate::settings::CashRoles, +) -> ledger_core::PaymentAccounts { + let ids = |codes: &[String]| { + codes + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect() + }; + ledger_core::PaymentAccounts { + cash: ids(&roles.cash), + financing: ids(&roles.financing), + } +} + impl BooksServer { /// The wanderbooks.toml settings that name account `code`. fn config_mentions(&self, code: &str) -> Vec { @@ -626,6 +645,189 @@ fn resolve_vendor(store: &Store, vendor: &str) -> Result { } } +/// Resolve a party by id or by name: the name ignoring case and +/// surrounding space, among parties not archived (an archived party goes +/// by id). A number that is one party's id and another's name is refused +/// (INVARIANT 6). +fn resolve_party(store: &Store, party: &str) -> Result { + let ledger = store.ledger(); + let by_id = party + .trim() + .parse::() + .ok() + .filter(|id| ledger.get_party(*id).is_some()); + let by_name = ledger.party_by_name(party).map(|p| p.id); + match (by_id, by_name) { + (Some(i), Some(n)) if i != n => Err(format!( + "party {party:?} is ambiguous: it is party {i}'s id and party {n}'s name; pass the id" + )), + (Some(i), _) | (None, Some(i)) => Ok(i), + (None, None) => Err(format!( + "unknown party {party:?}: use an id or name from books.parties (an archived party goes by id), or add it with books.add_party" + )), + } +} + +/// Parse role names (`Role::key`), refusing any unknown one. +fn parse_roles(roles: &[String]) -> Result, String> { + roles + .iter() + .map(|r| { + ledger_core::Role::parse(r.trim()).ok_or_else(|| { + format!( + "unknown role {r:?}: expected one of {}", + ledger_core::Role::ALL.map(|r| r.key()).join(", ") + ) + }) + }) + .collect() +} + +/// A party's roles (vendor and customer included), in display order. +fn party_roles(p: &ledger_core::Party) -> Vec { + let all = p.all_roles(); + ledger_core::Role::ALL + .into_iter() + .filter(|r| all.contains(r)) + .map(|r| r.key().to_string()) + .collect() +} + +/// A party's facts for a tool's output. +fn party_out(p: &ledger_core::Party) -> PartyOut { + PartyOut { + party_id: p.id, + name: p.name.clone(), + label: p.label().to_string(), + display_name: p.display_name.clone(), + roles: party_roles(p), + website: p.website.clone(), + address: p.address.clone(), + notes: p.notes.clone(), + archived: p.archived, + vendor_id: p.vendor, + customer_id: p.customer, + w9: p.w9.as_ref().map(|w| W9Out { + received_on: w.received.to_string(), + entity_type: w.entity_type.clone(), + exempt_payee_code: w.exempt_payee_code.clone(), + fatca_code: w.fatca_code.clone(), + tin_last4: w.tin_last4.clone(), + kept_at: w.kept_at.clone(), + }), + w4: p.w4.as_ref().map(|w| W4Out { + received_on: w.received.to_string(), + state: w.state.clone(), + kept_at: w.kept_at.clone(), + }), + } +} + +/// An optional party text from a tool: trimmed, and "" clears it. +fn optional_text(s: &str) -> Option { + let t = s.trim(); + (!t.is_empty()).then(|| t.to_string()) +} + +/// A document's status for a tool's output. +fn status_key(s: DocumentStatus) -> String { + match s { + DocumentStatus::Open => "open", + DocumentStatus::Partial => "partial", + DocumentStatus::Paid => "paid", + } + .to_string() +} + +/// An invoice or bill for `books.party`: its (date, due date), (total, +/// paid so far), status, and entry. +fn party_document( + id: u64, + (date, due): (NaiveDate, NaiveDate), + (total, paid): (Decimal, Decimal), + status: DocumentStatus, + entry_id: u64, +) -> PartyDocumentOut { + PartyDocumentOut { + id, + date: date.to_string(), + due_date: due.to_string(), + total: money(total), + paid: money(paid), + balance: money(total - paid), + status: status_key(status), + entry_id, + } +} + +/// Everything the books did with one party (`PartyTotalsOut` defines it). +struct PartyActivity { + /// The party's entries, drafts included. + entries: std::collections::BTreeSet, + /// Paid to it in the year. + paid: Decimal, + /// Received from it in the year. + received: Decimal, +} + +/// The party's entries and the year's money paid and received +/// (`PartyTotalsOut`): ledger_core::parties::activity over the calendar +/// year, with the `[books] cash_accounts`. +fn party_activity( + store: &Store, + party: &ledger_core::Party, + cash_codes: &[String], + year: i32, +) -> Result { + let ledger = store.ledger(); + let cash: Vec = cash_codes + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect(); + let span = ( + NaiveDate::from_ymd_opt(year, 1, 1).ok_or("year out of range")?, + NaiveDate::from_ymd_opt(year, 12, 31).ok_or("year out of range")?, + ); + let a = ledger_core::parties::activity(ledger, party.id, span, &cash); + Ok(PartyActivity { + entries: a.entries, + paid: a.paid, + received: a.received, + }) +} + +/// The party's customer record's invoices, by id. +fn party_invoices<'a>( + ledger: &'a Ledger, + party: &ledger_core::Party, +) -> Vec<&'a ledger_core::Invoice> { + let Some(c) = party.customer else { + return Vec::new(); + }; + ledger + .invoices() + .into_iter() + .filter(|i| i.customer_id == c) + .collect() +} + +/// The party's vendor record's bills, by id. +fn party_bills<'a>(ledger: &'a Ledger, party: &ledger_core::Party) -> Vec<&'a ledger_core::Bill> { + let Some(v) = party.vendor else { + return Vec::new(); + }; + ledger + .bills() + .into_iter() + .filter(|b| b.vendor_id == v) + .collect() +} + +/// This calendar year. +fn this_year() -> i32 { + chrono::Datelike::year(&today()) +} + fn form_1099_label(form: Option) -> Option { form.map(|f| f.label().to_string()) } @@ -1098,7 +1300,7 @@ impl BooksServer { #[tool( name = "books.draft_entry", - description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line." + description = "Start a draft journal entry. Drafts are journaled and survive restarts, but don't affect reports until posted; add lines with books.add_line. Pass party (id or name) for the counterparty: every entry with a vendor, platform, agency, bank, member, or contractor names its party (a party with a vendor record tags the vendor too, for 1099s)." )] async fn draft_entry( &self, @@ -1106,15 +1308,34 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let mut store = self.lock()?; + // Resolve every reference before writing anything (INVARIANT 2). + let date = parse_date(&p.date)?; let vendor = p .vendor .as_deref() .map(|v| resolve_vendor(&store, v)) .transpose()?; + let party = p + .party + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + if let (Some(v), Some(pid)) = (vendor, party) { + let party_vendor = store.ledger().get_party(pid).and_then(|p| p.vendor); + if party_vendor != Some(v) { + return Err(format!( + "vendor {v} isn't party {pid}'s vendor record: pass only the party (its vendor record is tagged with it), or the vendor alone" + )); + } + } let id = store - .create_entry(parse_date(&p.date)?, &p.description) + .create_entry(date, &p.description) .map_err(|e| e.to_string())?; - if vendor.is_some() { + if party.is_some() { + store + .set_entry_party(id, party) + .map_err(|e| e.to_string())?; + } else if vendor.is_some() { store .set_entry_vendor(id, vendor) .map_err(|e| e.to_string())?; @@ -1604,6 +1825,12 @@ impl BooksServer { .entry_vendor(p.entry_id) .and_then(|v| store.ledger().get_vendor(v)) .map(|v| v.name.clone()), + party_id: store.ledger().entry_party(p.entry_id), + party: store + .ledger() + .entry_party(p.entry_id) + .and_then(|id| store.ledger().get_party(id)) + .map(|p| p.label().to_string()), })) } @@ -1653,7 +1880,7 @@ impl BooksServer { #[tool( name = "books.show", - description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", + description = "Show something in the human's viewer (the read-only desktop app, `just gui`): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.", annotations(read_only_hint = true) )] async fn show(&self, params: Parameters) -> Result, String> { @@ -1667,6 +1894,7 @@ impl BooksServer { p.report.is_some(), p.attachment_id.is_some(), p.asset.is_some(), + p.party.is_some(), p.search.is_some(), p.page.is_some(), ] @@ -1675,7 +1903,7 @@ impl BooksServer { .count(); if given != 1 { return Err( - "give exactly one of entry_id, account, report, attachment_id, asset, search, page" + "give exactly one of entry_id, account, report, attachment_id, asset, party, search, page" .into(), ); } @@ -1695,7 +1923,7 @@ impl BooksServer { "ap_aging", "fixed_asset_register", ]; - const PAGES: [&str; 10] = [ + const PAGES: [&str; 11] = [ "overview", "cash", "profit", @@ -1703,6 +1931,7 @@ impl BooksServer { "reports", "accounts", "assets", + "parties", "documents", "company", "journal", @@ -1721,6 +1950,13 @@ impl BooksServer { a.description ), ) + } else if let Some(r) = &p.party { + let id = resolve_party(&store, r)?; + let party = ledger.get_party(id).expect("resolved"); + ( + ViewerTarget::Party { party_id: id }, + format!("party {id}, {}", party.label()), + ) } else if let Some(id) = p.entry_id { let e = ledger .get_entry(id) @@ -2357,7 +2593,7 @@ impl BooksServer { #[tool( name = "books.list_vendors", - description = "Vendors, with which 1099 each gets and whether its W-9 is on file.", + description = "Vendors, with which 1099 each gets, whether its W-9 is on file, and the party each belongs to (books.party).", annotations(read_only_hint = true) )] async fn list_vendors(&self) -> Result, String> { @@ -2372,6 +2608,7 @@ impl BooksServer { name: v.name.clone(), form_1099: form_1099_label(v.form_1099), tin_on_file: v.tin_on_file, + party_id: store.ledger().party_of_vendor(v.id), }) .collect(), })) @@ -2404,6 +2641,7 @@ impl BooksServer { name: v.name.clone(), form_1099: form_1099_label(v.form_1099), tin_on_file: v.tin_on_file, + party_id: store.ledger().party_of_vendor(id), })) } @@ -2447,8 +2685,13 @@ impl BooksServer { let store = self.lock()?; // A year past what wanderbooks knows is refused with guidance // (research or ask, pass `threshold`, then update the rule). - let r = ledger_core::vendor_1099_report(store.ledger(), p.year, threshold) - .map_err(|e| e.to_string())?; + let r = ledger_core::vendor_1099_report_with( + store.ledger(), + p.year, + threshold, + &payment_accounts(store.ledger(), &self.settings().roles), + ) + .map_err(|e| e.to_string())?; let missing_w9 = r .vendors .iter() @@ -3793,30 +4036,460 @@ impl BooksServer { #[tool( name = "books.add_customer", - description = "Add a customer for accounts receivable." + description = "Add a customer record for accounts receivable (invoices). It links to the party of the same name that has no customer record yet (ignoring case), else to a new party; the output names both ids." )] async fn add_customer( &self, - params: Parameters, - ) -> Result, String> { + params: Parameters, + ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let id = store.add_customer(&p.name).map_err(|e| e.to_string())?; - Ok(Json(AddPartyOut { id })) + let party_id = store + .ledger() + .party_of_customer(id) + .ok_or_else(|| format!("customer {id} has no party"))?; + Ok(Json(AddRecordOut { id, party_id })) } #[tool( name = "books.add_vendor", - description = "Add a vendor for accounts payable." + description = "Add a vendor record for accounts payable (bills) and 1099s. It links to the party of the same name that has no vendor record yet (ignoring case), else to a new party; the output names both ids." )] async fn add_vendor( &self, - params: Parameters, - ) -> Result, String> { + params: Parameters, + ) -> Result, String> { let p = params.0; let mut store = self.lock()?; let id = store.add_vendor(&p.name).map_err(|e| e.to_string())?; - Ok(Json(AddPartyOut { id })) + let party_id = store + .ledger() + .party_of_vendor(id) + .ok_or_else(|| format!("vendor {id} has no party"))?; + Ok(Json(AddRecordOut { id, party_id })) + } + + // ------------------------------------------------------------------ + // Parties (ledger_core::parties) + // ------------------------------------------------------------------ + + #[tool( + name = "books.add_party", + description = "Add a party to the directory: anyone the company deals with (a contractor, landlord, payout platform, tax agency, bank or card issuer, member, employee). Name it as on its documents (unique among parties not archived); give its roles: contractor, employee, member, payout_platform, tax_agency, financial_institution, landlord. Vendor and customer aren't given by hand: books.add_vendor and books.add_customer add those records and link them to the party of the same name. Never put a full TIN or SSN in any party field: the engine refuses nine-digit numbers. Journaled as party.added; returns the party." + )] + async fn add_party( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut roles = parse_roles(&p.roles)?; + roles.sort(); + roles.dedup(); + let mut store = self.lock()?; + let id = store + .add_party(&p.name, &roles) + .map_err(|e| e.to_string())?; + let party = store.ledger().get_party(id).expect("just added"); + Ok(Json(party_out(party))) + } + + #[tool( + name = "books.update_party", + description = "Change a party's facts: name, display_name, website, address, notes, archived. Give only the fields to change; an empty string clears display_name, website, address, or notes. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters) changes nothing; each is journaled as its own party.updated. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party." + )] + async fn update_party( + &self, + params: Parameters, + ) -> Result, String> { + use ledger_core::PartyField as F; + let p = params.0; + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let mut fields = Vec::new(); + if let Some(n) = p.name { + fields.push(F::Name(n)); + } + if let Some(v) = p.display_name { + fields.push(F::DisplayName(optional_text(&v))); + } + if let Some(v) = p.website { + fields.push(F::Website(optional_text(&v))); + } + if let Some(v) = p.address { + fields.push(F::Address(optional_text(&v))); + } + if let Some(v) = p.notes { + fields.push(F::Notes(optional_text(&v))); + } + if let Some(a) = p.archived { + fields.push(F::Archived(a)); + } + if fields.is_empty() { + return Err( + "give at least one field to change: name, display_name, website, address, notes, archived" + .into(), + ); + } + // Every field, in order, before any is written (INVARIANT 2). + let mut rehearsal = store.ledger().clone(); + for f in &fields { + rehearsal + .update_party(id, f.clone()) + .map_err(|e| e.to_string())?; + } + for f in fields { + store.update_party(id, f).map_err(|e| e.to_string())?; + } + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(party))) + } + + #[tool( + name = "books.set_party_roles", + description = "Give a party roles (add) or take them away (remove): contractor, employee, member, payout_platform, tax_agency, financial_institution, landlord. Vendor and customer follow their records and can't be set by hand. Adds apply before removes; a role it already has (or lacks) is left alone. Removing a party's last 1099 role (contractor, landlord) needs its W-9 facts cleared first (books.set_w9 clear), and removing employee its W-4 facts. All or nothing: a refusal changes nothing. Each change is journaled as party.role_set. Returns the party." + )] + async fn set_party_roles( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let add = parse_roles(&p.add)?; + let remove = parse_roles(&p.remove)?; + if add.is_empty() && remove.is_empty() { + return Err("give at least one role to add or remove".into()); + } + if let Some(r) = add.iter().find(|r| remove.contains(r)) { + return Err(format!("role {} is both added and removed", r.key())); + } + if add + .iter() + .chain(&remove) + .any(|r| matches!(r, ledger_core::Role::Vendor | ledger_core::Role::Customer)) + { + return Err( + "vendor and customer roles follow their records: books.add_vendor or books.add_customer" + .into(), + ); + } + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let has = store + .ledger() + .get_party(id) + .expect("resolved") + .roles + .clone(); + let mut changes: Vec<(ledger_core::Role, bool)> = Vec::new(); + for r in add { + if !has.contains(&r) && !changes.contains(&(r, true)) { + changes.push((r, true)); + } + } + for r in remove { + if has.contains(&r) && !changes.contains(&(r, false)) { + changes.push((r, false)); + } + } + // The whole change, in order, before any is written (INVARIANT 2). + let mut rehearsal = store.ledger().clone(); + for (r, on) in &changes { + rehearsal + .set_party_role(id, *r, *on) + .map_err(|e| e.to_string())?; + } + for (r, on) in changes { + store.set_party_role(id, r, on).map_err(|e| e.to_string())?; + } + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(party))) + } + + #[tool( + name = "books.set_w9", + description = "Record what a contractor's or landlord's W-9 says, or clear it (clear: true alone): the date received (received_on), line 3 entity type, line 4 exempt payee and FATCA codes, the TIN's last four digits only, and where the form is kept. Never pass the full TIN anywhere: the engine refuses any nine-digit number. The party needs a 1099 role (contractor or landlord: books.set_party_roles). Record it before paying them. A party with a vendor record also gets its W-9 marked on file there (books.vendor_1099 missing_w9). Journaled as party.w9_set. Returns the party." + )] + async fn set_w9(&self, params: Parameters) -> Result, String> { + let p = params.0; + let facts = if p.clear { + if p.received_on.is_some() + || p.entity_type.is_some() + || p.exempt_payee_code.is_some() + || p.fatca_code.is_some() + || p.tin_last4.is_some() + || p.kept_at.is_some() + { + return Err("clear: true takes no other W-9 fact".into()); + } + None + } else { + let need = |v: &Option, name: &str| { + v.as_deref() + .map(|s| s.trim().to_string()) + .ok_or_else(|| format!("{name} is required (or pass clear: true)")) + }; + Some(ledger_core::W9Facts { + received: parse_date(&need(&p.received_on, "received_on")?)?, + entity_type: need(&p.entity_type, "entity_type")?, + exempt_payee_code: p.exempt_payee_code.as_deref().and_then(optional_text), + fatca_code: p.fatca_code.as_deref().and_then(optional_text), + tin_last4: need(&p.tin_last4, "tin_last4")?, + kept_at: need(&p.kept_at, "kept_at")?, + vault_record: None, + }) + }; + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let on_file = facts.is_some(); + store.set_party_w9(id, facts).map_err(|e| e.to_string())?; + // Keep the vendor record's W-9 flag (the 1099 worksheet's) in step. + let vendor = store + .ledger() + .get_party(id) + .and_then(|p| p.vendor) + .and_then(|v| store.ledger().get_vendor(v)) + .map(|v| (v.id, v.form_1099, v.tin_on_file)); + if let Some((v, form, tin_on_file)) = vendor + && tin_on_file != on_file + { + store + .set_vendor_1099(v, form, on_file) + .map_err(|e| e.to_string())?; + } + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(party))) + } + + #[tool( + name = "books.set_w4", + description = "Record what an employee's W-4 says, or clear it (clear: true alone): the date received (received_on), the work state (two letters), and where the form is kept. Never the SSN. The party needs the employee role. Record it before their first payroll. Journaled as party.w4_set. Returns the party." + )] + async fn set_w4(&self, params: Parameters) -> Result, String> { + let p = params.0; + let facts = if p.clear { + if p.received_on.is_some() || p.state.is_some() || p.kept_at.is_some() { + return Err("clear: true takes no other W-4 fact".into()); + } + None + } else { + let need = |v: &Option, name: &str| { + v.as_deref() + .map(|s| s.trim().to_string()) + .ok_or_else(|| format!("{name} is required (or pass clear: true)")) + }; + Some(ledger_core::W4Facts { + received: parse_date(&need(&p.received_on, "received_on")?)?, + state: need(&p.state, "state")?, + kept_at: need(&p.kept_at, "kept_at")?, + }) + }; + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + store.set_party_w4(id, facts).map_err(|e| e.to_string())?; + let party = store.ledger().get_party(id).expect("resolved"); + Ok(Json(party_out(party))) + } + + #[tool( + name = "books.parties", + description = "The party directory, by id: each party's name, label, roles (vendor and customer included), vendor and customer record ids, whether W-9 facts (with the TIN's last four) and W-4 facts are on file, archived, and what was paid to and received from it in the year (default this year; defined as in books.party). Filter by role; archived parties only with include_archived.", + annotations(read_only_hint = true) + )] + async fn parties( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let role = p + .role + .as_deref() + .map(|r| parse_roles(&[r.to_string()]).map(|v| v[0])) + .transpose()?; + let year = p.year.unwrap_or_else(this_year); + let cash = self.settings().roles.cash.clone(); + let store = self.lock()?; + let mut parties = Vec::new(); + for party in store.ledger().parties() { + if party.archived && !p.include_archived { + continue; + } + if role.is_some_and(|r| !party.all_roles().contains(&r)) { + continue; + } + let act = party_activity(&store, party, &cash, year)?; + parties.push(PartySummaryOut { + party_id: party.id, + name: party.name.clone(), + label: party.label().to_string(), + roles: party_roles(party), + vendor_id: party.vendor, + customer_id: party.customer, + w9_on_file: party.w9.is_some(), + tin_last4: party.w9.as_ref().map(|w| w.tin_last4.clone()), + w4_on_file: party.w4.is_some(), + archived: party.archived, + paid: money(act.paid), + received: money(act.received), + }); + } + Ok(Json(ListPartiesOut { year, parties })) + } + + #[tool( + name = "books.party", + description = "One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (default this year: paid to it, received from it, invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.", + annotations(read_only_hint = true) + )] + async fn party(&self, params: Parameters) -> Result, String> { + let p = params.0; + let year = p.year.unwrap_or_else(this_year); + let cash = self.settings().roles.cash.clone(); + let store = self.lock()?; + let ledger = store.ledger(); + let id = resolve_party(&store, &p.party)?; + let party = ledger.get_party(id).expect("resolved"); + let act = party_activity(&store, party, &cash, year)?; + let in_year = |d: NaiveDate| chrono::Datelike::year(&d) == year; + let mut entries: Vec = act + .entries + .iter() + .filter_map(|&e| ledger.get_entry(e)) + .map(|e| PartyEntryOut { + entry_id: e.id(), + date: e.date().to_string(), + description: e.description().to_string(), + status: match e.status() { + ledger_core::EntryStatus::Draft => "draft", + ledger_core::EntryStatus::Posted => "posted", + } + .to_string(), + total: money(ReceiptPolicy::entry_debits(e)), + reversal_of: e.reversal_of(), + }) + .collect(); + entries.sort_by(|a, b| b.date.cmp(&a.date).then(b.entry_id.cmp(&a.entry_id))); + let invoices = party_invoices(ledger, party); + let bills = party_bills(ledger, party); + let amount_1099 = match party.vendor { + Some(v) => { + let r = ledger_core::vendor_1099_report_with( + ledger, + year, + Some(Decimal::ZERO), + &payment_accounts(ledger, &self.settings().roles), + ) + .map_err(|e| e.to_string())?; + Some(money( + r.vendors + .iter() + .find(|l| l.vendor_id == v) + .map(|l| l.paid) + .unwrap_or_default(), + )) + } + None => None, + }; + let mut payroll = Vec::new(); + let (mut gross, mut net) = (Decimal::ZERO, Decimal::ZERO); + for run in ledger.list_payroll_runs() { + for line in run.lines.iter().filter(|l| l.employee == Some(id)) { + if in_year(run.date) { + gross += line.gross_pay; + net += line.net_pay(); + } + payroll.push(PartyPayrollOut { + run_id: run.id, + date: run.date.to_string(), + entry_id: run.entry_id, + gross_pay: money(line.gross_pay), + net_pay: money(line.net_pay()), + }); + } + } + let totals = PartyTotalsOut { + paid: money(act.paid), + received: money(act.received), + invoiced: money( + invoices + .iter() + .filter(|i| in_year(i.date)) + .map(|i| i.total()) + .sum(), + ), + billed: money( + bills + .iter() + .filter(|b| in_year(b.date)) + .map(|b| b.total()) + .sum(), + ), + owed: money(bills.iter().map(|b| b.balance()).sum()), + receivable: money(invoices.iter().map(|i| i.balance()).sum()), + amount_1099, + form_1099: party + .vendor + .and_then(|v| ledger.get_vendor(v)) + .and_then(|v| form_1099_label(v.form_1099)), + gross_pay: money(gross), + net_pay: money(net), + }; + Ok(Json(PartyDetailOut { + party: party_out(party), + year, + totals, + entries, + invoices: invoices + .iter() + .map(|i| { + party_document( + i.id, + (i.date, i.due_date), + (i.total(), i.amount_paid), + i.status, + i.entry_id, + ) + }) + .collect(), + bills: bills + .iter() + .map(|b| { + party_document( + b.id, + (b.date, b.due_date), + (b.total(), b.amount_paid), + b.status, + b.entry_id, + ) + }) + .collect(), + payroll, + })) + } + + #[tool( + name = "books.set_entry_party", + description = "Tag an entry (draft or posted) with the party it was with (id or name), or pass party null (or omit it) to clear the tag. A party with a vendor record also tags the entry's vendor, so a payment to a contractor or landlord counts in books.vendor_1099; a party without one, or clearing, clears the vendor tag. Metadata only: amounts never change. Journaled as entry.party_set. Returns the entry (as books.get_entry)." + )] + async fn set_entry_party( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + { + let mut store = self.lock()?; + let party = p + .party + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + store + .set_entry_party(p.entry_id, party) + .map_err(|e| e.to_string())?; + } + self.get_entry(Parameters(GetEntryParams { + entry_id: p.entry_id, + })) + .await } #[tool( @@ -4046,7 +4719,7 @@ impl BooksServer { #[tool( name = "books.run_payroll", - description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats." + description = "Run payroll for a pay date. Each line carries one employee's gross pay and every withholding/deduction as decimal strings — the engine cuts one balanced entry (Dr Salary & Wage Expense, Dr Payroll Tax Expense for the employer share, Cr the withholding payables, Cr cash for net pay). Auto-creates the 61xx/21xx accounts. All amounts are decimal strings, never floats. Name each line's employee party (employee: id or name, a party with the employee role; add one with books.add_party and record its W-4 with books.set_w4 first): new runs should, so the run shows on the employee's books.party." )] async fn run_payroll( &self, @@ -4059,8 +4732,28 @@ impl BooksServer { .lines .into_iter() .map(|l| { + let employee = l + .employee + .as_deref() + .map(|r| resolve_party(&store, r)) + .transpose()?; + let employee_name = match (l.employee_name, employee) { + (Some(n), _) => n, + (None, Some(id)) => store + .ledger() + .get_party(id) + .map(|p| p.name.clone()) + .unwrap_or_default(), + (None, None) => { + return Err( + "each payroll line names its employee: employee (a party) or employee_name" + .to_string(), + ); + } + }; Ok(ledger_core::PayrollLine { - employee_name: l.employee_name, + employee_name, + employee, gross_pay: parse_amount(&l.gross_pay)?, federal_withholding: parse_amount(&l.federal_withholding)?, state_withholding: parse_amount(&l.state_withholding)?, @@ -4388,7 +5081,12 @@ impl BooksServer { )); // 1099 worksheet for the calendar year of as_of. let year = chrono::Datelike::year(&as_of); - match ledger_core::vendor_1099_report(ledger, year, None) { + match ledger_core::vendor_1099_report_with( + ledger, + year, + None, + &payment_accounts(ledger, &self.settings().roles), + ) { Ok(r1099) => { let mut w = String::from("vendor,form,w9_on_file,paid,reportable\n"); for v in &r1099.vendors { diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 891e982..ade59b1 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -315,6 +315,8 @@ pub struct VendorOut { pub form_1099: Option, /// W-9 on file. pub tin_on_file: bool, + /// The party the vendor record belongs to. + pub party_id: Option, } /// Result of `books.list_vendors`. @@ -432,10 +434,12 @@ pub struct ShowParams { pub attachment_id: Option, /// A fixed asset, by tag or id. pub asset: Option, + /// A party, by id or name (as `books.party`). + pub party: Option, /// Search the transactions list (description, account, vendor, amount). pub search: Option, /// A page: overview, cash, profit, transactions, reports, accounts, - /// assets, documents, company, journal. + /// assets, parties, documents, company, journal. pub page: Option, /// For account and report: first date, YYYY-MM-DD. pub start: Option, @@ -938,6 +942,10 @@ pub struct DraftEntryParams { pub description: String, /// The vendor this entry pays (id or name), for 1099 tracking. pub vendor: Option, + /// The party this entry is with (id or name; see `books.parties`). A + /// party with a vendor record also tags the entry's vendor; given with + /// `vendor`, both must name the same vendor. + pub party: Option, } /// Result of `books.draft_entry`. @@ -1298,6 +1306,10 @@ pub struct GetEntryOut { pub receipts: Vec, /// The vendor the entry is tagged with, for 1099 tracking. pub vendor: Option, + /// The id of the party the entry is tagged with (its counterparty). + pub party_id: Option, + /// That party's label (display name, else name). + pub party: Option, } // --------------------------------------------------------------------------- @@ -2015,16 +2027,21 @@ pub struct ReconcileOut { /// Parameters for `books.add_customer` / `books.add_vendor`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct AddPartyParams { - /// Customer or vendor name. +pub struct AddRecordParams { + /// Customer or vendor name. The record links to the party of that name + /// (ignoring case and surrounding space, not archived) that has no such + /// record yet, else to a new party. pub name: String, } /// Output of `books.add_customer` / `books.add_vendor`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct AddPartyOut { - /// The new party's id. +pub struct AddRecordOut { + /// The new customer's or vendor's id (for `books.create_invoice`, + /// `books.create_bill`, `books.set_vendor_1099`). pub id: u64, + /// The party the record belongs to (`books.party`). + pub party_id: u64, } /// One line on an invoice or bill. @@ -2146,8 +2163,13 @@ pub struct SetFxRateOut { /// One employee's payroll numbers. All amounts are decimal strings. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct PayrollLineIn { - /// Employee name. - pub employee_name: String, + /// The employee's party (id or name), which must have the employee + /// role. New runs should name it; without it the line carries only + /// `employee_name`. + pub employee: Option, + /// Employee name for the run record. Defaults to the employee party's + /// name; required without `employee`. + pub employee_name: Option, /// Gross pay, decimal string. pub gross_pay: String, /// Federal withholding, decimal string. @@ -2325,3 +2347,369 @@ pub struct ExportCpaPackageOut { /// The zip, base64-encoded. pub content_base64: String, } + +// --------------------------------------------------------------------------- +// Parties (the directory of everyone the company deals with) +// --------------------------------------------------------------------------- +// +// A `party` reference is a party's id or its name: the name ignoring case +// and surrounding space, among parties not archived (an archived party goes +// by id). A number that is one party's id and another party's name is +// refused, never guessed. + +/// Parameters for `books.add_party`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct AddPartyParams { + /// Legal or trade name, as on its documents. Unique (ignoring case and + /// surrounding space) among parties not archived; never a full TIN. + pub name: String, + /// Roles: contractor, employee, member, payout_platform, tax_agency, + /// financial_institution, landlord. Not vendor or customer: those come + /// with their records (`books.add_vendor`, `books.add_customer`, which + /// link to the party of the same name). May be empty. + #[serde(default)] + pub roles: Vec, +} + +/// Parameters for `books.update_party`. Give at least one field to change. +/// Every field given is checked before any is written: a bad one changes +/// nothing. Each is journaled as its own `party.updated`, in this order: +/// name, display_name, website, address, notes, archived. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct UpdatePartyParams { + /// The party: id or name. + pub party: String, + /// A new legal or trade name (not blank, not another party's). + pub name: Option, + /// A shorter name to show; "" clears it. + pub display_name: Option, + /// Website; "" clears it. + pub website: Option, + /// Business address; "" clears it. + pub address: Option, + /// Notes; "" clears them. + pub notes: Option, + /// True: no longer dealt with (its history stays; it no longer + /// resolves by name, and its name is free for another party). False: + /// dealt with again (refused while another party has its name). + pub archived: Option, +} + +/// Parameters for `books.set_party_roles`. Give at least one role to add +/// or remove. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SetPartyRolesParams { + /// The party: id or name. + pub party: String, + /// Roles to give it (see `books.add_party`; not vendor or customer). + #[serde(default)] + pub add: Vec, + /// Roles to take away. Refused while facts need the role: clear the + /// W-9 (`books.set_w9` clear) before removing its last 1099 role + /// (contractor, landlord), and the W-4 before removing employee. + #[serde(default)] + pub remove: Vec, +} + +/// Parameters for `books.set_w9`: what a W-9 said, never the number +/// itself. Either `clear: true` alone, or every required fact. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SetW9Params { + /// The party: id or name. Needs a 1099 role (contractor or landlord). + pub party: String, + /// When the W-9 was received, YYYY-MM-DD. Required unless clearing. + pub received_on: Option, + /// Line 3 federal tax classification, e.g. "individual/sole + /// proprietor", "C corporation", "LLC (partnership)". Required unless + /// clearing. + pub entity_type: Option, + /// Line 4 exempt payee code, if any. + pub exempt_payee_code: Option, + /// Line 4 FATCA exemption code, if any. + pub fatca_code: Option, + /// The TIN's last four digits: exactly four digits. A full TIN (nine + /// digits, however written) is refused here and in every party field. + /// Required unless clearing. + pub tin_last4: Option, + /// Where the form itself is kept, e.g. "CPA portal" or "filed in the + /// books, document 12". Required unless clearing. + pub kept_at: Option, + /// True: clear the W-9 facts (give no other fact). + #[serde(default)] + pub clear: bool, +} + +/// Parameters for `books.set_w4`: what a W-4 said, never the SSN. Either +/// `clear: true` alone, or every fact. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SetW4Params { + /// The party: id or name. Needs the employee role. + pub party: String, + /// When the W-4 was received, YYYY-MM-DD. Required unless clearing. + pub received_on: Option, + /// The work state: two capital letters, e.g. "UT". Required unless + /// clearing. + pub state: Option, + /// Where the form itself is kept. Required unless clearing. + pub kept_at: Option, + /// True: clear the W-4 facts (give no other fact). + #[serde(default)] + pub clear: bool, +} + +/// Parameters for `books.parties`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct ListPartiesParams { + /// Only parties with this role (any role, vendor and customer + /// included). + pub role: Option, + /// Include archived parties (default false). + #[serde(default)] + pub include_archived: bool, + /// Calendar year for `paid` and `received` (default: this year, so + /// year to date). + pub year: Option, +} + +/// Parameters for `books.party`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct PartyParams { + /// The party: id or name. + pub party: String, + /// Calendar year for the totals (default: this year, so year to date). + pub year: Option, +} + +/// Parameters for `books.set_entry_party`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SetEntryPartyParams { + /// Entry id (draft or posted). + pub entry_id: u64, + /// The party (id or name); null or omitted clears the tag. A party with + /// a vendor record also tags the entry's vendor (so the payment counts + /// in `books.vendor_1099`); one without, or clearing, clears the vendor + /// tag too. + pub party: Option, +} + +/// A party's W-9 facts. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct W9Out { + /// When it was received, YYYY-MM-DD. + pub received_on: String, + /// Line 3 federal tax classification. + pub entity_type: String, + /// Line 4 exempt payee code, if any. + pub exempt_payee_code: Option, + /// Line 4 FATCA exemption code, if any. + pub fatca_code: Option, + /// The TIN's last four digits. + pub tin_last4: String, + /// Where the form is kept. + pub kept_at: String, +} + +/// A party's W-4 facts. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct W4Out { + /// When it was received, YYYY-MM-DD. + pub received_on: String, + /// The work state. + pub state: String, + /// Where the form is kept. + pub kept_at: String, +} + +/// One party: its facts, as every party tool that changes one returns it. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartyOut { + /// Party id. + pub party_id: u64, + /// Legal or trade name. + pub name: String, + /// The name to show: the display name, else the name. + pub label: String, + /// Display name, if set. + pub display_name: Option, + /// Roles, in display order: the ones set by hand, plus vendor and + /// customer when it has those records. + pub roles: Vec, + /// Website. + pub website: Option, + /// Business address. + pub address: Option, + /// Notes. + pub notes: Option, + /// No longer dealt with. + pub archived: bool, + /// Its vendor record's id, if it bills the company. + pub vendor_id: Option, + /// Its customer record's id, if the company invoices it. + pub customer_id: Option, + /// W-9 facts, if on file. + pub w9: Option, + /// W-4 facts, if on file. + pub w4: Option, +} + +/// One party in `books.parties`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartySummaryOut { + /// Party id. + pub party_id: u64, + /// Legal or trade name. + pub name: String, + /// The name to show. + pub label: String, + /// Roles, vendor and customer included. + pub roles: Vec, + /// Its vendor record's id, if any. + pub vendor_id: Option, + /// Its customer record's id, if any. + pub customer_id: Option, + /// W-9 facts are on file. + pub w9_on_file: bool, + /// The W-9's TIN last four digits, when on file. + pub tin_last4: Option, + /// W-4 facts are on file. + pub w4_on_file: bool, + /// No longer dealt with. + pub archived: bool, + /// Paid to the party in the year, decimal string (as `books.party` + /// totals). + pub paid: String, + /// Received from the party in the year, decimal string (as + /// `books.party` totals). + pub received: String, +} + +/// Result of `books.parties`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct ListPartiesOut { + /// The year `paid` and `received` cover. + pub year: i32, + /// Parties, by id. + pub parties: Vec, +} + +/// A party's year, from the journal. "The party's entries" are the +/// entries tagged with the party (`books.set_entry_party`, `books.draft_entry` +/// party), the entries tagged with its vendor record (bills and their +/// payments, `books.set_entry_vendor`), its customer record's invoices and +/// the payments received on them, and reversals of any of these. Only +/// posted entries dated in the calendar year count. +/// +/// Money lines are the lines on the money accounts: the `[books] +/// cash_accounts` (every asset account but receivables when none are set) +/// and every liability account except the payables and payroll +/// withholding accounts (the control roles in `books.roles`: 2000 and +/// 2110-2150 by default). A liability counts because it is how the +/// company paid or was paid: a card, a member paying for it, a loan. Each +/// entry's net is its money lines' credits minus their debits: a positive +/// net was paid to the party, a negative one received from it; a reversal +/// counts against its original's side. An entry that only moves money +/// between money accounts (borrowing, a customer's deposit booked to a +/// liability) nets to zero. A bill is not paid until its payment is. +/// (`ledger_core::parties::activity` computes it; the viewer shows the +/// same numbers.) +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartyTotalsOut { + /// Paid to the party, decimal string. + pub paid: String, + /// Received from the party, decimal string. + pub received: String, + /// Its customer record's invoices dated in the year, total, decimal + /// string. + pub invoiced: String, + /// Its vendor record's bills dated in the year, total, decimal string. + pub billed: String, + /// Still owed to it now: its open bills' balances (every year), + /// decimal string. + pub owed: String, + /// Still owed by it now: its open invoices' balances (every year), + /// decimal string. + pub receivable: String, + /// For a party with a vendor record: the cash `books.vendor_1099` + /// counts as paid to that vendor in the year (asset accounts only, so + /// card payments drop out), decimal string. Absent otherwise. + pub amount_1099: Option, + /// The information return its vendor record gets ("1099-NEC", + /// "1099-MISC"), if any. + pub form_1099: Option, + /// Gross pay on payroll lines naming it, decimal string. + pub gross_pay: String, + /// Net pay on payroll lines naming it, decimal string. + pub net_pay: String, +} + +/// One of a party's entries. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartyEntryOut { + /// Entry id. + pub entry_id: u64, + /// Entry date, YYYY-MM-DD. + pub date: String, + /// Description. + pub description: String, + /// "draft" or "posted". + pub status: String, + /// Total debits, decimal string. + pub total: String, + /// Id of the reversed entry, for reversals. + pub reversal_of: Option, +} + +/// One of a party's invoices or bills. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartyDocumentOut { + /// Invoice or bill id. + pub id: u64, + /// Document date, YYYY-MM-DD. + pub date: String, + /// Due date, YYYY-MM-DD. + pub due_date: String, + /// Total, decimal string. + pub total: String, + /// Paid so far, decimal string. + pub paid: String, + /// Still owed, decimal string. + pub balance: String, + /// "open", "partial", or "paid". + pub status: String, + /// The posted entry that raised it. + pub entry_id: u64, +} + +/// A payroll run line naming the party. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartyPayrollOut { + /// Payroll run id. + pub run_id: u64, + /// Pay date, YYYY-MM-DD. + pub date: String, + /// The run's posted entry. + pub entry_id: u64, + /// Gross pay, decimal string. + pub gross_pay: String, + /// Net pay, decimal string. + pub net_pay: String, +} + +/// Result of `books.party`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartyDetailOut { + /// Its facts and roles. + pub party: PartyOut, + /// The calendar year the totals cover. + pub year: i32, + /// The year's totals. + pub totals: PartyTotalsOut, + /// Its entries (every year, drafts included), newest first. + pub entries: Vec, + /// Its customer record's invoices, by id. + pub invoices: Vec, + /// Its vendor record's bills, by id. + pub bills: Vec, + /// Payroll lines naming it, by run. + pub payroll: Vec, +} diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index 2618276..b38fedf 100644 --- a/crates/ledger-mcp/tests/mcp_stdio.rs +++ b/crates/ledger-mcp/tests/mcp_stdio.rs @@ -147,7 +147,7 @@ fn mcp_end_to_end_over_stdio() { // Tool listing: the narrow tools; chart is read-only. let list = mcp.request("tools/list", json!({})); let tools = list["result"]["tools"].as_array().unwrap(); - assert_eq!(tools.len(), 74, "expected 74 tools, got {}", tools.len()); + assert_eq!(tools.len(), 82, "expected 82 tools, got {}", tools.len()); let chart = tools.iter().find(|t| t["name"] == "books.chart").unwrap(); assert_eq!(chart["annotations"]["readOnlyHint"], true); assert!( diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 32d8ec0..4dfa327 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -51,11 +51,13 @@ const MONEY_KEYS: &[&str] = &[ "adjustments", "after", "amount", + "amount_1099", "assets", "balance", "balance_after", "balance_before", "before", + "billed", "book_balance", "book_value", "cash_beginning", @@ -84,12 +86,18 @@ const MONEY_KEYS: &[&str] = &[ "final_depreciation", "gain_loss", "gross", + "gross_pay", + "invoiced", "liabilities", "movement", "net_change_in_cash", "net_income", + "net_pay", + "owed", "paid", "proceeds", + "received", + "receivable", "remaining", "revenue", "salvage_value", @@ -588,6 +596,7 @@ fn happy_path(name: &str) -> Mcp { m.ok("books.list_recurring", json!({})); m.ok("books.list_schedules", json!({})); m.ok("books.list_vendors", json!({})); + m.ok("books.parties", json!({})); m.ok("books.digest", json!({"since": "2026-01-01"})); // Chart. @@ -645,6 +654,28 @@ fn happy_path(name: &str) -> Mcp { json!({"vendor": "Landlord", "form_1099": "misc", "tin_on_file": true}), ); m.ok("books.list_vendors", json!({})); + m.ok( + "books.add_party", + json!({"name": "Pat", "roles": ["employee"]}), + ); + m.ok( + "books.update_party", + json!({"party": "Pat", "display_name": "Pat D.", "notes": "first hire"}), + ); + m.ok( + "books.set_w4", + json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}), + ); + m.ok( + "books.set_party_roles", + json!({"party": "Landlord", "add": ["landlord"]}), + ); + m.ok( + "books.set_w9", + json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)", + "tin_last4": "4321", "kept_at": "CPA portal"}), + ); + m.ok("books.parties", json!({"role": "landlord", "year": 2026})); // Owner contribution: draft, lines, receipt, preview, post. let e1 = m.draft( @@ -693,6 +724,10 @@ fn happy_path(name: &str) -> Mcp { "books.set_entry_vendor", json!({"entry_id": e2, "vendor": vendor.to_string()}), ); + m.ok( + "books.set_entry_party", + json!({"entry_id": e2, "party": "Landlord"}), + ); // Replace the receipt: attach new, unlink old, retire it. m.attach(Some(e2), b"rent receipt v2"); m.ok( @@ -853,7 +888,7 @@ fn happy_path(name: &str) -> Mcp { m.ok( "books.run_payroll", json!({"date": "2026-02-15", "cash_account": "1000", "lines": [{ - "employee_name": "Pat", "gross_pay": "1000.00", "federal_withholding": "100.00", + "employee": "Pat", "gross_pay": "1000.00", "federal_withholding": "100.00", "state_withholding": "50.00", "ss_employee": "62.00", "medicare_employee": "14.50", "ss_employer": "62.00", "medicare_employer": "14.50", "other_deductions": "0.00"}]}), ); @@ -908,7 +943,11 @@ fn happy_path(name: &str) -> Mcp { m.ok("books.digest", json!({"since": "2026-01-01"})); m.ok("books.search_entries", json!({"text": "rent", "limit": 10})); m.ok("books.vendor_1099", json!({"year": 2026})); + m.ok("books.party", json!({"party": "Landlord", "year": 2026})); + m.ok("books.party", json!({"party": "Pat", "year": 2026})); + m.ok("books.party", json!({"party": "Client"})); m.ok("books.verify", json!({})); + m.ok("books.show", json!({"party": "Landlord"})); m.ok("books.show", json!({"entry_id": e1})); m.ok( "books.show", diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-mcp/tests/spec_parties.rs new file mode 100644 index 0000000..5a16eb9 --- /dev/null +++ b/crates/ledger-mcp/tests/spec_parties.rs @@ -0,0 +1,1149 @@ +//! Spec-blind tests for the party directory tools of `ledger-mcp`, driven +//! over stdio against the real binary. +//! +//! Expected behavior comes only from the tool descriptions and parameter / +//! result doc comments in `src/server.rs` and `src/types.rs` (the declared +//! interface, as `tools/list` serves it) and `docs/mcp-interface.md` +//! ("Tools — parties"). The harness is the minimal subset of +//! `tests/spec_mcp.rs` (copied from `tests/spec_assets.rs`): every success +//! validates against the tool's `outputSchema`, and every refusal leaves +//! `journal.jsonl` byte-identical (server invariant 2: a refused call +//! changes nothing). + +use serde_json::{Value, json}; +use std::collections::HashMap; +use std::io::{BufRead, BufReader, Write}; +use std::path::{Path, PathBuf}; +use std::process::{Child, ChildStdin, Command, Stdio}; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::mpsc::{Receiver, RecvTimeoutError, channel}; +use std::time::Duration; + +const TIMEOUT: Duration = Duration::from_secs(180); + +// ---------------------------------------------------------------- harness + +#[derive(Debug, Clone)] +enum Out { + Ok(Value), + ToolErr(String), + RpcErr(Value), +} + +impl Out { + fn refused(&self) -> bool { + !matches!(self, Out::Ok(_)) + } + fn err_text(&self) -> String { + match self { + Out::Ok(v) => panic!("expected a refusal, got {v}"), + Out::ToolErr(t) => t.clone(), + Out::RpcErr(e) => e.to_string(), + } + } +} + +struct Mcp { + stdin: ChildStdin, + rx: Receiver, + child: Child, + next_id: u64, + data: PathBuf, + validators: HashMap, +} + +impl Drop for Mcp { + fn drop(&mut self) { + let _ = self.child.kill(); + let _ = self.child.wait(); + } +} + +static DIR_SEQ: AtomicU64 = AtomicU64::new(0); + +fn test_dir(name: &str) -> PathBuf { + let n = DIR_SEQ.fetch_add(1, Ordering::Relaxed); + let dir = std::env::temp_dir().join(format!( + "ledger-mcp-spec-parties-{}-{name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// `wanderbooks.toml` with a company name and extra `[books]` TOML. The +/// receipt threshold is high so ordinary test entries need no receipt. +fn books_toml(dir: &Path, company: &str, books_extra: &str) -> PathBuf { + let path = dir.join("wanderbooks.toml"); + std::fs::write( + &path, + format!( + "[company]\nname = \"{company}\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n{books_extra}" + ), + ) + .unwrap(); + path +} + +impl Mcp { + fn start(dir: &Path, toml: &Path) -> Self { + let run = dir.join("run"); + std::fs::create_dir_all(&run).unwrap(); + let mut child = Command::new(env!("CARGO_BIN_EXE_ledger-mcp")) + .arg("--data-dir") + .arg(dir.join("books-data")) + .arg("--books-toml") + .arg(toml) + .env("XDG_RUNTIME_DIR", &run) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .spawn() + .expect("spawn ledger-mcp"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + let (tx, rx) = channel(); + std::thread::spawn(move || { + for line in stdout.lines() { + let Ok(line) = line else { break }; + if tx.send(line).is_err() { + break; + } + } + }); + let mut mcp = Self { + stdin, + rx, + child, + next_id: 1, + data: dir.join("books-data"), + validators: HashMap::new(), + }; + let init = mcp.rpc( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "spec-parties", "version": "0.1.0"}, + }), + ); + assert!(init.get("result").is_some(), "initialize failed: {init}"); + mcp.send_line( + &json!({"jsonrpc": "2.0", "method": "notifications/initialized"}).to_string(), + ); + let list = mcp.rpc("tools/list", json!({})); + for tool in list["result"]["tools"].as_array().expect("tools") { + let name = tool["name"].as_str().unwrap().to_string(); + let validator = jsonschema::validator_for(&tool["outputSchema"]) + .unwrap_or_else(|e| panic!("{name}'s outputSchema is not valid: {e}")); + mcp.validators.insert(name, validator); + } + mcp + } + + fn journal(&self) -> Vec { + std::fs::read(self.data.join("journal.jsonl")).unwrap_or_default() + } + + /// The journal's line types, in order. + fn journal_types(&self) -> Vec { + String::from_utf8(self.journal()) + .unwrap() + .lines() + .filter(|l| !l.trim().is_empty()) + .map(|l| { + let v: Value = serde_json::from_str(l).unwrap(); + v["type"] + .as_str() + .or_else(|| v["line_type"].as_str()) + .unwrap_or_default() + .to_string() + }) + .collect() + } + + fn send_line(&mut self, line: &str) { + writeln!(self.stdin, "{line}").unwrap(); + self.stdin.flush().unwrap(); + } + + fn recv(&mut self) -> Option { + loop { + match self.rx.recv_timeout(TIMEOUT) { + Ok(line) => { + let v: Value = serde_json::from_str(&line) + .unwrap_or_else(|e| panic!("server wrote non-JSON {line:?}: {e}")); + if v.get("id").is_none() && v.get("method").is_some() { + continue; + } + return Some(v); + } + Err(RecvTimeoutError::Timeout | RecvTimeoutError::Disconnected) => return None, + } + } + } + + fn rpc(&mut self, method: &str, params: Value) -> Value { + let id = self.next_id; + self.next_id += 1; + let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params}); + self.send_line(&msg.to_string()); + let resp = self + .recv() + .unwrap_or_else(|| panic!("no response to {method} (id {id})")); + assert_eq!(resp["id"], json!(id), "response id mismatch: {resp}"); + resp + } + + /// Call a tool; a refusal must leave the journal unchanged, a success + /// must match the outputSchema. + fn tool(&mut self, name: &str, args: Value) -> Out { + let before = self.journal(); + let resp = self.rpc("tools/call", json!({"name": name, "arguments": args})); + let out = if let Some(err) = resp.get("error") { + Out::RpcErr(err.clone()) + } else { + let result = &resp["result"]; + if result.get("isError") == Some(&json!(true)) { + Out::ToolErr( + result["content"] + .as_array() + .and_then(|c| c.iter().find_map(|b| b["text"].as_str())) + .unwrap_or_default() + .to_string(), + ) + } else { + let sc = result["structuredContent"].clone(); + let v = self + .validators + .get(name) + .unwrap_or_else(|| panic!("{name} is not in tools/list")); + let errors: Vec = v.iter_errors(&sc).map(|e| e.to_string()).collect(); + assert!( + errors.is_empty(), + "{name} violates its outputSchema: {errors:?}\n{sc}" + ); + Out::Ok(sc) + } + }; + if out.refused() { + assert!( + self.journal() == before, + "refused {name} {args} changed the journal ({out:?})" + ); + } + out + } + + fn ok(&mut self, name: &str, args: Value) -> Value { + let a = args.to_string(); + match self.tool(name, args) { + Out::Ok(v) => v, + other => panic!("{name} {a}: expected success, got {other:?}"), + } + } + + /// `tool`, expecting a refusal that says why. + fn refuse(&mut self, name: &str, args: Value) -> String { + let a = args.to_string(); + let out = self.tool(name, args); + assert!(out.refused(), "{name} {a} should be refused, got {out:?}"); + let why = out.err_text(); + assert!(!why.trim().is_empty(), "{name} {a}: a refusal says why"); + why + } + + /// `ok`, and the journal must not change (read-only tools). + fn read_only(&mut self, name: &str, args: Value) -> Value { + let before = self.journal(); + let v = self.ok(name, args); + assert!( + self.journal() == before, + "read-only {name} changed the journal" + ); + v + } + + fn accounts(&mut self, accts: &[(&str, &str, &str)]) { + for (code, name, ty) in accts { + self.ok( + "books.add_account", + json!({"code": code, "name": name, "account_type": ty}), + ); + } + } + + /// Draft an entry with `args` extra (party, vendor) and lines; the id. + fn draft(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 { + let mut args = json!({"date": date, "description": format!("entry {date}")}); + for (k, v) in extra.as_object().unwrap() { + args[k] = v.clone(); + } + let id = self.ok("books.draft_entry", args)["entry_id"] + .as_u64() + .unwrap(); + for (account, side, amount) in lines { + self.ok( + "books.add_line", + json!({"entry_id": id, "account": account, "side": side, "amount": amount}), + ); + } + id + } + + /// `draft`, then post it (high receipt threshold: no receipt needed). + fn post(&mut self, date: &str, extra: Value, lines: &[(&str, &str, &str)]) -> u64 { + let id = self.draft(date, extra, lines); + self.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + id + } + + fn add_party(&mut self, name: &str, roles: &[&str]) -> u64 { + self.ok("books.add_party", json!({"name": name, "roles": roles}))["party_id"] + .as_u64() + .unwrap() + } + + fn party(&mut self, party: &str, year: i32) -> Value { + self.read_only("books.party", json!({"party": party, "year": year})) + } + + /// How many journal lines have this type. + fn count(&self, line_type: &str) -> usize { + self.journal_types() + .iter() + .filter(|t| t.as_str() == line_type) + .count() + } +} + +const CHART: &[(&str, &str, &str)] = &[ + ("1000", "Bank", "asset"), + ("1300", "Prepaid Expenses", "asset"), + ("2050", "Card", "liability"), + ("2100", "Due to Members", "liability"), + ("3000", "Capital", "equity"), + ("4000", "Revenue", "revenue"), + ("6000", "Contract Labor", "expense"), + ("6999", "General Expenses", "expense"), +]; + +/// Books with the chart; cash is 1000. +fn books(name: &str) -> Mcp { + let dir = test_dir(name); + let toml = books_toml(&dir, "Spec Co", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m +} + +const W9: &str = r#"{"received_on": "2026-01-05", "entity_type": "individual/sole proprietor", + "tin_last4": "6789", "kept_at": "CPA portal"}"#; + +fn w9(party: &str) -> Value { + let mut v: Value = serde_json::from_str(W9).unwrap(); + v["party"] = json!(party); + v +} + +fn roles(v: &Value) -> Vec { + v["roles"] + .as_array() + .unwrap() + .iter() + .map(|r| r.as_str().unwrap().to_string()) + .collect() +} + +/// Pat's payroll line, naming the employee by `employee` (and no name). +fn pat_line(employee: &str) -> Value { + json!({"employee": employee, "gross_pay": "1000.00", "federal_withholding": "100.00", + "state_withholding": "50.00", "ss_employee": "62.00", "medicare_employee": "14.50", + "ss_employer": "62.00", "medicare_employer": "14.50", "other_deductions": "0.00"}) +} + +// ------------------------------------------------- books.add_party + +#[test] +fn add_party_returns_the_party_and_journals_party_added() { + let mut m = books("add"); + let p = m.ok( + "books.add_party", + json!({"name": " Acme Design ", "roles": ["contractor", "landlord", "contractor"]}), + ); + assert_eq!(p["name"], "Acme Design", "the name is trimmed"); + assert_eq!(p["label"], "Acme Design"); + assert_eq!(roles(&p), ["contractor", "landlord"]); + assert_eq!(p["archived"], false); + assert!(p["w9"].is_null() && p["w4"].is_null()); + assert!(p["vendor_id"].is_null() && p["customer_id"].is_null()); + assert_eq!(m.count("party.added"), 1); + // No roles is fine. + let q = m.ok("books.add_party", json!({"name": "IRS"})); + assert!(roles(&q).is_empty()); +} + +#[test] +fn add_party_refusals_change_nothing() { + let mut m = books("add-refusals"); + m.add_party("Acme Design", &[]); + for args in [ + json!({"name": "acme design "}), // duplicate, case and space + json!({"name": " "}), // blank + json!({"name": "Bob 123-45-6789"}), // a full SSN + json!({"name": "Bob 123456789"}), // a full TIN, no separators + json!({"name": "Shop", "roles": ["vendor"]}), // vendor by hand + json!({"name": "Shop", "roles": ["customer"]}), // customer by hand + json!({"name": "Shop", "roles": ["wizard"]}), // unknown role + json!({"name": "x".repeat(501)}), // too long + ] { + m.refuse("books.add_party", args); + } + assert_eq!(m.count("party.added"), 1); +} + +// ------------------------------------------------- resolution + +#[test] +fn a_party_resolves_by_id_or_by_name_and_refuses_ambiguity() { + let mut m = books("resolve"); + let acme = m.add_party("Acme Design", &[]); + let by_name = m.party(" ACME design ", 2026); + assert_eq!(by_name["party"]["party_id"], json!(acme)); + let by_id = m.party(&acme.to_string(), 2026); + assert_eq!(by_id["party"]["party_id"], json!(acme)); + // A party named like another's id: the number is ambiguous. + let named_1 = m.add_party(&acme.to_string(), &[]); + assert_ne!(named_1, acme); + let why = m.refuse("books.party", json!({"party": acme.to_string()})); + assert!(why.contains("ambiguous"), "{why}"); + // The other party's id is unambiguous. + let p = m.party(&named_1.to_string(), 2026); + assert_eq!(p["party"]["party_id"], json!(named_1)); + m.refuse("books.party", json!({"party": "Nobody"})); + m.refuse("books.party", json!({"party": "999"})); +} + +#[test] +fn an_archived_party_goes_by_id_and_frees_its_name() { + let mut m = books("archive"); + let old = m.add_party("Acme Design", &[]); + let p = m.ok( + "books.update_party", + json!({"party": "Acme Design", "archived": true}), + ); + assert_eq!(p["archived"], true); + m.refuse("books.party", json!({"party": "Acme Design"})); + m.party(&old.to_string(), 2026); + // The name is free for a new party. + let new = m.add_party("Acme Design", &[]); + assert_ne!(new, old); + assert_eq!( + m.party("acme design", 2026)["party"]["party_id"], + json!(new) + ); + // Un-archiving the old one is refused while the name is taken. + m.refuse( + "books.update_party", + json!({"party": old.to_string(), "archived": false}), + ); + // The list hides archived parties unless asked. + let listed = m.read_only("books.parties", json!({"year": 2026})); + let ids: Vec<&Value> = listed["parties"] + .as_array() + .unwrap() + .iter() + .map(|p| &p["party_id"]) + .collect(); + assert_eq!(ids, [&json!(new)]); + let all = m.read_only( + "books.parties", + json!({"year": 2026, "include_archived": true}), + ); + assert_eq!(all["parties"].as_array().unwrap().len(), 2); +} + +// ------------------------------------------------- books.update_party + +#[test] +fn update_party_journals_each_field_and_empty_clears() { + let mut m = books("update"); + m.add_party("Acme Design", &[]); + let p = m.ok( + "books.update_party", + json!({"party": "Acme Design", "name": "Acme Design LLC", "display_name": "Acme", + "website": "https://acme.example", "address": "1 Main St, Draper, UT", + "notes": "logo work"}), + ); + assert_eq!(m.count("party.updated"), 5, "one event per field"); + assert_eq!(p["name"], "Acme Design LLC"); + assert_eq!(p["label"], "Acme", "the display name is the label"); + assert_eq!(p["website"], "https://acme.example"); + assert_eq!(p["address"], "1 Main St, Draper, UT"); + assert_eq!(p["notes"], "logo work"); + // "" clears an optional field. + let p = m.ok( + "books.update_party", + json!({"party": "Acme Design LLC", "display_name": "", "notes": ""}), + ); + assert!(p["display_name"].is_null() && p["notes"].is_null()); + assert_eq!(p["label"], "Acme Design LLC"); + assert_eq!(m.count("party.updated"), 7); +} + +#[test] +fn update_party_refusals_change_nothing() { + let mut m = books("update-refusals"); + m.add_party("Acme Design", &[]); + m.add_party("Other", &[]); + // A bad field refuses the whole call, the good field included. + m.refuse( + "books.update_party", + json!({"party": "Acme Design", "website": "https://acme.example", + "notes": "EIN 12-3456789"}), + ); + m.refuse( + "books.update_party", + json!({"party": "Acme Design", "website": "https://acme.example", "name": "other"}), + ); + m.refuse( + "books.update_party", + json!({"party": "Acme Design", "website": "https://acme.example", "name": " "}), + ); + m.refuse( + "books.update_party", + json!({"party": "Acme Design", "address": "a".repeat(501)}), + ); + m.refuse("books.update_party", json!({"party": "Acme Design"})); + m.refuse( + "books.update_party", + json!({"party": "Nobody", "notes": "x"}), + ); + let p = m.party("Acme Design", 2026); + assert!(p["party"]["website"].is_null(), "nothing was written"); + assert_eq!(m.count("party.updated"), 0); +} + +// ------------------------------------------------- books.set_party_roles + +#[test] +fn set_party_roles_adds_then_removes_and_skips_no_ops() { + let mut m = books("roles"); + m.add_party("Pat", &["contractor"]); + let p = m.ok( + "books.set_party_roles", + json!({"party": "Pat", "add": ["employee", "contractor"], "remove": ["member"]}), + ); + assert_eq!(roles(&p), ["contractor", "employee"]); + assert_eq!(m.count("party.role_set"), 1, "only the real change"); + // W-9 on file: swapping one 1099 role for the other works (adds first). + m.ok("books.set_w9", w9("Pat")); + let p = m.ok( + "books.set_party_roles", + json!({"party": "Pat", "add": ["landlord"], "remove": ["contractor"]}), + ); + assert_eq!(roles(&p), ["employee", "landlord"]); +} + +#[test] +fn set_party_roles_refusals_change_nothing() { + let mut m = books("roles-refusals"); + m.add_party("Pat", &["contractor", "employee"]); + m.ok("books.set_w9", w9("Pat")); + m.ok( + "books.set_w4", + json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", "kept_at": "binder"}), + ); + let before = m.count("party.role_set"); + for args in [ + json!({"party": "Pat", "add": ["vendor"]}), + json!({"party": "Pat", "remove": ["customer"]}), + json!({"party": "Pat", "add": ["wizard"]}), + json!({"party": "Pat"}), + json!({"party": "Pat", "add": ["member"], "remove": ["member"]}), + // The last 1099 role, with W-9 facts on file (the add goes too). + json!({"party": "Pat", "add": ["member"], "remove": ["contractor"]}), + // Employee, with W-4 facts on file. + json!({"party": "Pat", "remove": ["employee"]}), + json!({"party": "Nobody", "add": ["member"]}), + ] { + m.refuse("books.set_party_roles", args); + } + assert_eq!(m.count("party.role_set"), before); + // Clearing the facts first makes the removals work. + m.ok("books.set_w9", json!({"party": "Pat", "clear": true})); + m.ok("books.set_w4", json!({"party": "Pat", "clear": true})); + let p = m.ok( + "books.set_party_roles", + json!({"party": "Pat", "remove": ["contractor", "employee"]}), + ); + assert!(roles(&p).is_empty()); +} + +// ------------------------------------------------- books.set_w9 / books.set_w4 + +#[test] +fn set_w9_records_the_facts_and_marks_the_vendor() { + let mut m = books("w9"); + m.add_party("Acme Design", &["contractor"]); + let v = m.ok("books.add_vendor", json!({"name": "Acme Design"})); + let mut args = w9("Acme Design"); + args["exempt_payee_code"] = json!("5"); + args["fatca_code"] = json!(""); + let p = m.ok("books.set_w9", args); + let f = &p["w9"]; + assert_eq!(f["received_on"], "2026-01-05"); + assert_eq!(f["entity_type"], "individual/sole proprietor"); + assert_eq!(f["exempt_payee_code"], "5"); + assert!(f["fatca_code"].is_null(), "an empty code is none"); + assert_eq!(f["tin_last4"], "6789"); + assert_eq!(f["kept_at"], "CPA portal"); + assert_eq!(m.count("party.w9_set"), 1); + // The vendor record's W-9 flag follows. + let vendors = m.read_only("books.list_vendors", json!({})); + let vendor = &vendors["vendors"][0]; + assert_eq!(vendor["id"], v["id"]); + assert_eq!(vendor["tin_on_file"], true); + assert_eq!(vendor["party_id"], p["party_id"]); + let listed = m.read_only("books.parties", json!({"role": "contractor", "year": 2026})); + assert_eq!(listed["parties"][0]["w9_on_file"], true); + assert_eq!(listed["parties"][0]["tin_last4"], "6789"); + // Clearing clears both. + let p = m.ok( + "books.set_w9", + json!({"party": "Acme Design", "clear": true}), + ); + assert!(p["w9"].is_null()); + let vendors = m.read_only("books.list_vendors", json!({})); + assert_eq!(vendors["vendors"][0]["tin_on_file"], false); +} + +#[test] +fn set_w9_refusals_change_nothing() { + let mut m = books("w9-refusals"); + m.add_party("Acme Design", &["contractor"]); + m.add_party("Insurer", &[]); + m.refuse("books.set_w9", w9("Insurer")); // no 1099 role + for (field, value) in [ + ("tin_last4", "123456789"), + ("tin_last4", "12-3456789"), + ("tin_last4", "123"), + ("tin_last4", "12a4"), + ("entity_type", "individual 123 45 6789"), + ("kept_at", "SSN 123-45-6789"), + ("exempt_payee_code", "123456789"), + ("fatca_code", "987-65-4321"), + ("entity_type", " "), + ("kept_at", ""), + ("received_on", "2026-13-01"), + ] { + let mut args = w9("Acme Design"); + args[field] = json!(value); + m.refuse("books.set_w9", args); + } + for missing in ["received_on", "entity_type", "tin_last4", "kept_at"] { + let mut args = w9("Acme Design"); + args.as_object_mut().unwrap().remove(missing); + m.refuse("books.set_w9", args); + } + let mut args = w9("Acme Design"); + args["clear"] = json!(true); + m.refuse("books.set_w9", args); + m.refuse("books.set_w9", w9("Nobody")); + assert_eq!(m.count("party.w9_set"), 0); +} + +#[test] +fn set_w4_records_and_clears_and_refuses_change_nothing() { + let mut m = books("w4"); + m.add_party("Pat", &["employee"]); + m.add_party("Acme Design", &["contractor"]); + let good = json!({"party": "Pat", "received_on": "2026-01-02", "state": "UT", + "kept_at": "payroll binder"}); + for (field, value) in [ + ("party", "Acme Design"), // not an employee + ("state", "ut"), + ("state", "Utah"), + ("kept_at", " "), + ("kept_at", "SSN 123 45 6789"), + ("received_on", "January"), + ] { + let mut args = good.clone(); + args[field] = json!(value); + m.refuse("books.set_w4", args); + } + let mut args = good.clone(); + args["clear"] = json!(true); + m.refuse("books.set_w4", args); + assert_eq!(m.count("party.w4_set"), 0); + let p = m.ok("books.set_w4", good); + assert_eq!(p["w4"]["state"], "UT"); + assert_eq!(p["w4"]["received_on"], "2026-01-02"); + assert_eq!(p["w4"]["kept_at"], "payroll binder"); + let listed = m.read_only("books.parties", json!({"role": "employee", "year": 2026})); + assert_eq!(listed["parties"][0]["w4_on_file"], true); + let p = m.ok("books.set_w4", json!({"party": "Pat", "clear": true})); + assert!(p["w4"].is_null()); + assert_eq!(m.count("party.w4_set"), 2); +} + +// ------------------------------------------------- vendor and customer records + +#[test] +fn add_vendor_and_add_customer_link_a_party() { + let mut m = books("records"); + let acme = m.add_party("Acme Design", &["contractor"]); + let v = m.ok("books.add_vendor", json!({"name": "ACME DESIGN"})); + assert_eq!(v["party_id"], json!(acme), "links the party of that name"); + let c = m.ok("books.add_customer", json!({"name": "acme design"})); + assert_eq!( + c["party_id"], + json!(acme), + "the same party, as a customer too" + ); + let p = m.party("Acme Design", 2026); + assert_eq!( + roles(&p["party"]), + ["vendor", "contractor", "customer"], + "vendor and customer follow the records" + ); + assert_eq!(p["party"]["vendor_id"], v["id"]); + assert_eq!(p["party"]["customer_id"], c["id"]); + // A new name makes a new party. + let w = m.ok("books.add_vendor", json!({"name": "Hosting Co"})); + assert_ne!(w["party_id"], json!(acme)); + let listed = m.read_only("books.parties", json!({"role": "vendor", "year": 2026})); + assert_eq!(listed["parties"].as_array().unwrap().len(), 2); + let vendors = m.read_only("books.list_vendors", json!({})); + for v in vendors["vendors"].as_array().unwrap() { + assert!(v["party_id"].is_u64(), "{v}"); + } +} + +// ------------------------------------------------- tagging entries + +#[test] +fn set_entry_party_tags_the_vendor_so_vendor_1099_counts_it() { + let mut m = books("tag-1099"); + m.add_party("Landlord LLC", &["landlord"]); + m.ok("books.add_vendor", json!({"name": "Landlord LLC"})); + m.ok( + "books.set_vendor_1099", + json!({"vendor": "Landlord LLC", "form_1099": "misc"}), + ); + let rent = m.post( + "2026-02-01", + json!({}), + &[("6999", "debit", "2500.00"), ("1000", "credit", "2500.00")], + ); + let paid = |m: &mut Mcp| { + m.read_only("books.vendor_1099", json!({"year": 2026}))["vendors"][0]["paid"].clone() + }; + assert_eq!(paid(&mut m), "0.00"); + let e = m.ok( + "books.set_entry_party", + json!({"entry_id": rent, "party": "landlord llc"}), + ); + assert_eq!(e["party"], "Landlord LLC"); + assert_eq!(e["vendor"], "Landlord LLC", "the vendor tag follows"); + assert!(e["party_id"].is_u64()); + assert_eq!(paid(&mut m), "2500.00"); + assert_eq!(m.count("entry.party_set"), 1); + // Clearing clears both tags. + let e = m.ok( + "books.set_entry_party", + json!({"entry_id": rent, "party": null}), + ); + assert!(e["party"].is_null() && e["vendor"].is_null()); + assert_eq!(paid(&mut m), "0.00"); + // A party without a vendor record clears a vendor tag. + m.add_party("IRS", &["tax_agency"]); + m.ok( + "books.set_entry_vendor", + json!({"entry_id": rent, "vendor": "Landlord LLC"}), + ); + let e = m.ok( + "books.set_entry_party", + json!({"entry_id": rent, "party": "IRS"}), + ); + assert_eq!(e["party"], "IRS"); + assert!(e["vendor"].is_null()); + // Refusals. + m.refuse( + "books.set_entry_party", + json!({"entry_id": 999, "party": "IRS"}), + ); + m.refuse( + "books.set_entry_party", + json!({"entry_id": rent, "party": "Nobody"}), + ); +} + +#[test] +fn draft_entry_takes_a_party_and_refuses_a_mismatched_vendor() { + let mut m = books("draft-party"); + m.add_party("Acme Design", &["contractor"]); + m.ok("books.add_vendor", json!({"name": "Acme Design"})); + m.ok("books.add_vendor", json!({"name": "Hosting Co"})); + let id = m.draft("2026-03-01", json!({"party": "Acme Design"}), &[]); + let e = m.read_only("books.get_entry", json!({"entry_id": id})); + assert_eq!(e["party"], "Acme Design"); + assert_eq!(e["vendor"], "Acme Design"); + // Both, agreeing. + let id = m.draft( + "2026-03-02", + json!({"party": "Acme Design", "vendor": "Acme Design"}), + &[], + ); + assert_eq!( + m.read_only("books.get_entry", json!({"entry_id": id}))["party"], + "Acme Design" + ); + // A vendor alone tags its party too. + let id = m.draft("2026-03-03", json!({"vendor": "Hosting Co"}), &[]); + assert_eq!( + m.read_only("books.get_entry", json!({"entry_id": id}))["party"], + "Hosting Co" + ); + // Refusals create no draft. + m.refuse( + "books.draft_entry", + json!({"date": "2026-03-04", "description": "x", "party": "Acme Design", "vendor": "Hosting Co"}), + ); + m.refuse( + "books.draft_entry", + json!({"date": "2026-03-04", "description": "x", "party": "Nobody"}), + ); +} + +// ------------------------------------------------- books.party totals + +/// A small year, computed by hand (see `PartyTotalsOut`): cash is 1000; +/// 2050 (card) and 2100 (members) are liabilities, so they count as money; +/// 2000 (payables) and 1200 (receivables) don't. +#[test] +fn party_totals_match_a_hand_computed_year() { + let mut m = books("totals"); + m.add_party("Acme Design", &["contractor"]); + let v = m.ok("books.add_vendor", json!({"name": "Acme Design"}))["id"] + .as_u64() + .unwrap(); + m.ok( + "books.set_vendor_1099", + json!({"vendor": "Acme Design", "form_1099": "nec"}), + ); + let acme = json!({"party": "Acme Design"}); + // Last year: not in 2026. + let e0 = m.post( + "2025-12-15", + acme.clone(), + &[("6000", "debit", "999.00"), ("1000", "credit", "999.00")], + ); + // Paid from the bank: paid 500, 1099 500. + let e1 = m.post( + "2026-02-01", + acme.clone(), + &[("6000", "debit", "500.00"), ("1000", "credit", "500.00")], + ); + // By card: paid 300, not in the 1099 (the card processor reports it). + let e2 = m.post( + "2026-03-01", + acme.clone(), + &[("6000", "debit", "300.00"), ("2050", "credit", "300.00")], + ); + // A refund: received 50; the 1099 nets it. + let e3 = m.post( + "2026-05-01", + acme.clone(), + &[("1000", "debit", "50.00"), ("6000", "credit", "50.00")], + ); + // Paid 100 and reversed: nets to nothing. + let e4 = m.post( + "2026-06-01", + acme.clone(), + &[("6000", "debit", "100.00"), ("1000", "credit", "100.00")], + ); + let e4r = m.ok( + "books.reverse_entry", + json!({"entry_id": e4, "date": "2026-06-02", "description": "undo", "confirmed": true}), + )["reversal_id"] + .as_u64() + .unwrap(); + // A bill of 800 (not paid until paid), 300 of it paid from the bank. + let bill = m.ok( + "books.create_bill", + json!({"party_id": v, "date": "2026-07-01", "due_date": "2026-07-31", + "lines": [{"description": "Logo", "amount": "800.00"}]}), + ); + let pay = m.ok( + "books.pay_bill", + json!({"document_id": bill["id"], "date": "2026-07-15", "amount": "300.00", + "cash_account": "1000"}), + )["entry_id"] + .as_u64() + .unwrap(); + // A draft: listed, never counted. + let d = m.draft( + "2026-08-01", + acme.clone(), + &[("6000", "debit", "70.00"), ("1000", "credit", "70.00")], + ); + // Someone else's entry: not Acme's. + m.post( + "2026-02-02", + json!({}), + &[("6000", "debit", "40.00"), ("1000", "credit", "40.00")], + ); + + let p = m.party("Acme Design", 2026); + let t = &p["totals"]; + assert_eq!(t["paid"], "1100.00", "500 + 300 + 100 - 100 + 300"); + assert_eq!(t["received"], "50.00"); + assert_eq!(t["billed"], "800.00"); + assert_eq!(t["owed"], "500.00"); + assert_eq!(t["invoiced"], "0.00"); + assert_eq!(t["receivable"], "0.00"); + assert_eq!(t["amount_1099"], "750.00", "500 - 50 + 100 - 100 + 300"); + assert_eq!(t["form_1099"], "1099-NEC"); + assert_eq!(t["gross_pay"], "0.00"); + // The 1099 amount is books.vendor_1099's. + let w = m.read_only("books.vendor_1099", json!({"year": 2026})); + assert_eq!(w["vendors"][0]["paid"], "750.00"); + // Every entry it is on, newest first, drafts included. + let ids: Vec = p["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["entry_id"].as_u64().unwrap()) + .collect(); + let bill_entry = bill["entry_id"].as_u64().unwrap(); + assert_eq!(ids, [d, pay, bill_entry, e4r, e4, e3, e2, e1, e0]); + let draft = &p["entries"][0]; + assert_eq!(draft["status"], "draft"); + assert_eq!(draft["total"], "70.00"); + assert_eq!(p["entries"][3]["reversal_of"], json!(e4)); + // Its bill. + let b = &p["bills"][0]; + assert_eq!(b["id"], bill["id"]); + assert_eq!(b["total"], "800.00"); + assert_eq!(b["paid"], "300.00"); + assert_eq!(b["balance"], "500.00"); + assert_eq!(b["status"], "partial"); + assert_eq!(b["due_date"], "2026-07-31"); + assert!(p["invoices"].as_array().unwrap().is_empty()); + // Last year, on its own. + let last = m.party("Acme Design", 2025); + assert_eq!(last["year"], 2025); + assert_eq!(last["totals"]["paid"], "999.00"); + assert_eq!(last["totals"]["billed"], "0.00"); + assert_eq!(last["totals"]["owed"], "500.00", "owed is now, every year"); + // The list carries the same paid and received. + let listed = m.read_only("books.parties", json!({"year": 2026})); + let row = &listed["parties"][0]; + assert_eq!(row["paid"], "1100.00"); + assert_eq!(row["received"], "50.00"); +} + +#[test] +fn party_totals_for_a_customer_a_member_and_a_prepaid() { + let mut m = books("totals-others"); + let c = m.ok("books.add_customer", json!({"name": "Client Co"}))["id"] + .as_u64() + .unwrap(); + let inv = m.ok( + "books.create_invoice", + json!({"party_id": c, "date": "2026-03-01", "due_date": "2026-03-31", + "lines": [{"description": "Work", "amount": "1000.00"}]}), + ); + let pay = m.ok( + "books.receive_payment", + json!({"document_id": inv["id"], "date": "2026-03-20", "amount": "600.00", + "cash_account": "1000"}), + )["entry_id"] + .as_u64() + .unwrap(); + let p = m.party("Client Co", 2026); + let t = &p["totals"]; + assert_eq!(t["invoiced"], "1000.00"); + assert_eq!(t["received"], "600.00", "the payment on its invoice"); + assert_eq!(t["paid"], "0.00"); + assert_eq!(t["receivable"], "400.00"); + assert!(t["amount_1099"].is_null(), "no vendor record"); + let ids: Vec = p["entries"] + .as_array() + .unwrap() + .iter() + .map(|e| e["entry_id"].as_u64().unwrap()) + .collect(); + assert_eq!(ids, [pay, inv["entry_id"].as_u64().unwrap()]); + assert_eq!(p["invoices"][0]["balance"], "400.00"); + assert_eq!(p["invoices"][0]["status"], "partial"); + + m.add_party("Stephen", &["member"]); + let member = json!({"party": "Stephen"}); + m.post( + "2026-01-02", + member.clone(), + &[("1000", "debit", "2000.00"), ("3000", "credit", "2000.00")], + ); + m.post( + "2026-09-30", + member, + &[("3000", "debit", "500.00"), ("1000", "credit", "500.00")], + ); + let t = m.party("Stephen", 2026)["totals"].clone(); + assert_eq!(t["received"], "2000.00", "a contribution"); + assert_eq!(t["paid"], "500.00", "a distribution"); + + // A prepaid bought with a member's card: an asset, paid by a liability. + m.add_party("Insurer", &[]); + m.post( + "2026-04-01", + json!({"party": "Insurer"}), + &[("1300", "debit", "1200.00"), ("2100", "credit", "1200.00")], + ); + let t = m.party("Insurer", 2026)["totals"].clone(); + assert_eq!(t["paid"], "1200.00"); + assert_eq!(t["received"], "0.00"); +} + +// ------------------------------------------------- payroll + +#[test] +fn payroll_names_the_employee_party() { + let mut m = books("payroll"); + let pat = m.add_party("Pat Doe", &["employee"]); + m.add_party("Acme Design", &["contractor"]); + // Not an employee: refused, nothing written. + m.refuse( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [pat_line("Acme Design")]}), + ); + m.refuse( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [pat_line("Nobody")]}), + ); + // Neither employee nor name. + let mut nameless = pat_line("Pat Doe"); + nameless.as_object_mut().unwrap().remove("employee"); + m.refuse( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [nameless]}), + ); + let run = m.ok( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [pat_line("pat doe")]}), + ); + let p = m.party(&pat.to_string(), 2026); + let line = &p["payroll"][0]; + assert_eq!(line["run_id"], run["run_id"]); + assert_eq!(line["entry_id"], run["entry_id"]); + assert_eq!(line["date"], "2026-02-15"); + assert_eq!(line["gross_pay"], "1000.00"); + assert_eq!(line["net_pay"], "773.50", "1000 - 100 - 50 - 62 - 14.50"); + assert_eq!(p["totals"]["gross_pay"], "1000.00"); + assert_eq!(p["totals"]["net_pay"], "773.50"); + // The run record carries the employee's name by default. + let journal = String::from_utf8(m.journal()).unwrap(); + assert!( + journal.contains("\"employee_name\":\"Pat Doe\""), + "{journal}" + ); + assert_eq!(m.party("Pat Doe", 2025)["totals"]["gross_pay"], "0.00"); +} + +/// The books verify (replay and the SQLite projection) after a payroll run +/// that names its employee party. +#[test] +fn books_verify_after_payroll_with_an_employee_party() { + let mut m = books("payroll-verify"); + m.add_party("Pat Doe", &["employee"]); + m.ok( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [pat_line("Pat Doe")]}), + ); + let v = m.ok("books.verify", json!({})); + assert_eq!(v["status"], "ok", "{v}"); +} + +// ------------------------------------------------- books.parties + +#[test] +fn parties_lists_by_id_and_filters_by_role() { + let mut m = books("list"); + let a = m.add_party("Acme Design", &["contractor"]); + let i = m.add_party("IRS", &["tax_agency"]); + let b = m.add_party("Bluevine", &["financial_institution"]); + let all = m.read_only("books.parties", json!({})); + assert!(all["year"].is_i64(), "defaults to this year"); + let ids: Vec = all["parties"] + .as_array() + .unwrap() + .iter() + .map(|p| p["party_id"].as_u64().unwrap()) + .collect(); + assert_eq!(ids, [a, i, b]); + let row = &all["parties"][0]; + assert_eq!(row["name"], "Acme Design"); + assert_eq!(row["label"], "Acme Design"); + assert_eq!(roles(row), ["contractor"]); + assert_eq!(row["w9_on_file"], false); + assert!(row["tin_last4"].is_null()); + assert_eq!(row["paid"], "0.00"); + let agencies = m.read_only("books.parties", json!({"role": "tax_agency"})); + assert_eq!(agencies["parties"].as_array().unwrap().len(), 1); + assert_eq!(agencies["parties"][0]["party_id"], json!(i)); + m.refuse("books.parties", json!({"role": "wizard"})); +} + +// ------------------------------------------------- books.show { party } + +#[test] +fn show_opens_a_party_by_name_or_id() { + let mut m = books("show"); + let a = m.add_party("Acme Design", &[]); + let s = m.read_only("books.show", json!({"party": "acme design"})); + assert!(s["shown"].as_str().unwrap().contains("Acme Design"), "{s}"); + m.read_only("books.show", json!({"party": a.to_string()})); + m.read_only("books.show", json!({"page": "parties"})); + m.refuse("books.show", json!({"party": "Nobody"})); + m.refuse( + "books.show", + json!({"party": "Acme Design", "page": "parties"}), + ); +} + +// ------------------------------------------------- never a full TIN out + +/// No tool output in a session full of party facts holds a nine-digit run. +#[test] +fn no_party_output_holds_a_full_tin() { + let mut m = books("no-tin"); + m.add_party("Acme Design", &["contractor"]); + m.ok("books.add_vendor", json!({"name": "Acme Design"})); + m.ok("books.set_w9", w9("Acme Design")); + for args in [ + json!({"name": "Acme 123456789"}), + json!({"name": "Acme 123 45 6789"}), + ] { + m.refuse("books.add_party", args); + } + let outs = [ + m.read_only("books.parties", json!({"year": 2026})), + m.party("Acme Design", 2026), + m.read_only("books.list_vendors", json!({})), + ]; + for out in outs { + let text = out.to_string(); + let digits: Vec = text + .split(|c: char| !c.is_ascii_digit()) + .map(str::len) + .collect(); + assert!(digits.iter().all(|&n| n < 9), "{text}"); + } +} diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 7226022..3c3ee77 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -5,6 +5,31 @@ use thiserror::Error; /// Every way a store operation can fail. #[derive(Debug, Error)] pub enum StoreError { + /// An attachment link names a subject that doesn't exist. + #[error("no {0} to link to")] + UnknownSubject(String), + /// The attachment is already linked to that subject. + #[error("attachment {0} is already linked to {1}")] + AlreadyLinked(u64, String), + /// The attachment isn't linked to that subject. + #[error("attachment {0} isn't linked to {1}")] + NotLinkedTo(u64, String), + /// A statement record that doesn't fit its account or itself. + #[error("bad statement: {0}")] + InvalidStatement(String), + /// The books don't agree with the statement yet. + #[error( + "not reconciled: the cleared balance differs from the statement by {difference}; \ + clear the lines the statement shows (or find what's missing) first" + )] + NotReconciled { + /// Cleared balance less the statement balance. + difference: rust_decimal::Decimal, + }, + /// A reconciliation command that breaks the sequence. + #[error("{0}")] + ReconciliationRefused(String), + /// An asset can't be corrected once depreciation for it is posted: the /// posted entries would disagree with the corrected schedule. #[error("asset {0} already has depreciation posted; correct it with entries instead")] diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 37a97df..f9ef5f0 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -32,5 +32,5 @@ pub use journal::{GENESIS_PREV, JournalLine, JournalWriter}; pub use snapshot::{DiskCheck, Snapshot}; pub use store::{ Attachment, DB_FILE, DepreciationRun, DisposalPreview, EntryRow, EventRow, JOURNAL_FILE, - LOCK_FILE, PayrollLineInput, Store, VerifyReport, + LOCK_FILE, PayrollLineInput, Reconciliation, Statement, Store, Subject, VerifyReport, }; diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index f1ccd5c..49b8400 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 10; +pub const SCHEMA_VERSION: u32 = 11; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -227,5 +227,44 @@ CREATE TABLE IF NOT EXISTS asset_tags_log( -- (TEXT, exact decimal, never REAL). ALTER TABLE assets ADD COLUMN tax_rule TEXT; ALTER TABLE assets ADD COLUMN contributor_basis TEXT; +"#, + r#" +-- v11: attachments link to any subject (docs/parties-and-attachments.md): +-- entries beyond the first, accounts, assets, parties, invoices, bills, +-- periods, payroll runs, schedules, reconciliations, the company. Bank and +-- card statements carry their account, period, and balances; a +-- reconciliation records the statement, its balance, and the lines it +-- cleared. Amounts are TEXT, exact decimal. +CREATE TABLE IF NOT EXISTS attachment_links( + attachment_id INTEGER NOT NULL, + subject_kind TEXT NOT NULL, + subject_id INTEGER NOT NULL, + PRIMARY KEY(attachment_id, subject_kind, subject_id) +); +CREATE INDEX IF NOT EXISTS idx_attachment_links_subject ON attachment_links(subject_kind, subject_id); +-- Payroll lines name their employee party (Ledger PayrollLine.employee). +ALTER TABLE payroll_lines ADD COLUMN employee_id INTEGER; +CREATE TABLE IF NOT EXISTS statements( + attachment_id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL, + period_start TEXT NOT NULL, + period_end TEXT NOT NULL, + opening_balance TEXT NOT NULL, + closing_balance TEXT NOT NULL +); +CREATE TABLE IF NOT EXISTS reconciliations( + id INTEGER PRIMARY KEY, + account_id INTEGER NOT NULL, + statement_date TEXT NOT NULL, + statement_balance TEXT NOT NULL, + attachment_id INTEGER NOT NULL, + voided_reason TEXT NULL +); +CREATE TABLE IF NOT EXISTS reconciliation_lines( + reconciliation_id INTEGER NOT NULL, + entry_id INTEGER NOT NULL, + line_index INTEGER NOT NULL, + PRIMARY KEY(reconciliation_id, entry_id, line_index) +); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index a146214..8d7e824 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -725,6 +725,102 @@ impl Store { Ok(()) } + // ------------------------------------------------------------------ + // Parties (ledger_core::parties) + // ------------------------------------------------------------------ + + /// Add a party. Journaled as `party.added`. + pub fn add_party( + &mut self, + name: &str, + roles: &[ledger_core::Role], + ) -> Result { + self.ledger.check_add_party(name, roles)?; + let id = self.ledger.next_party_id(); + let line = self.append_journal( + "party.added", + json!({"id": id, "name": name.trim(), "roles": roles.iter().map(|r| r.key()).collect::>()}), + )?; + let got = self.ledger.add_party(name, roles)?; + debug_assert_eq!(got, id, "party id allocator diverged from journal"); + apply_line_to_sqlite(&self.conn, &line)?; + Ok(got) + } + + /// Change one of a party's facts. Journaled as `party.updated`. + pub fn update_party( + &mut self, + id: ledger_core::PartyId, + field: ledger_core::PartyField, + ) -> Result<(), StoreError> { + self.ledger.check_update_party(id, &field)?; + let line = self.append_journal("party.updated", json!({"id": id, "change": field}))?; + self.ledger.update_party(id, field)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Give or take a party role. Journaled as `party.role_set`. + pub fn set_party_role( + &mut self, + id: ledger_core::PartyId, + role: ledger_core::Role, + on: bool, + ) -> Result<(), StoreError> { + self.ledger.check_set_party_role(id, role, on)?; + let line = self.append_journal( + "party.role_set", + json!({"id": id, "role": role.key(), "on": on}), + )?; + self.ledger.set_party_role(id, role, on)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Record or clear a party's W-9 facts. Journaled as `party.w9_set`. + pub fn set_party_w9( + &mut self, + id: ledger_core::PartyId, + facts: Option, + ) -> Result<(), StoreError> { + self.ledger.check_set_party_w9(id, facts.as_ref())?; + let line = self.append_journal("party.w9_set", json!({"id": id, "facts": facts}))?; + self.ledger.set_party_w9(id, facts)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Record or clear a party's W-4 facts. Journaled as `party.w4_set`. + pub fn set_party_w4( + &mut self, + id: ledger_core::PartyId, + facts: Option, + ) -> Result<(), StoreError> { + self.ledger.check_set_party_w4(id, facts.as_ref())?; + let line = self.append_journal("party.w4_set", json!({"id": id, "facts": facts}))?; + self.ledger.set_party_w4(id, facts)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Tag an entry with its party, or clear the tag. Journaled as + /// `entry.party_set`. + pub fn set_entry_party( + &mut self, + entry: EntryId, + party: Option, + ) -> Result<(), StoreError> { + let mut rehearsal = self.ledger.clone(); + rehearsal.set_entry_party(entry, party)?; + let line = self.append_journal( + "entry.party_set", + json!({"entry_id": entry, "party_id": party}), + )?; + self.ledger.set_entry_party(entry, party)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Correct a registered asset's cost, salvage, life, or in-service date /// (see `Ledger::correct_asset`), with the reason. Refused once any /// depreciation for it is posted. Journaled as `asset.corrected`, with @@ -2416,7 +2512,10 @@ impl Store { /// while they may be needed). /// SCENARIOS: /// 1. IF the attachment is unknown or already withdrawn: SHOULD refuse. - /// 2. IF it is linked to an entry: SHOULD refuse (AttachmentLinked). + /// 2. IF it is filed on an entry: SHOULD refuse (AttachmentLinked, + /// undone with unlink_receipt). IF it is linked to any subject + /// (link_attachment): SHOULD refuse (AlreadyLinked, naming the + /// first subject; undone with unlink_attachment). /// 3. IF the reason is blank: SHOULD refuse. /// 4. Otherwise: SHOULD journal `attachment.withdrawn`; the store's /// listing marks it withdrawn with the reason (the MCP listing @@ -2432,6 +2531,9 @@ impl Store { return Err(StoreError::ReasonRequired); } self.check_linkable(attachment_id)?; + if let Some(sub) = self.attachment_links(attachment_id)?.first() { + return Err(StoreError::AlreadyLinked(attachment_id, sub.describe())); + } let line = self.append_journal( "attachment.withdrawn", json!({ "attachment_id": attachment_id, "reason": reason }), @@ -2440,6 +2542,443 @@ impl Store { Ok(()) } + // ------------------------------------------------------------------ + // Attachment links, statements, reconciliations + // ------------------------------------------------------------------ + + /// Whether `subject` exists in the books. + fn subject_exists(&self, subject: Subject) -> Result { + let l = &self.ledger; + Ok(match subject { + Subject::Entry(i) => l.get_entry(i).is_some(), + Subject::Account(i) => l.chart().get(i).is_some(), + Subject::Asset(i) => l.get_asset(i).is_some(), + Subject::Party(i) => l.get_party(i).is_some(), + Subject::Invoice(i) => l.get_invoice(i).is_some(), + Subject::Bill(i) => l.get_bill(i).is_some(), + Subject::Period(i) => l.list_periods().iter().any(|p| p.id == i), + Subject::PayrollRun(i) => l.get_payroll_run(i).is_some(), + Subject::Schedule(i) => l.get_schedule(i).is_some(), + Subject::Reconciliation(i) => self + .conn + .query_row( + "SELECT 1 FROM reconciliations WHERE id = ?", + params![i as i64], + |_| Ok(()), + ) + .optional()? + .is_some(), + Subject::Company => true, + }) + } + + /// The subjects an attachment is linked to (its filing entry, if any, + /// first). + pub fn attachment_links(&self, attachment_id: u64) -> Result, StoreError> { + let (entry, _) = self.attachment_state(attachment_id)?; + let mut out: Vec = entry.map(Subject::Entry).into_iter().collect(); + let mut stmt = self.conn.prepare( + "SELECT subject_kind, subject_id FROM attachment_links WHERE attachment_id = ? ORDER BY subject_kind, subject_id", + )?; + let rows = stmt.query_map(params![attachment_id as i64], |r| { + Ok((r.get::<_, String>(0)?, r.get::<_, i64>(1)?)) + })?; + for row in rows { + let (kind, id) = row?; + if let Some(sub) = Subject::from_parts(&kind, id as u64) { + out.push(sub); + } + } + Ok(out) + } + + /// The attachments linked to a subject (for an entry: filed on it or + /// linked to it), not withdrawn. + pub fn attachments_for(&self, subject: Subject) -> Result, StoreError> { + if let Subject::Entry(e) = subject { + return Ok(query_attachments(&self.conn, Some(e))? + .into_iter() + .filter(|a| a.withdrawn_at.is_none()) + .collect()); + } + let ids: Vec = self + .conn + .prepare( + "SELECT attachment_id FROM attachment_links WHERE subject_kind = ? AND subject_id = ? ORDER BY attachment_id", + )? + .query_map(params![subject.kind(), subject.id() as i64], |r| r.get(0))? + .collect::>()?; + Ok(query_attachments(&self.conn, None)? + .into_iter() + .filter(|a| ids.contains(&(a.id as i64)) && a.withdrawn_at.is_none()) + .collect()) + } + + /// Link a stored attachment to a subject: another entry, an account, an + /// asset, a party, an invoice, a bill, a period, a payroll run, a + /// schedule, a reconciliation, or the company. + /// + /// ```text + /// GOAL: one file, linked wherever it's evidence, without refiling. + /// SOURCE: docs/parties-and-attachments.md "Attachments"; DECISION + /// (Stephen, 2026-10-15). + /// INVARIANTS: + /// 1. Journaled as `attachment.linked {attachment_id, subject}`. + /// 2. An entry's receipts are the files filed on it and the files + /// linked to it; the receipt policy counts both. + /// 3. A refusal journals nothing. + /// SCENARIOS: + /// 1. IF the attachment is unknown or withdrawn, or the subject + /// doesn't exist: SHOULD refuse. + /// 2. IF it is already linked to that subject (or filed on that + /// entry): SHOULD refuse. + /// ``` + pub fn link_attachment( + &mut self, + attachment_id: u64, + subject: Subject, + ) -> Result<(), StoreError> { + let (entry, withdrawn) = self.attachment_state(attachment_id)?; + if withdrawn { + return Err(StoreError::AttachmentWithdrawn(attachment_id)); + } + if !self.subject_exists(subject)? { + return Err(StoreError::UnknownSubject(subject.describe())); + } + if entry.map(Subject::Entry) == Some(subject) + || self.attachment_links(attachment_id)?.contains(&subject) + { + return Err(StoreError::AlreadyLinked(attachment_id, subject.describe())); + } + let line = self.append_journal( + "attachment.linked", + json!({"attachment_id": attachment_id, "subject": subject}), + )?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Take a link off, with the reason. The file stays filed; a receipt + /// filed on an entry comes off with `unlink_receipt` instead. + /// + /// ```text + /// SCENARIOS: + /// 1. IF the reason is blank, or the attachment isn't linked to the + /// subject (through this table): SHOULD refuse. + /// 2. Otherwise: journaled as `attachment.unlinked {attachment_id, + /// subject, reason}`. + /// ``` + pub fn unlink_attachment( + &mut self, + attachment_id: u64, + subject: Subject, + reason: &str, + ) -> Result<(), StoreError> { + let reason = reason.trim(); + if reason.is_empty() { + return Err(StoreError::ReasonRequired); + } + self.attachment_state(attachment_id)?; + let linked = self + .conn + .query_row( + "SELECT 1 FROM attachment_links WHERE attachment_id = ? AND subject_kind = ? AND subject_id = ?", + params![attachment_id as i64, subject.kind(), subject.id() as i64], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !linked { + return Err(StoreError::NotLinkedTo(attachment_id, subject.describe())); + } + let line = self.append_journal( + "attachment.unlinked", + json!({"attachment_id": attachment_id, "subject": subject, "reason": reason}), + )?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Record what a filed statement says: its account, period, and + /// balances. Links it to the account. + /// + /// ```text + /// GOAL: the bank and card statements are in the books, so a + /// reconciliation names the one it agrees with. + /// INVARIANTS: + /// 1. Journaled as `statement.recorded {attachment_id, account_id, + /// period_start, period_end, opening_balance, closing_balance}`, + /// then `attachment.linked` to the account. + /// SCENARIOS: + /// 1. IF the attachment is unknown or withdrawn, already records a + /// statement, the account is unknown or not an asset or liability + /// (a bank, cash, or card account), the period runs backwards, or + /// a balance has more than two decimal places: SHOULD refuse, + /// journaling nothing. + /// ``` + pub fn record_statement(&mut self, st: Statement) -> Result<(), StoreError> { + let (_, withdrawn) = self.attachment_state(st.attachment_id)?; + if withdrawn { + return Err(StoreError::AttachmentWithdrawn(st.attachment_id)); + } + let bad = |why: &str| Err(StoreError::InvalidStatement(why.to_string())); + let acct = self + .ledger + .chart() + .get(st.account_id) + .ok_or(LedgerError::UnknownAccount(st.account_id))?; + if !matches!( + acct.account_type, + AccountType::Asset | AccountType::Liability + ) { + return bad("a statement is for a bank, cash, or card account (an asset or liability)"); + } + if st.period_end < st.period_start { + return bad("the period ends before it starts"); + } + if [st.opening_balance, st.closing_balance] + .iter() + .any(|b| b.normalize().scale() > 2) + { + return bad("balances are in cents at most"); + } + if self.statement(st.attachment_id)?.is_some() { + return bad("that attachment already records a statement"); + } + let line = self.append_journal( + "statement.recorded", + json!({ + "attachment_id": st.attachment_id, + "account_id": st.account_id, + "period_start": st.period_start.to_string(), + "period_end": st.period_end.to_string(), + "opening_balance": st.opening_balance.to_string(), + "closing_balance": st.closing_balance.to_string(), + }), + )?; + apply_line_to_sqlite(&self.conn, &line)?; + if !self + .attachment_links(st.attachment_id)? + .contains(&Subject::Account(st.account_id)) + { + self.link_attachment(st.attachment_id, Subject::Account(st.account_id))?; + } + Ok(()) + } + + /// The statement an attachment records, if any. + pub fn statement(&self, attachment_id: u64) -> Result, StoreError> { + Ok(self + .statements(None)? + .into_iter() + .find(|s| s.attachment_id == attachment_id)) + } + + /// Recorded statements, optionally one account's, by period end. + pub fn statements(&self, account: Option) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT attachment_id, account_id, period_start, period_end, opening_balance, closing_balance FROM statements ORDER BY period_end, attachment_id", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(5)?, + )) + })?; + let mut out = Vec::new(); + for row in rows { + let (att, acct, start, end, open, close) = row?; + if account.is_some_and(|a| a != acct as u64) { + continue; + } + let dec = |t: &str| { + t.parse::() + .map_err(|_| StoreError::JournalCorrupt(format!("statement amount {t}"))) + }; + out.push(Statement { + attachment_id: att as u64, + account_id: acct as u64, + period_start: parse_date(&start)?, + period_end: parse_date(&end)?, + opening_balance: dec(&open)?, + closing_balance: dec(&close)?, + }); + } + Ok(out) + } + + /// Reconcile an account to a recorded statement: the books' cleared + /// balance through the statement date must equal its closing balance. + /// + /// ```text + /// GOAL: a monthly close that leaves proof: which statement, which + /// balance, which lines it cleared. + /// SOURCE: CONVENTION (bank reconciliation); docs/parties-and- + /// attachments.md "Reconciliations"; DECISION (Stephen, + /// 2026-10-15). + /// INVARIANTS: + /// 1. Recorded only when the difference is exactly zero + /// (Ledger::reconciliation_report against the statement's date and + /// closing balance). + /// 2. It records the cleared lines on the account dated on or before + /// the statement date, and links the statement to it. + /// 3. Reconciliations of an account go forward: a new one's date is + /// after every one not voided. + /// 4. Journaled as `reconciliation.completed`; never edited (void and + /// redo). + /// SCENARIOS: + /// 1. IF the attachment records no statement: SHOULD refuse. + /// 2. IF the books differ from the statement: SHOULD refuse with + /// NotReconciled naming the difference. + /// 3. IF a reconciliation not voided is dated on or after it: SHOULD + /// refuse. + /// ``` + pub fn complete_reconciliation( + &mut self, + statement_attachment: u64, + ) -> Result { + let st = self.statement(statement_attachment)?.ok_or_else(|| { + StoreError::InvalidStatement(format!( + "attachment {statement_attachment} records no statement (record_statement first)" + )) + })?; + if let Some(last) = self + .reconciliations(Some(st.account_id))? + .into_iter() + .filter(|r| r.voided.is_none()) + .map(|r| r.statement_date) + .max() + && last >= st.period_end + { + return Err(StoreError::ReconciliationRefused(format!( + "account {} is already reconciled through {last}; void that reconciliation to redo it", + st.account_id + ))); + } + let report = + self.ledger + .reconciliation_report(st.account_id, st.period_end, st.closing_balance)?; + if !report.difference.is_zero() { + return Err(StoreError::NotReconciled { + difference: report.difference, + }); + } + let mut cleared = Vec::new(); + for e in self.ledger.posted_entries() { + if e.date() > st.period_end { + continue; + } + for (i, l) in e.lines().iter().enumerate() { + if l.account == st.account_id && self.ledger.is_cleared(e.id(), i) { + cleared.push((e.id(), i)); + } + } + } + cleared.sort_unstable(); + let id: u64 = self.conn.query_row( + "SELECT COALESCE(MAX(id), 0) + 1 FROM reconciliations", + [], + |r| r.get::<_, i64>(0), + )? as u64; + let line = self.append_journal( + "reconciliation.completed", + json!({ + "id": id, + "account_id": st.account_id, + "statement_date": st.period_end.to_string(), + "statement_balance": st.closing_balance.to_string(), + "attachment_id": statement_attachment, + "cleared": cleared, + }), + )?; + apply_line_to_sqlite(&self.conn, &line)?; + self.link_attachment(statement_attachment, Subject::Reconciliation(id))?; + Ok(Reconciliation { + id, + account_id: st.account_id, + statement_date: st.period_end, + statement_balance: st.closing_balance, + attachment_id: statement_attachment, + cleared, + voided: None, + }) + } + + /// Void a reconciliation, with the reason (to redo it). Journaled as + /// `reconciliation.voided`. + pub fn void_reconciliation(&mut self, id: u64, reason: &str) -> Result<(), StoreError> { + let reason = reason.trim(); + if reason.is_empty() { + return Err(StoreError::ReasonRequired); + } + let r = self + .reconciliations(None)? + .into_iter() + .find(|r| r.id == id) + .ok_or_else(|| StoreError::UnknownSubject(format!("reconciliation {id}")))?; + if r.voided.is_some() { + return Err(StoreError::ReconciliationRefused(format!( + "reconciliation {id} is already voided" + ))); + } + let line = + self.append_journal("reconciliation.voided", json!({"id": id, "reason": reason}))?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Reconciliations, optionally one account's, by date. + pub fn reconciliations( + &self, + account: Option, + ) -> Result, StoreError> { + let mut stmt = self.conn.prepare( + "SELECT id, account_id, statement_date, statement_balance, attachment_id, voided_reason FROM reconciliations ORDER BY statement_date, id", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, i64>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, i64>(4)?, + r.get::<_, Option>(5)?, + )) + })?; + let mut out = Vec::new(); + for row in rows { + let (id, acct, date, bal, att, voided) = row?; + if account.is_some_and(|a| a != acct as u64) { + continue; + } + let cleared: Vec<(EntryId, usize)> = self + .conn + .prepare( + "SELECT entry_id, line_index FROM reconciliation_lines WHERE reconciliation_id = ? ORDER BY entry_id, line_index", + )? + .query_map(params![id], |r| { + Ok((r.get::<_, i64>(0)? as u64, r.get::<_, i64>(1)? as usize)) + })? + .collect::>()?; + out.push(Reconciliation { + id: id as u64, + account_id: acct as u64, + statement_date: parse_date(&date)?, + statement_balance: bal.parse().map_err(|_| { + StoreError::JournalCorrupt(format!("reconciliation balance {bal}")) + })?, + attachment_id: att as u64, + cleared, + voided, + }); + } + Ok(out) + } + /// `(linked entry, withdrawn?)` for an attachment. fn attachment_state(&self, attachment_id: u64) -> Result<(Option, bool), StoreError> { self.conn @@ -3002,7 +3541,7 @@ impl Store { for r in run_rows { let (id, date, entry_id) = r?; let mut pl_stmt = self.conn.prepare( - "SELECT employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions FROM payroll_lines WHERE run_id = ? ORDER BY id", + "SELECT employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions, employee_id FROM payroll_lines WHERE run_id = ? ORDER BY id", )?; let pl_rows = pl_stmt.query_map(params![id], |lr| { Ok(( @@ -3015,13 +3554,15 @@ impl Store { lr.get::<_, String>(6)?, lr.get::<_, String>(7)?, lr.get::<_, String>(8)?, + lr.get::<_, Option>(9)?, )) })?; let mut lines = Vec::new(); for lr in pl_rows { - let (name, gross, fed, state, ss_e, med_e, ss_r, med_r, other) = lr?; + let (name, gross, fed, state, ss_e, med_e, ss_r, med_r, other, emp) = lr?; lines.push(PayrollLine { employee_name: name, + employee: emp.map(|e| e as u64), gross_pay: parse_payroll_amount(gross)?, federal_withholding: parse_payroll_amount(fed)?, state_withholding: parse_payroll_amount(state)?, @@ -3150,6 +3691,7 @@ fn flip_line_json(l: &ledger_core::JournalLine) -> Value { fn payroll_line_json(l: &PayrollLine) -> Value { json!({ "employee_name": l.employee_name, + "employee": l.employee, "gross_pay": l.gross_pay.to_string(), "federal_withholding": l.federal_withholding.to_string(), "state_withholding": l.state_withholding.to_string(), @@ -3177,6 +3719,12 @@ fn parse_payroll_lines(p: &Value) -> Result, StoreError> { }; Ok(PayrollLine { employee_name: get_str(l, "employee_name")?.to_string(), + employee: match l.get("employee") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().ok_or_else(|| { + StoreError::JournalCorrupt("payroll line employee".into()) + })?), + }, gross_pay: amount("gross_pay")?, federal_withholding: amount("federal_withholding")?, state_withholding: amount("state_withholding")?, @@ -3498,6 +4046,13 @@ pub(crate) fn replay_line_into_ledger( pct, )?; } + "attachment.linked" + | "attachment.unlinked" + | "statement.recorded" + | "reconciliation.completed" + | "reconciliation.voided" => { + // Evidence, not accounting state: the projection records them. + } "receipt.attached" | "receipt.linked" | "receipt.unlinked" @@ -3530,6 +4085,59 @@ pub(crate) fn replay_line_into_ledger( "asset.tagged" => { ledger.set_asset_tag(get_u64(p, "asset_id")?, get_str(p, "tag")?)?; } + "party.added" => { + let want = get_u64(p, "id")?; + let roles = p + .get("roles") + .and_then(|v| v.as_array()) + .ok_or_else(|| StoreError::JournalCorrupt("party.added roles".into()))? + .iter() + .map(|r| { + r.as_str() + .and_then(ledger_core::Role::parse) + .ok_or_else(|| StoreError::JournalCorrupt("party.added role".into())) + }) + .collect::, _>>()?; + let got = ledger.add_party(get_str(p, "name")?, &roles)?; + if got != want { + return Err(StoreError::ReplayMismatch(format!( + "party id: journal says {want}, replay got {got}" + ))); + } + } + "party.updated" => { + let field: ledger_core::PartyField = serde_json::from_value(p["change"].clone()) + .map_err(|_| StoreError::JournalCorrupt("party.updated change".into()))?; + ledger.update_party(get_u64(p, "id")?, field)?; + } + "party.role_set" => { + let role = ledger_core::Role::parse(get_str(p, "role")?) + .ok_or_else(|| StoreError::JournalCorrupt("party.role_set role".into()))?; + let on = p + .get("on") + .and_then(|v| v.as_bool()) + .ok_or_else(|| StoreError::JournalCorrupt("party.role_set on".into()))?; + ledger.set_party_role(get_u64(p, "id")?, role, on)?; + } + "party.w9_set" => { + let facts: Option = serde_json::from_value(p["facts"].clone()) + .map_err(|_| StoreError::JournalCorrupt("party.w9_set facts".into()))?; + ledger.set_party_w9(get_u64(p, "id")?, facts)?; + } + "party.w4_set" => { + let facts: Option = serde_json::from_value(p["facts"].clone()) + .map_err(|_| StoreError::JournalCorrupt("party.w4_set facts".into()))?; + ledger.set_party_w4(get_u64(p, "id")?, facts)?; + } + "entry.party_set" => { + let party = match p.get("party_id") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().ok_or_else(|| { + StoreError::JournalCorrupt("entry.party_set party_id".into()) + })?), + }; + ledger.set_entry_party(get_u64(p, "entry_id")?, party)?; + } "asset.corrected" => { ledger.correct_asset( get_u64(p, "asset_id")?, @@ -4030,9 +4638,82 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr "payroll.taxes_remitted" => { // The entry was projected by its own events; this only names it. } - "role.assigned" => { - // Roles live in the ledger (rebuilt by replay); the events - // mirror below keeps the history. + "attachment.linked" | "attachment.unlinked" => { + let sub = &p["subject"]; + let kind = get_str(sub, "kind")?; + let id = sub.get("id").and_then(|v| v.as_u64()).unwrap_or(0); + if Subject::from_parts(kind, id).is_none() { + return Err(StoreError::JournalCorrupt(format!( + "attachment subject {kind}" + ))); + } + let att = get_u64(p, "attachment_id")? as i64; + if line.line_type == "attachment.linked" { + conn.execute( + "INSERT INTO attachment_links(attachment_id, subject_kind, subject_id) VALUES (?,?,?)", + params![att, kind, id as i64], + )?; + } else { + conn.execute( + "DELETE FROM attachment_links WHERE attachment_id = ? AND subject_kind = ? AND subject_id = ?", + params![att, kind, id as i64], + )?; + } + } + "statement.recorded" => { + conn.execute( + "INSERT INTO statements(attachment_id, account_id, period_start, period_end, opening_balance, closing_balance) VALUES (?,?,?,?,?,?)", + params![ + get_u64(p, "attachment_id")? as i64, + get_u64(p, "account_id")? as i64, + get_str(p, "period_start")?, + get_str(p, "period_end")?, + get_str(p, "opening_balance")?, + get_str(p, "closing_balance")?, + ], + )?; + } + "reconciliation.completed" => { + let id = get_u64(p, "id")? as i64; + conn.execute( + "INSERT INTO reconciliations(id, account_id, statement_date, statement_balance, attachment_id) VALUES (?,?,?,?,?)", + params![ + id, + get_u64(p, "account_id")? as i64, + get_str(p, "statement_date")?, + get_str(p, "statement_balance")?, + get_u64(p, "attachment_id")? as i64, + ], + )?; + let cleared = p + .get("cleared") + .and_then(|v| v.as_array()) + .ok_or_else(|| StoreError::JournalCorrupt("reconciliation cleared".into()))?; + for pair in cleared { + let e = pair.get(0).and_then(|v| v.as_u64()); + let l = pair.get(1).and_then(|v| v.as_u64()); + let (Some(e), Some(l)) = (e, l) else { + return Err(StoreError::JournalCorrupt( + "reconciliation cleared line".into(), + )); + }; + conn.execute( + "INSERT INTO reconciliation_lines(reconciliation_id, entry_id, line_index) VALUES (?,?,?)", + params![id, e as i64, l as i64], + )?; + } + } + "reconciliation.voided" => { + conn.execute( + "UPDATE reconciliations SET voided_reason = ? WHERE id = ?", + params![get_str(p, "reason")?, get_u64(p, "id")? as i64], + )?; + } + "role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set" + | "party.w4_set" | "entry.party_set" => { + // Control roles and the party directory live in the ledger + // (rebuilt by replay); the events mirror below keeps the + // history. } "asset.tagged" => { let (id, tag) = (get_u64(p, "asset_id")? as i64, get_str(p, "tag")?); @@ -4373,7 +5054,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr )?; for l in parse_payroll_lines(p)? { conn.execute( - "INSERT INTO payroll_lines(run_id, employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions) VALUES (?,?,?,?,?,?,?,?,?,?)", + "INSERT INTO payroll_lines(run_id, employee_name, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions, employee_id) VALUES (?,?,?,?,?,?,?,?,?,?,?)", params![ run_id, l.employee_name, @@ -4385,6 +5066,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr l.ss_employer.to_string(), l.medicare_employer.to_string(), l.other_deductions.to_string(), + l.employee.map(|e| e as i64), ], )?; } @@ -4517,6 +5199,135 @@ pub(crate) fn compare_projections( Ok(()) } +/// What an attachment can be linked to (docs/parties-and-attachments.md). +#[derive( + Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Serialize, serde::Deserialize, +)] +#[serde(tag = "kind", content = "id", rename_all = "snake_case")] +pub enum Subject { + /// An entry (beyond the one a receipt is filed on). + Entry(u64), + /// An account (e.g. its statements, the agreement that opened it). + Account(u64), + /// A fixed asset (photos, warranty, manuals). + Asset(u64), + /// A party (contracts, correspondence). + Party(u64), + /// An invoice. + Invoice(u64), + /// A bill. + Bill(u64), + /// An accounting period (its close package). + Period(u64), + /// A payroll run. + PayrollRun(u64), + /// An amortization schedule (the prepaid contract). + Schedule(u64), + /// A reconciliation (its statement). + Reconciliation(u64), + /// The company itself (formation documents). + Company, +} + +impl Subject { + /// Its kind's wire name. + pub fn kind(self) -> &'static str { + match self { + Subject::Entry(_) => "entry", + Subject::Account(_) => "account", + Subject::Asset(_) => "asset", + Subject::Party(_) => "party", + Subject::Invoice(_) => "invoice", + Subject::Bill(_) => "bill", + Subject::Period(_) => "period", + Subject::PayrollRun(_) => "payroll_run", + Subject::Schedule(_) => "schedule", + Subject::Reconciliation(_) => "reconciliation", + Subject::Company => "company", + } + } + + /// Its id (0 for the company). + pub fn id(self) -> u64 { + match self { + Subject::Entry(i) + | Subject::Account(i) + | Subject::Asset(i) + | Subject::Party(i) + | Subject::Invoice(i) + | Subject::Bill(i) + | Subject::Period(i) + | Subject::PayrollRun(i) + | Subject::Schedule(i) + | Subject::Reconciliation(i) => i, + Subject::Company => 0, + } + } + + /// The subject of `kind` and `id`. + pub fn from_parts(kind: &str, id: u64) -> Option { + Some(match kind { + "entry" => Subject::Entry(id), + "account" => Subject::Account(id), + "asset" => Subject::Asset(id), + "party" => Subject::Party(id), + "invoice" => Subject::Invoice(id), + "bill" => Subject::Bill(id), + "period" => Subject::Period(id), + "payroll_run" => Subject::PayrollRun(id), + "schedule" => Subject::Schedule(id), + "reconciliation" => Subject::Reconciliation(id), + "company" => Subject::Company, + _ => return None, + }) + } + + /// Human words, e.g. "account 1", for messages. + pub fn describe(self) -> String { + match self { + Subject::Company => "the company".into(), + s => format!("{} {}", s.kind().replace('_', " "), s.id()), + } + } +} + +/// A bank or card statement: the attachment that holds it, and what it +/// says. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Statement { + /// The attachment holding the statement. + pub attachment_id: u64, + /// The account it is for. + pub account_id: AccountId, + /// First day it covers. + pub period_start: NaiveDate, + /// Last day it covers (its statement date). + pub period_end: NaiveDate, + /// Its opening balance, in the account's normal direction. + pub opening_balance: Decimal, + /// Its closing balance, in the account's normal direction. + pub closing_balance: Decimal, +} + +/// A completed reconciliation. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Reconciliation { + /// Its id. + pub id: u64, + /// The account reconciled. + pub account_id: AccountId, + /// The statement date it reconciles through. + pub statement_date: NaiveDate, + /// The statement's closing balance. + pub statement_balance: Decimal, + /// The statement's attachment. + pub attachment_id: u64, + /// The lines it found cleared, (entry, line index). + pub cleared: Vec<(EntryId, usize)>, + /// Why it was voided, if it was. + pub voided: Option, +} + /// Attachments in the projection, optionally only one entry's, by id. pub(crate) fn query_attachments( conn: &Connection, @@ -4530,7 +5341,11 @@ pub(crate) fn query_attachments( JOIN blobs b ON b.sha256 = a.blob_sha", ); if entry_id.is_some() { - sql.push_str(" WHERE a.entry_id = ?"); + // Filed on the entry, or linked to it. + sql.push_str( + " WHERE a.entry_id = ?1 OR a.id IN \ + (SELECT attachment_id FROM attachment_links WHERE subject_kind = 'entry' AND subject_id = ?1)", + ); } sql.push_str(" ORDER BY a.id"); let mut stmt = conn.prepare(&sql)?; diff --git a/crates/ledger-store/src/viewer.rs b/crates/ledger-store/src/viewer.rs index 42a7a9d..22b9035 100644 --- a/crates/ledger-store/src/viewer.rs +++ b/crates/ledger-store/src/viewer.rs @@ -16,7 +16,7 @@ use std::path::{Path, PathBuf}; #[serde(tag = "show", rename_all = "snake_case")] pub enum ViewerTarget { /// A top-level page: overview, cash, profit, transactions, reports, - /// accounts, assets, documents, company, journal. + /// accounts, assets, parties, documents, company, journal. Page { /// Page name. page: String, @@ -62,6 +62,12 @@ pub enum ViewerTarget { /// Asset id. asset_id: u64, }, + /// One party: who they are, their roles, and everything the company + /// did with them. + Party { + /// Party id. + party_id: u64, + }, /// The transactions list, searched. Search { /// Search text: description, account, vendor, amount. diff --git a/crates/ledger-store/tests/asset_tags.rs b/crates/ledger-store/tests/asset_tags.rs index 929b99c..b4e8bef 100644 --- a/crates/ledger-store/tests/asset_tags.rs +++ b/crates/ledger-store/tests/asset_tags.rs @@ -669,9 +669,37 @@ fn tax_basis_replays_on_reopen() { } } +/// Downgrade the database to schema v10 (none of v11's attachment links, +/// statements, reconciliations, or payroll_lines.employee_id). +fn downgrade_db_to_v10(dir: &Path) { + let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap(); + conn.execute_batch( + "DROP TABLE attachment_links; + DROP TABLE statements; + DROP TABLE reconciliations; + DROP TABLE reconciliation_lines; + ALTER TABLE payroll_lines DROP COLUMN employee_id; + UPDATE meta SET value = '10' WHERE key = 'schema_version';", + ) + .unwrap(); +} + +/// An existing v10 database migrates on open and verifies. +#[test] +fn a_v10_database_migrates_and_verifies() { + let (dir, mut s, _, x, _) = fixture("v10"); + s.set_asset_tag(x, "WL-0001").unwrap(); + drop(s); + downgrade_db_to_v10(&dir); + let s = Store::open(&dir).expect("a v10 database opens"); + s.verify().unwrap(); + assert_eq!(tag_of(&s, x).as_deref(), Some("WL-0001")); +} + /// Downgrade the database to schema v9 (no assets.tax_rule / /// assets.contributor_basis), as an older wanderbooks left it. fn downgrade_db_to_v9(dir: &Path) { + downgrade_db_to_v10(dir); let conn = rusqlite::Connection::open(dir.join(DB_FILE)).unwrap(); conn.execute_batch( "ALTER TABLE assets DROP COLUMN tax_rule; diff --git a/crates/ledger-store/tests/attachments.rs b/crates/ledger-store/tests/attachments.rs new file mode 100644 index 0000000..e825723 --- /dev/null +++ b/crates/ledger-store/tests/attachments.rs @@ -0,0 +1,1275 @@ +//! Spec-derived tests for attachment links, statements, and +//! reconciliations in the store. +//! +//! Oracles: the doc comments on `Store::link_attachment`, +//! `unlink_attachment`, `attachment_links`, `attachments_for`, +//! `record_statement`, `statement`, `statements`, `complete_reconciliation`, +//! `void_reconciliation`, `reconciliations`, `withdraw_attachment`; the +//! `Subject`, `Statement`, `Reconciliation` types; the "Attachments" and +//! "Reconciliations" sections of docs/parties-and-attachments.md; +//! `Store::verify` (a fresh replay equals the live state); and the store +//! errors (UnknownSubject, AlreadyLinked, NotLinkedTo, InvalidStatement, +//! NotReconciled{difference}, ReconciliationRefused). Expected values are +//! worked by hand from those texts, not from the method bodies. + +use chrono::NaiveDate; +use ledger_core::arap::DocumentLine; +use ledger_core::{AccountId, AccountType, Currency, EntryId, Money, NewAsset, Side}; +use ledger_store::journal::read_and_verify_journal; +use ledger_store::store::{Reconciliation, Statement, Subject}; +use ledger_store::{Attachment, DB_FILE, JOURNAL_FILE, Store, StoreError}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +// ---------------------------------------------------------------- helpers + +fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wb-attach-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn dec(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(dec(s), Currency::USD) +} + +fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// The journal's (type, payload) pairs, verified. +fn journal(dir: &Path) -> Vec<(String, Value)> { + read_and_verify_journal(&dir.join(JOURNAL_FILE)) + .unwrap() + .into_iter() + .map(|l| (l.line_type, l.payload)) + .collect() +} + +fn journal_bytes(dir: &Path) -> Vec { + std::fs::read(dir.join(JOURNAL_FILE)).unwrap() +} + +/// Run `f`; it must journal exactly the lines of types `tys`, in order. +/// Returns the payloads. +fn events( + s: &mut Store, + dir: &Path, + tys: &[&str], + f: impl FnOnce(&mut Store) -> T, +) -> (T, Vec) { + let seq = s.journal_seq(); + let len = journal(dir).len(); + let out = f(s); + assert_eq!( + s.journal_seq(), + seq + tys.len() as u64, + "{tys:?}: line count" + ); + let j = journal(dir); + assert_eq!(j.len(), len + tys.len(), "{tys:?}: lines on disk"); + let new = &j[len..]; + let types: Vec<&str> = new.iter().map(|(t, _)| t.as_str()).collect(); + assert_eq!(types, tys); + (out, new.iter().map(|(_, p)| p.clone()).collect()) +} + +/// Run `f`; it must journal exactly one line, of type `ty`. Returns that +/// line's payload. +fn one_event( + s: &mut Store, + dir: &Path, + ty: &str, + f: impl FnOnce(&mut Store) -> T, +) -> (T, Value) { + let (out, mut ps) = events(s, dir, &[ty], f); + (out, ps.remove(0)) +} + +/// Run `f`; it must fail and journal nothing. Returns the error. +fn refused_nothing_journaled( + s: &mut Store, + dir: &Path, + what: &str, + f: impl FnOnce(&mut Store) -> Result, +) -> StoreError { + let seq = s.journal_seq(); + let bytes = journal_bytes(dir); + let before = s.ledger().clone(); + let r = f(s); + assert_eq!(s.journal_seq(), seq, "{what}: journaled"); + assert_eq!(journal_bytes(dir), bytes, "{what}: journal changed"); + assert!(*s.ledger() == before, "{what}: ledger changed"); + match r { + Ok(v) => panic!("{what}: expected refusal, got {v:?}"), + Err(e) => e, + } +} + +fn delete_db(dir: &Path) { + for suffix in ["", "-wal", "-shm", "-journal"] { + let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}"))); + } +} + +fn ids(v: &[Attachment]) -> Vec { + let mut v: Vec = v.iter().map(|a| a.id).collect(); + v.sort(); + v +} + +fn sorted(v: &[T]) -> Vec { + let mut v = v.to_vec(); + v.sort(); + v +} + +fn doc(s: &mut Store, name: &str) -> u64 { + s.file_document( + "accounting", + name, + "application/pdf", + format!("%PDF {name}").as_bytes(), + Some(name), + ) + .unwrap() + .0 +} + +struct Fx { + cash: AccountId, + capital: AccountId, + expense: AccountId, + card: AccountId, + revenue: AccountId, + /// 2026-01-01 Dr cash 500.00 (line 0) / Cr capital 500.00 (line 1). + e1: EntryId, + /// 2026-01-15 Dr expense 120.00 (line 0) / Cr cash 120.00 (line 1). + e2: EntryId, + /// 2026-02-03 Dr expense 30.00 (line 0) / Cr cash 30.00 (line 1). + e3: EntryId, + /// 2026-01-10 Dr expense 75.00 (line 0) / Cr card 75.00 (line 1). + e4: EntryId, +} + +fn post( + s: &mut Store, + d: NaiveDate, + desc: &str, + dr: AccountId, + cr: AccountId, + amt: &str, +) -> EntryId { + let e = s.create_entry(d, desc).unwrap(); + s.add_line(e, dr, Side::Debit, usd(amt)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(amt)).unwrap(); + s.post_entry(e).unwrap(); + e +} + +fn fixture(dir: &Path) -> (Store, Fx) { + let mut s = Store::create(dir, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let card = s + .add_account("2050", "Card", AccountType::Liability, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Owner Capital", AccountType::Equity, None, false) + .unwrap(); + let revenue = s + .add_account("4000", "App Revenue", AccountType::Revenue, None, false) + .unwrap(); + let expense = s + .add_account("6000", "Software", AccountType::Expense, None, false) + .unwrap(); + let e1 = post(&mut s, date(2026, 1, 1), "Opening", cash, capital, "500.00"); + let e2 = post(&mut s, date(2026, 1, 15), "Tool", expense, cash, "120.00"); + let e3 = post(&mut s, date(2026, 2, 3), "Tool 2", expense, cash, "30.00"); + let e4 = post( + &mut s, + date(2026, 1, 10), + "Card buy", + expense, + card, + "75.00", + ); + ( + s, + Fx { + cash, + capital, + expense, + card, + revenue, + e1, + e2, + e3, + e4, + }, + ) +} + +fn stmt( + attachment_id: u64, + account_id: AccountId, + start: NaiveDate, + end: NaiveDate, + opening: &str, + closing: &str, +) -> Statement { + Statement { + attachment_id, + account_id, + period_start: start, + period_end: end, + opening_balance: dec(opening), + closing_balance: dec(closing), + } +} + +/// January's cash statement: 0.00 -> 380.00 (500.00 in, 120.00 out). +fn jan_cash(s: &mut Store, fx: &Fx, name: &str) -> u64 { + let a = doc(s, name); + s.record_statement(stmt( + a, + fx.cash, + date(2026, 1, 1), + date(2026, 1, 31), + "0.00", + "380.00", + )) + .unwrap(); + a +} + +fn clear_jan(s: &mut Store, fx: &Fx) { + s.mark_cleared(fx.e1, 0, true).unwrap(); + s.mark_cleared(fx.e2, 1, true).unwrap(); +} + +fn subject_json(subject: Subject) -> Value { + match subject { + Subject::Company => json!({"kind": "company"}), + s => json!({"kind": s.kind(), "id": s.id()}), + } +} + +/// One of every subject kind, created in the fixture's store. +fn every_subject(s: &mut Store, fx: &Fx) -> Vec { + let cost = s + .add_account("1500", "Equipment", AccountType::Asset, None, false) + .unwrap(); + let accum = s + .add_account("1501", "Accum Depr", AccountType::Asset, None, false) + .unwrap(); + let depr = s + .add_account("6400", "Depreciation", AccountType::Expense, None, false) + .unwrap(); + let prepaid = s + .add_account("1300", "Prepaid", AccountType::Asset, None, false) + .unwrap(); + let asset = s + .register_asset(NewAsset { + description: "Laptop".into(), + purchase_date: date(2026, 1, 5), + cost: usd("3000.00"), + salvage_value: usd("0"), + useful_life_months: 36, + cost_account: cost, + accum_depr_account: accum, + expense_account: depr, + }) + .unwrap(); + let party = s.add_party("Dev Shop", &[]).unwrap(); + let cust = s.add_customer("Acme").unwrap(); + let invoice = s + .create_invoice( + cust, + date(2026, 3, 1), + date(2026, 3, 31), + vec![DocumentLine { + description: "App".into(), + amount: dec("100.00"), + }], + ) + .unwrap(); + let vend = s.add_vendor("Dana").unwrap(); + let bill = s + .create_bill( + vend, + date(2026, 3, 1), + date(2026, 3, 31), + vec![DocumentLine { + description: "Design".into(), + amount: dec("200.00"), + }], + ) + .unwrap(); + let period = s + .create_period("2026-01", date(2026, 1, 1), date(2026, 1, 31)) + .unwrap(); + let run = s + .run_payroll( + date(2026, 3, 27), + vec![ledger_core::PayrollLine { + employee_name: "Pat".into(), + employee: None, + gross_pay: dec("1000.00"), + federal_withholding: dec("100.00"), + state_withholding: dec("0"), + ss_employee: dec("62.00"), + medicare_employee: dec("14.50"), + ss_employer: dec("62.00"), + medicare_employer: dec("14.50"), + other_deductions: dec("0"), + }], + fx.cash, + ) + .unwrap(); + let schedule = s + .register_schedule(ledger_core::NewSchedule { + description: "Insurance".into(), + kind: ledger_core::ScheduleKind::PrepaidExpense, + total: usd("1200.00"), + start: date(2026, 1, 1), + months: 12, + balance_account: prepaid, + pl_account: fx.expense, + }) + .unwrap(); + clear_jan(s, fx); + let st = jan_cash(s, fx, "subject-stmt.pdf"); + let rec = s.complete_reconciliation(st).unwrap(); + vec![ + Subject::Entry(fx.e2), + Subject::Account(fx.revenue), + Subject::Asset(asset), + Subject::Party(party), + Subject::Invoice(invoice), + Subject::Bill(bill), + Subject::Period(period), + Subject::PayrollRun(run), + Subject::Schedule(schedule), + Subject::Reconciliation(rec.id), + Subject::Company, + ] +} + +// ---------------------------------------------------------------- linking + +/// Oracle: link_attachment ("another entry, an account, an asset, a party, +/// an invoice, a bill, a period, a payroll run, a schedule, a +/// reconciliation, or the company"; journaled as `attachment.linked +/// {attachment_id, subject}`); Subject's serde (tag "kind", content "id", +/// snake_case); attachment_links; attachments_for. +#[test] +fn links_to_every_subject_kind() { + let dir = test_dir("every-kind"); + let (mut s, fx) = fixture(&dir); + let subjects = every_subject(&mut s, &fx); + let a = doc(&mut s, "contract.pdf"); + for &subject in &subjects { + let (r, p) = one_event(&mut s, &dir, "attachment.linked", |s| { + s.link_attachment(a, subject) + }); + r.unwrap(); + assert_eq!(p["attachment_id"], json!(a), "{subject:?}"); + assert_eq!(p["subject"], subject_json(subject), "{subject:?}"); + assert!( + ids(&s.attachments_for(subject).unwrap()).contains(&a), + "attachments_for({subject:?}) lacks the file" + ); + } + // Filed by file_document: no filing entry, so just the links. + assert_eq!(sorted(&s.attachment_links(a).unwrap()), sorted(&subjects)); + s.verify().unwrap(); +} + +/// Oracle: link_attachment SCENARIO 1 ("the subject doesn't exist: SHOULD +/// refuse"), INVARIANT 3 (a refusal journals nothing); UnknownSubject. +#[test] +fn refuses_unknown_subjects() { + let dir = test_dir("unknown-subject"); + let (mut s, _) = fixture(&dir); + let a = doc(&mut s, "x.pdf"); + let missing = 9_999; + for subject in [ + Subject::Entry(missing), + Subject::Account(missing), + Subject::Asset(missing), + Subject::Party(missing), + Subject::Invoice(missing), + Subject::Bill(missing), + Subject::Period(missing), + Subject::PayrollRun(missing), + Subject::Schedule(missing), + Subject::Reconciliation(missing), + ] { + let e = refused_nothing_journaled(&mut s, &dir, &format!("{subject:?}"), |s| { + s.link_attachment(a, subject) + }); + assert!( + matches!(e, StoreError::UnknownSubject(_)), + "{subject:?}: {e:?}" + ); + } + assert!(s.attachment_links(a).unwrap().is_empty()); +} + +/// Oracle: link_attachment SCENARIO 1 (unknown or withdrawn attachment). +#[test] +fn refuses_unknown_or_withdrawn_attachment() { + let dir = test_dir("link-bad-attachment"); + let (mut s, _) = fixture(&dir); + let e = refused_nothing_journaled(&mut s, &dir, "unknown", |s| { + s.link_attachment(9_999, Subject::Company) + }); + assert!(matches!(e, StoreError::UnknownAttachment(9_999)), "{e:?}"); + let a = doc(&mut s, "gone.pdf"); + s.withdraw_attachment(a, "wrong file").unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| { + s.link_attachment(a, Subject::Company) + }); + assert!( + matches!(e, StoreError::AttachmentWithdrawn(x) if x == a), + "{e:?}" + ); +} + +/// Oracle: link_attachment SCENARIO 2 (already linked to that subject, or +/// filed on that entry: refuse); AlreadyLinked(id, subject words); +/// attachment_links ("its filing entry, if any, first"). +#[test] +fn refuses_duplicate_links_and_the_filing_entry() { + let dir = test_dir("dup-link"); + let (mut s, fx) = fixture(&dir); + let a = doc(&mut s, "agreement.pdf"); + s.link_attachment(a, Subject::Account(fx.cash)).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "dup account", |s| { + s.link_attachment(a, Subject::Account(fx.cash)) + }); + assert!( + matches!(&e, StoreError::AlreadyLinked(x, w) if *x == a && *w == Subject::Account(fx.cash).describe()), + "{e:?}" + ); + s.link_attachment(a, Subject::Company).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "dup company", |s| { + s.link_attachment(a, Subject::Company) + }); + assert!(matches!(e, StoreError::AlreadyLinked(..)), "{e:?}"); + + let (r, _, _) = s + .attach_receipt(Some(fx.e1), "r.pdf", "application/pdf", b"%PDF r", None) + .unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "filing entry", |s| { + s.link_attachment(r, Subject::Entry(fx.e1)) + }); + assert!( + matches!(e, StoreError::AlreadyLinked(x, _) if x == r), + "{e:?}" + ); + + // The same receipt is evidence for a second entry, without refiling. + s.link_attachment(r, Subject::Entry(fx.e2)).unwrap(); + s.link_attachment(r, Subject::Account(fx.cash)).unwrap(); + let links = s.attachment_links(r).unwrap(); + assert_eq!(links[0], Subject::Entry(fx.e1), "filing entry first"); + assert_eq!( + sorted(&links[1..]), + sorted(&[Subject::Entry(fx.e2), Subject::Account(fx.cash)]) + ); + assert_eq!( + ids(&s.attachments_for(Subject::Entry(fx.e1)).unwrap()), + vec![r] + ); + assert_eq!( + ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()), + vec![r] + ); + s.verify().unwrap(); +} + +/// Oracle: link_attachment INVARIANT 2 ("an entry's receipts are the files +/// filed on it and the files linked to it"); attachments_for ("for an +/// entry: filed on it or linked to it"); docs "A receipt is an attachment +/// linked to an entry". The store is policy-free, so the receipt rule's +/// count is what attachments_for(Entry) returns. +#[test] +fn a_document_linked_to_an_entry_is_its_receipt() { + let dir = test_dir("doc-as-receipt"); + let (mut s, fx) = fixture(&dir); + assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty()); + let a = doc(&mut s, "invoice.pdf"); + s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); + assert_eq!( + ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()), + vec![a] + ); + let (r, _, _) = s + .attach_receipt( + Some(fx.e2), + "paid.pdf", + "application/pdf", + b"%PDF paid", + None, + ) + .unwrap(); + assert_eq!( + ids(&s.attachments_for(Subject::Entry(fx.e2)).unwrap()), + sorted(&[a, r]) + ); + // Other entries are untouched. + assert!(s.attachments_for(Subject::Entry(fx.e3)).unwrap().is_empty()); + + // A draft supported only by a linked document. + let d = s.create_entry(date(2026, 1, 20), "Draft").unwrap(); + s.add_line(d, fx.expense, Side::Debit, usd("10.00")) + .unwrap(); + s.add_line(d, fx.cash, Side::Credit, usd("10.00")).unwrap(); + let b = doc(&mut s, "draft-support.pdf"); + s.link_attachment(b, Subject::Entry(d)).unwrap(); + assert_eq!(ids(&s.attachments_for(Subject::Entry(d)).unwrap()), vec![b]); + s.post_entry(d).unwrap(); + assert_eq!(ids(&s.attachments_for(Subject::Entry(d)).unwrap()), vec![b]); + s.verify().unwrap(); +} + +// ---------------------------------------------------------------- unlinking + +/// Oracle: unlink_attachment SCENARIOS 1–2 (blank reason or not linked: +/// refuse; otherwise `attachment.unlinked {attachment_id, subject, +/// reason}`); ReasonRequired; NotLinkedTo; "the file stays filed". +#[test] +fn unlink_needs_a_reason_and_a_link() { + let dir = test_dir("unlink"); + let (mut s, fx) = fixture(&dir); + let a = doc(&mut s, "misfiled.pdf"); + s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); + for blank in ["", " "] { + let e = refused_nothing_journaled(&mut s, &dir, &format!("blank {blank:?}"), |s| { + s.unlink_attachment(a, Subject::Entry(fx.e2), blank) + }); + assert!(matches!(e, StoreError::ReasonRequired), "{e:?}"); + } + let e = refused_nothing_journaled(&mut s, &dir, "not linked", |s| { + s.unlink_attachment(a, Subject::Entry(fx.e3), "wrong entry") + }); + assert!( + matches!(&e, StoreError::NotLinkedTo(x, w) if *x == a && *w == Subject::Entry(fx.e3).describe()), + "{e:?}" + ); + let e = refused_nothing_journaled(&mut s, &dir, "unknown attachment", |s| { + s.unlink_attachment(9_999, Subject::Entry(fx.e2), "why") + }); + let _ = e; + + let (r, p) = one_event(&mut s, &dir, "attachment.unlinked", |s| { + s.unlink_attachment(a, Subject::Entry(fx.e2), "belongs to e3") + }); + r.unwrap(); + assert_eq!(p["attachment_id"], json!(a)); + assert_eq!(p["subject"], subject_json(Subject::Entry(fx.e2))); + assert_eq!(p["reason"], json!("belongs to e3")); + assert!(s.attachment_links(a).unwrap().is_empty()); + assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty()); + // Still filed, and it can go where it belongs. + assert!(ids(&s.list_attachments(None).unwrap()).contains(&a)); + s.link_attachment(a, Subject::Entry(fx.e3)).unwrap(); + assert_eq!(s.attachment_links(a).unwrap(), vec![Subject::Entry(fx.e3)]); + // Unlinking twice: no longer linked. + s.unlink_attachment(a, Subject::Entry(fx.e3), "done") + .unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "twice", |s| { + s.unlink_attachment(a, Subject::Entry(fx.e3), "again") + }); + assert!(matches!(e, StoreError::NotLinkedTo(..)), "{e:?}"); + s.verify().unwrap(); +} + +/// Oracle: unlink_attachment ("a receipt filed on an entry comes off with +/// `unlink_receipt` instead"; "isn't linked to the subject (through this +/// table): SHOULD refuse"). +#[test] +fn unlink_attachment_does_not_take_a_filed_receipt_off() { + let dir = test_dir("unlink-filed"); + let (mut s, fx) = fixture(&dir); + let (r, _, _) = s + .attach_receipt(Some(fx.e1), "r.pdf", "application/pdf", b"%PDF r1", None) + .unwrap(); + refused_nothing_journaled(&mut s, &dir, "filed receipt", |s| { + s.unlink_attachment(r, Subject::Entry(fx.e1), "wrong") + }); + assert_eq!( + ids(&s.attachments_for(Subject::Entry(fx.e1)).unwrap()), + vec![r] + ); +} + +// ---------------------------------------------------------------- withdrawal + +/// Oracle: withdraw_attachment SCENARIO 2 ("IF it is linked to any subject +/// (link_attachment): SHOULD refuse (AlreadyLinked, naming the first +/// subject)"). Linked through link_attachment rather than filed on the entry. +#[test] +fn withdraw_while_linked_to_an_entry_is_already_linked() { + let dir = test_dir("withdraw-variant"); + let (mut s, fx) = fixture(&dir); + let a = doc(&mut s, "dup.pdf"); + s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "linked", |s| { + s.withdraw_attachment(a, "duplicate") + }); + assert!( + matches!(&e, StoreError::AlreadyLinked(x, sub) if *x == a && *sub == Subject::Entry(fx.e2).describe()), + "{e:?}" + ); +} + +/// Oracle: withdraw_attachment SCENARIO 2 ("linked to any subject: SHOULD +/// refuse"), SCENARIO 3 (blank reason), SCENARIO 4, and +/// "can never be linked again"; attachments_for ("not withdrawn"). +#[test] +fn withdraw_refuses_while_linked_to_an_entry_and_works_after_unlink() { + let dir = test_dir("withdraw"); + let (mut s, fx) = fixture(&dir); + let a = doc(&mut s, "dup.pdf"); + s.link_attachment(a, Subject::Entry(fx.e2)).unwrap(); + refused_nothing_journaled(&mut s, &dir, "linked", |s| { + s.withdraw_attachment(a, "duplicate") + }); + s.unlink_attachment(a, Subject::Entry(fx.e2), "duplicate") + .unwrap(); + refused_nothing_journaled(&mut s, &dir, "blank reason", |s| { + s.withdraw_attachment(a, " ") + }); + let (r, p) = one_event(&mut s, &dir, "attachment.withdrawn", |s| { + s.withdraw_attachment(a, "duplicate") + }); + r.unwrap(); + assert_eq!(p["attachment_id"], json!(a)); + let listed = s + .list_attachments(None) + .unwrap() + .into_iter() + .find(|x| x.id == a) + .unwrap(); + assert_eq!(listed.withdrawn_reason.as_deref(), Some("duplicate")); + assert!(s.attachments_for(Subject::Entry(fx.e2)).unwrap().is_empty()); + let e = refused_nothing_journaled(&mut s, &dir, "relink", |s| { + s.link_attachment(a, Subject::Entry(fx.e2)) + }); + assert!( + matches!(e, StoreError::AttachmentWithdrawn(x) if x == a), + "{e:?}" + ); + refused_nothing_journaled(&mut s, &dir, "withdraw twice", |s| { + s.withdraw_attachment(a, "again") + }); + s.verify().unwrap(); +} + +// ---------------------------------------------------------------- statements + +/// Oracle: record_statement INVARIANT 1 (`statement.recorded +/// {attachment_id, account_id, period_start, period_end, opening_balance, +/// closing_balance}` then `attachment.linked` to the account); statement; +/// statements ("optionally one account's, by period end"). +#[test] +fn record_statement_journals_and_links_to_the_account() { + let dir = test_dir("statement"); + let (mut s, fx) = fixture(&dir); + let a = doc(&mut s, "jan.pdf"); + let st = stmt( + a, + fx.cash, + date(2026, 1, 1), + date(2026, 1, 31), + "0.00", + "380.00", + ); + let (r, ps) = events( + &mut s, + &dir, + &["statement.recorded", "attachment.linked"], + |s| s.record_statement(st.clone()), + ); + r.unwrap(); + let p = &ps[0]; + assert_eq!(p["attachment_id"], json!(a)); + assert_eq!(p["account_id"], json!(fx.cash)); + assert_eq!(p["period_start"], json!("2026-01-01")); + assert_eq!(p["period_end"], json!("2026-01-31")); + let money = |v: &Value| dec(v.as_str().unwrap_or(&v.to_string())); + assert_eq!(money(&p["opening_balance"]), dec("0")); + assert_eq!(money(&p["closing_balance"]), dec("380.00")); + assert_eq!(ps[1]["attachment_id"], json!(a)); + assert_eq!(ps[1]["subject"], subject_json(Subject::Account(fx.cash))); + + assert_eq!(s.statement(a).unwrap(), Some(st.clone())); + assert_eq!( + s.attachment_links(a).unwrap(), + vec![Subject::Account(fx.cash)] + ); + assert!(ids(&s.attachments_for(Subject::Account(fx.cash)).unwrap()).contains(&a)); + + // A plain document records no statement. + let plain = doc(&mut s, "plain.pdf"); + assert_eq!(s.statement(plain).unwrap(), None); + + // A card (liability) statement, and February's cash one recorded + // before January's in time order of calls but sorted by period end. + let feb = doc(&mut s, "feb.pdf"); + let st_feb = stmt( + feb, + fx.cash, + date(2026, 2, 1), + date(2026, 2, 28), + "380.00", + "350.00", + ); + let c = doc(&mut s, "card.pdf"); + let st_card = stmt( + c, + fx.card, + date(2026, 1, 1), + date(2026, 1, 31), + "0.00", + "75.00", + ); + s.record_statement(st_card.clone()).unwrap(); + s.record_statement(st_feb.clone()).unwrap(); + assert_eq!( + s.statements(Some(fx.cash)).unwrap(), + vec![st.clone(), st_feb.clone()] + ); + assert_eq!(s.statements(Some(fx.card)).unwrap(), vec![st_card.clone()]); + assert!(s.statements(Some(fx.capital)).unwrap().is_empty()); + let all = s.statements(None).unwrap(); + assert_eq!(all.len(), 3); + assert_eq!(all.last().unwrap(), &st_feb, "by period end"); + s.verify().unwrap(); +} + +/// Oracle: record_statement SCENARIO 1 (unknown or withdrawn attachment, +/// already records a statement, unknown account or not an asset or +/// liability, period backwards, a balance with more than two decimal +/// places: refuse, journaling nothing); InvalidStatement. +#[test] +fn record_statement_refusals_journal_nothing() { + let dir = test_dir("statement-refusals"); + let (mut s, fx) = fixture(&dir); + let a = doc(&mut s, "stmt.pdf"); + let jan = |acct| { + stmt( + a, + acct, + date(2026, 1, 1), + date(2026, 1, 31), + "0.00", + "380.00", + ) + }; + + for acct in [fx.capital, fx.revenue, fx.expense] { + let e = refused_nothing_journaled(&mut s, &dir, &format!("account {acct}"), |s| { + s.record_statement(jan(acct)) + }); + assert!( + matches!(e, StoreError::InvalidStatement(_)), + "{acct}: {e:?}" + ); + } + refused_nothing_journaled(&mut s, &dir, "unknown account", |s| { + s.record_statement(jan(9_999)) + }); + let e = refused_nothing_journaled(&mut s, &dir, "backwards", |s| { + s.record_statement(stmt( + a, + fx.cash, + date(2026, 1, 31), + date(2026, 1, 1), + "0", + "380.00", + )) + }); + assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}"); + for (open, close) in [("0.001", "380.00"), ("0.00", "380.005")] { + let e = refused_nothing_journaled(&mut s, &dir, &format!("sub-cent {open} {close}"), |s| { + s.record_statement(stmt( + a, + fx.cash, + date(2026, 1, 1), + date(2026, 1, 31), + open, + close, + )) + }); + assert!(matches!(e, StoreError::InvalidStatement(_)), "{e:?}"); + } + let e = refused_nothing_journaled(&mut s, &dir, "unknown attachment", |s| { + s.record_statement(stmt( + 9_999, + fx.cash, + date(2026, 1, 1), + date(2026, 1, 31), + "0", + "1", + )) + }); + assert!(matches!(e, StoreError::UnknownAttachment(9_999)), "{e:?}"); + let w = doc(&mut s, "withdrawn.pdf"); + s.withdraw_attachment(w, "wrong month").unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "withdrawn", |s| { + s.record_statement(stmt( + w, + fx.cash, + date(2026, 1, 1), + date(2026, 1, 31), + "0", + "1", + )) + }); + assert!( + matches!(e, StoreError::AttachmentWithdrawn(x) if x == w), + "{e:?}" + ); + + // Nothing was recorded by the refusals. + assert_eq!(s.statement(a).unwrap(), None); + assert!(s.statements(None).unwrap().is_empty()); + + // A one-day period does not run backwards; a liability is allowed. + s.record_statement(stmt( + a, + fx.card, + date(2026, 1, 31), + date(2026, 1, 31), + "0", + "75.00", + )) + .unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "duplicate", |s| { + s.record_statement(jan(fx.cash)) + }); + let _ = e; + assert_eq!(s.statements(None).unwrap().len(), 1); + s.verify().unwrap(); +} + +// ---------------------------------------------------------------- reconciliations + +/// Oracle: complete_reconciliation SCENARIO 2 (NotReconciled naming the +/// difference); NotReconciled's doc ("cleared balance less the statement +/// balance"); Ledger::reconciliation_report (cleared lines dated on or +/// before the statement date). +#[test] +fn refuses_with_the_exact_difference() { + let dir = test_dir("not-reconciled"); + let (mut s, fx) = fixture(&dir); + let a = jan_cash(&mut s, &fx, "jan.pdf"); + // Nothing cleared: 0.00 - 380.00. + let e = refused_nothing_journaled(&mut s, &dir, "none cleared", |s| { + s.complete_reconciliation(a) + }); + assert!( + matches!(e, StoreError::NotReconciled { difference } if difference == dec("-380.00")), + "{e:?}" + ); + // Only the deposit: 500.00 - 380.00. + s.mark_cleared(fx.e1, 0, true).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "deposit only", |s| { + s.complete_reconciliation(a) + }); + assert!( + matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")), + "{e:?}" + ); + // A February line, cleared, is after the statement date and doesn't count. + s.mark_cleared(fx.e3, 1, true).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "feb cleared", |s| { + s.complete_reconciliation(a) + }); + assert!( + matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")), + "{e:?}" + ); + // Clearing the non-cash side of an entry doesn't move the cash balance. + s.mark_cleared(fx.e2, 0, true).unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "expense side cleared", |s| { + s.complete_reconciliation(a) + }); + assert!( + matches!(e, StoreError::NotReconciled { difference } if difference == dec("120.00")), + "{e:?}" + ); + assert!(s.reconciliations(None).unwrap().is_empty()); +} + +/// Oracle: complete_reconciliation SCENARIO 1 (no statement: refuse). +#[test] +fn refuses_without_a_statement() { + let dir = test_dir("no-statement"); + let (mut s, fx) = fixture(&dir); + clear_jan(&mut s, &fx); + let plain = doc(&mut s, "plain.pdf"); + refused_nothing_journaled(&mut s, &dir, "plain doc", |s| { + s.complete_reconciliation(plain) + }); + refused_nothing_journaled(&mut s, &dir, "unknown", |s| { + s.complete_reconciliation(9_999) + }); +} + +/// Oracle: complete_reconciliation INVARIANTS 1, 2, 4 (records the cleared +/// lines on the account dated on or before the statement date, links the +/// statement to it; journaled as `reconciliation.completed`); the docs' +/// payload `{id, account_id, statement_date, statement_balance, cleared, +/// attachment_id}`; Reconciliation; reconciliations. +#[test] +fn completes_when_the_cleared_balance_matches() { + let dir = test_dir("reconciled"); + let (mut s, fx) = fixture(&dir); + let a = jan_cash(&mut s, &fx, "jan.pdf"); + clear_jan(&mut s, &fx); + // Cleared but out of scope: another account's line, and a cash line + // after the statement date. + s.mark_cleared(fx.e1, 1, true).unwrap(); + s.mark_cleared(fx.e3, 1, true).unwrap(); + // 500.00 - 120.00 = 380.00: reconciled. + let seq = s.journal_seq(); + let len = journal(&dir).len(); + let rec = s.complete_reconciliation(a).unwrap(); + let new: Vec<(String, Value)> = journal(&dir)[len..].to_vec(); + assert_eq!(s.journal_seq(), seq + new.len() as u64); + let completed: Vec<&Value> = new + .iter() + .filter(|(t, _)| t == "reconciliation.completed") + .map(|(_, p)| p) + .collect(); + assert_eq!(completed.len(), 1, "one reconciliation.completed: {new:?}"); + for (t, _) in &new { + assert!( + t == "reconciliation.completed" || t == "attachment.linked", + "unexpected line {t}" + ); + } + + assert_eq!(rec.account_id, fx.cash); + assert_eq!(rec.statement_date, date(2026, 1, 31)); + assert_eq!(rec.statement_balance, dec("380.00")); + assert_eq!(rec.attachment_id, a); + assert_eq!(rec.voided, None); + assert_eq!(sorted(&rec.cleared), sorted(&[(fx.e1, 0), (fx.e2, 1)])); + + let p = completed[0]; + assert_eq!(p["id"], json!(rec.id)); + assert_eq!(p["account_id"], json!(fx.cash)); + assert_eq!(p["statement_date"], json!("2026-01-31")); + assert_eq!(p["attachment_id"], json!(a)); + let mut cleared: Vec<(u64, u64)> = p["cleared"] + .as_array() + .unwrap() + .iter() + .map(|x| (x[0].as_u64().unwrap(), x[1].as_u64().unwrap())) + .collect(); + cleared.sort(); + assert_eq!(cleared, vec![(fx.e1, 0), (fx.e2, 1)]); + + let links = s.attachment_links(a).unwrap(); + assert!(links.contains(&Subject::Account(fx.cash)), "{links:?}"); + assert!( + links.contains(&Subject::Reconciliation(rec.id)), + "{links:?}" + ); + assert_eq!( + ids(&s.attachments_for(Subject::Reconciliation(rec.id)).unwrap()), + vec![a] + ); + assert_eq!(s.reconciliations(Some(fx.cash)).unwrap(), vec![rec.clone()]); + assert_eq!(s.reconciliations(None).unwrap(), vec![rec.clone()]); + assert!(s.reconciliations(Some(fx.card)).unwrap().is_empty()); + s.verify().unwrap(); +} + +/// Oracle: Statement ("closing balance, in the account's normal +/// direction"); complete_reconciliation INVARIANT 1. A card (liability) +/// owing 75.00 states 75.00. +#[test] +fn reconciles_a_liability_in_its_normal_direction() { + let dir = test_dir("liability"); + let (mut s, fx) = fixture(&dir); + let c = doc(&mut s, "card.pdf"); + s.record_statement(stmt( + c, + fx.card, + date(2026, 1, 1), + date(2026, 1, 31), + "0.00", + "75.00", + )) + .unwrap(); + s.mark_cleared(fx.e4, 1, true).unwrap(); + let rec = s.complete_reconciliation(c).unwrap(); + assert_eq!(rec.account_id, fx.card); + assert_eq!(rec.cleared, vec![(fx.e4, 1)]); + assert_eq!(rec.statement_balance, dec("75.00")); +} + +/// Oracle: complete_reconciliation INVARIANT 3 / SCENARIO 3 ("a +/// reconciliation not voided is dated on or after it: SHOULD refuse"); +/// ReconciliationRefused; reconciliations ("by date"). +#[test] +fn reconciliations_go_forward() { + let dir = test_dir("forward"); + let (mut s, fx) = fixture(&dir); + clear_jan(&mut s, &fx); + let jan = jan_cash(&mut s, &fx, "jan.pdf"); + let r1 = s.complete_reconciliation(jan).unwrap(); + + // Same date, another copy of the statement. + let jan2 = jan_cash(&mut s, &fx, "jan-again.pdf"); + let e = refused_nothing_journaled(&mut s, &dir, "same date", |s| { + s.complete_reconciliation(jan2) + }); + assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); + // Earlier date: cleared through 01-20 is also 380.00, but it's backwards. + let mid = doc(&mut s, "mid.pdf"); + s.record_statement(stmt( + mid, + fx.cash, + date(2026, 1, 1), + date(2026, 1, 20), + "0", + "380.00", + )) + .unwrap(); + let e = refused_nothing_journaled(&mut s, &dir, "earlier", |s| s.complete_reconciliation(mid)); + assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); + // The same January statement, again. + let e = refused_nothing_journaled(&mut s, &dir, "jan again", |s| { + s.complete_reconciliation(jan) + }); + assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); + + // Another account's sequence is its own. + let c = doc(&mut s, "card.pdf"); + s.record_statement(stmt( + c, + fx.card, + date(2026, 1, 1), + date(2026, 1, 15), + "0", + "75.00", + )) + .unwrap(); + s.mark_cleared(fx.e4, 1, true).unwrap(); + s.complete_reconciliation(c).unwrap(); + + // February goes forward: 380.00 - 30.00 = 350.00. + s.mark_cleared(fx.e3, 1, true).unwrap(); + let feb = doc(&mut s, "feb.pdf"); + s.record_statement(stmt( + feb, + fx.cash, + date(2026, 2, 1), + date(2026, 2, 28), + "380.00", + "350.00", + )) + .unwrap(); + let r2 = s.complete_reconciliation(feb).unwrap(); + assert_ne!(r2.id, r1.id); + assert_eq!( + sorted(&r2.cleared), + sorted(&[(fx.e1, 0), (fx.e2, 1), (fx.e3, 1)]) + ); + let cash_recs = s.reconciliations(Some(fx.cash)).unwrap(); + assert_eq!(cash_recs, vec![r1, r2]); + assert_eq!(s.reconciliations(None).unwrap().len(), 3); + s.verify().unwrap(); +} + +/// Oracle: void_reconciliation ("with the reason (to redo it)"; +/// `reconciliation.voided {id, reason}`); complete_reconciliation +/// INVARIANT 3 (only reconciliations not voided bound the next); +/// Reconciliation.voided. +#[test] +fn void_and_redo() { + let dir = test_dir("void"); + let (mut s, fx) = fixture(&dir); + clear_jan(&mut s, &fx); + let jan = jan_cash(&mut s, &fx, "jan.pdf"); + let r1 = s.complete_reconciliation(jan).unwrap(); + + for blank in ["", " "] { + refused_nothing_journaled(&mut s, &dir, &format!("blank {blank:?}"), |s| { + s.void_reconciliation(r1.id, blank) + }); + } + refused_nothing_journaled(&mut s, &dir, "unknown", |s| { + s.void_reconciliation(r1.id + 1_000, "why") + }); + let (r, p) = one_event(&mut s, &dir, "reconciliation.voided", |s| { + s.void_reconciliation(r1.id, "cleared the wrong line") + }); + r.unwrap(); + assert_eq!(p["id"], json!(r1.id)); + assert_eq!(p["reason"], json!("cleared the wrong line")); + refused_nothing_journaled(&mut s, &dir, "double void", |s| { + s.void_reconciliation(r1.id, "again") + }); + let listed = s.reconciliations(Some(fx.cash)).unwrap(); + assert_eq!(listed.len(), 1); + assert_eq!(listed[0].voided.as_deref(), Some("cleared the wrong line")); + assert_eq!( + Reconciliation { + voided: None, + ..listed[0].clone() + }, + r1, + "voiding changes nothing else" + ); + + // Redo the same statement. + let r2 = s.complete_reconciliation(jan).unwrap(); + assert_ne!(r2.id, r1.id); + assert_eq!(r2.voided, None); + assert_eq!(r2.statement_date, date(2026, 1, 31)); + assert_eq!(sorted(&r2.cleared), sorted(&[(fx.e1, 0), (fx.e2, 1)])); + let mut got: Vec = s + .reconciliations(Some(fx.cash)) + .unwrap() + .iter() + .map(|r| r.id) + .collect(); + got.sort(); + assert_eq!(got, sorted(&[r1.id, r2.id])); + assert!( + s.attachment_links(jan) + .unwrap() + .contains(&Subject::Reconciliation(r2.id)) + ); + // And the redo bounds the next one again. + let e = refused_nothing_journaled(&mut s, &dir, "after redo", |s| { + s.complete_reconciliation(jan) + }); + assert!(matches!(e, StoreError::ReconciliationRefused(_)), "{e:?}"); + s.verify().unwrap(); +} + +// ---------------------------------------------------------------- replay + +#[derive(Debug, PartialEq)] +struct View { + /// Attachment has no PartialEq: compared by its Debug form. + attachments: Vec, + links: Vec<(u64, Vec)>, + for_subjects: Vec<(Subject, Vec)>, + statements: Vec, + statement_of: Vec<(u64, Option)>, + reconciliations: Vec, +} + +fn view(s: &Store, subjects: &[Subject]) -> View { + let attachments = s.list_attachments(None).unwrap(); + View { + links: attachments + .iter() + .map(|a| (a.id, s.attachment_links(a.id).unwrap())) + .collect(), + for_subjects: subjects + .iter() + .map(|&x| (x, ids(&s.attachments_for(x).unwrap()))) + .collect(), + statements: s.statements(None).unwrap(), + statement_of: attachments + .iter() + .map(|a| (a.id, s.statement(a.id).unwrap())) + .collect(), + reconciliations: s.reconciliations(None).unwrap(), + attachments: attachments.iter().map(|a| format!("{a:?}")).collect(), + } +} + +/// Oracle: Store::open (the ledger is the replay of every journal line; +/// a missing database is rebuilt from the journal); Store::verify. +#[test] +fn everything_survives_replay_and_verifies() { + let dir = test_dir("replay"); + let (mut s, fx) = fixture(&dir); + let mut subjects = every_subject(&mut s, &fx); + let a = doc(&mut s, "multi.pdf"); + for &x in &subjects { + s.link_attachment(a, x).unwrap(); + } + s.unlink_attachment(a, Subject::Company, "not formation") + .unwrap(); + let (r, _, _) = s + .attach_receipt(Some(fx.e3), "r.pdf", "application/pdf", b"%PDF r3", None) + .unwrap(); + s.link_attachment(r, Subject::Entry(fx.e2)).unwrap(); + let w = doc(&mut s, "gone.pdf"); + s.link_attachment(w, Subject::Entry(fx.e1)).unwrap(); + s.unlink_attachment(w, Subject::Entry(fx.e1), "wrong") + .unwrap(); + s.withdraw_attachment(w, "wrong").unwrap(); + // every_subject reconciled January; void it, redo, then February. + let first = s.reconciliations(Some(fx.cash)).unwrap()[0].clone(); + s.void_reconciliation(first.id, "redo").unwrap(); + let redo = s.complete_reconciliation(first.attachment_id).unwrap(); + s.mark_cleared(fx.e3, 1, true).unwrap(); + let feb = doc(&mut s, "feb.pdf"); + s.record_statement(stmt( + feb, + fx.cash, + date(2026, 2, 1), + date(2026, 2, 28), + "380.00", + "350.00", + )) + .unwrap(); + let r_feb = s.complete_reconciliation(feb).unwrap(); + let c = doc(&mut s, "card.pdf"); + s.record_statement(stmt( + c, + fx.card, + date(2026, 1, 1), + date(2026, 1, 31), + "0", + "75.00", + )) + .unwrap(); + subjects.extend([ + Subject::Entry(fx.e1), + Subject::Entry(fx.e3), + Subject::Account(fx.cash), + Subject::Account(fx.card), + Subject::Reconciliation(first.id), + Subject::Reconciliation(redo.id), + Subject::Reconciliation(r_feb.id), + ]); + s.verify().unwrap(); + let before = view(&s, &subjects); + assert_eq!(before.statements.len(), 3); + assert_eq!(before.reconciliations.len(), 3); + drop(s); + + let s = Store::open(&dir).unwrap(); + assert_eq!(view(&s, &subjects), before, "reopen"); + s.verify().unwrap(); + drop(s); + + delete_db(&dir); + let s = Store::open(&dir).unwrap(); + assert_eq!(view(&s, &subjects), before, "full replay"); + s.verify().unwrap(); +} diff --git a/crates/ledger-store/tests/parties.rs b/crates/ledger-store/tests/parties.rs new file mode 100644 index 0000000..b127e3e --- /dev/null +++ b/crates/ledger-store/tests/parties.rs @@ -0,0 +1,754 @@ +//! Spec-derived tests for the party directory in the store. +//! +//! Oracles: the `Store` party methods' doc comments (each "journaled as" +//! its event: `party.added {id, name, roles}`, `party.updated {id, +//! change}`, `party.role_set {id, role, on}`, `party.w9_set {id, facts}`, +//! `party.w4_set {id, facts}`, `entry.party_set {entry_id, party_id}`; +//! `payroll.run` lines carry `employee`), the `ledger_core::parties` spec +//! (INVARIANTS 1–4), `Store::verify` (a fresh replay equals the live +//! ledger), and `docs/parties-and-attachments.md` "Migration" (old journals +//! replay unchanged; vendors and customers get their parties by replay). +//! Expected values are worked by hand from those texts. + +use chrono::NaiveDate; +use ledger_core::{ + AccountId, AccountType, Currency, EntryId, Ledger, Money, Party, PartyField, PartyId, + PayrollLine, PayrollRun, Role, Side, W4Facts, W9Facts, +}; +use ledger_store::journal::read_and_verify_journal; +use ledger_store::{DB_FILE, JOURNAL_FILE, JournalWriter, Store}; +use rust_decimal::Decimal; +use serde_json::{Value, json}; +use std::path::{Path, PathBuf}; +use std::str::FromStr; + +// ---------------------------------------------------------------- helpers + +fn test_dir(name: &str) -> PathBuf { + let dir = std::env::temp_dir().join(format!("wb-parties-{name}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +fn dec(s: &str) -> Decimal { + Decimal::from_str(s).unwrap() +} + +fn usd(s: &str) -> Money { + Money::new(dec(s), Currency::USD) +} + +fn date(y: i32, m: u32, d: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, d).unwrap() +} + +/// The journal's (type, payload) pairs, verified. +fn journal(dir: &Path) -> Vec<(String, Value)> { + read_and_verify_journal(&dir.join(JOURNAL_FILE)) + .unwrap() + .into_iter() + .map(|l| (l.line_type, l.payload)) + .collect() +} + +fn journal_bytes(dir: &Path) -> Vec { + std::fs::read(dir.join(JOURNAL_FILE)).unwrap() +} + +/// Run `f`; it must journal exactly one line, of type `ty`. Returns that +/// line's payload. +fn one_event( + s: &mut Store, + dir: &Path, + ty: &str, + f: impl FnOnce(&mut Store) -> T, +) -> (T, Value) { + let seq = s.journal_seq(); + let len = journal(dir).len(); + let out = f(s); + assert_eq!(s.journal_seq(), seq + 1, "{ty}: exactly one line"); + let j = journal(dir); + assert_eq!(j.len(), len + 1, "{ty}: exactly one line on disk"); + let (t, p) = j.last().unwrap().clone(); + assert_eq!(t, ty); + (out, p) +} + +/// Run `f`; it must fail and journal nothing. +fn refused_nothing_journaled( + s: &mut Store, + dir: &Path, + what: &str, + f: impl FnOnce(&mut Store) -> Result, +) { + let seq = s.journal_seq(); + let bytes = journal_bytes(dir); + let before = s.ledger().clone(); + let r = f(s); + assert!(r.is_err(), "{what}: expected refusal, got {:?}", r.ok()); + assert_eq!(s.journal_seq(), seq, "{what}: journaled"); + assert_eq!(journal_bytes(dir), bytes, "{what}: journal changed"); + assert!(*s.ledger() == before, "{what}: ledger changed"); +} + +fn w9(last4: &str) -> W9Facts { + W9Facts { + received: date(2026, 3, 1), + entity_type: "individual/sole proprietor".to_string(), + exempt_payee_code: None, + fatca_code: None, + tin_last4: last4.to_string(), + kept_at: "wanderbooks vault".to_string(), + vault_record: None, + } +} + +fn w4(state: &str) -> W4Facts { + W4Facts { + received: date(2026, 3, 1), + state: state.to_string(), + kept_at: "CPA portal".to_string(), + } +} + +fn pay_line(name: &str, employee: Option) -> PayrollLine { + PayrollLine { + employee_name: name.to_string(), + employee, + gross_pay: dec("1000.00"), + federal_withholding: dec("100.00"), + state_withholding: dec("0"), + ss_employee: dec("62.00"), + medicare_employee: dec("14.50"), + ss_employer: dec("62.00"), + medicare_employer: dec("14.50"), + other_deductions: dec("0"), + } +} + +struct Fx { + cash: AccountId, + entry: EntryId, +} + +/// A store with cash, capital, and one posted $500.00 entry. +fn fixture(dir: &Path) -> (Store, Fx) { + let mut s = Store::create(dir, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Owner Capital", AccountType::Equity, None, false) + .unwrap(); + let entry = s.create_entry(date(2026, 1, 1), "Opening").unwrap(); + s.add_line(entry, cash, Side::Debit, usd("500.00")).unwrap(); + s.add_line(entry, capital, Side::Credit, usd("500.00")) + .unwrap(); + s.post_entry(entry).unwrap(); + (s, Fx { cash, entry }) +} + +/// What "replays to an equal ledger" compares: every party, every entry's +/// party and vendor tags, every payroll run. +#[derive(Debug, PartialEq)] +struct View { + parties: Vec, + tags: Vec<(EntryId, Option, Option)>, + runs: Vec, +} + +fn view(l: &Ledger) -> View { + View { + parties: l.parties().cloned().collect(), + tags: (0..l.next_entry_id() + 2) + .map(|e| (e, l.entry_party(e), l.entry_vendor(e))) + .collect(), + runs: l.list_payroll_runs().into_iter().cloned().collect(), + } +} + +fn delete_db(dir: &Path) { + for suffix in ["", "-wal", "-shm", "-journal"] { + let _ = std::fs::remove_file(dir.join(format!("{DB_FILE}{suffix}"))); + } +} + +/// A store exercising every party event. +/// `named_employee`: whether the payroll run names Alice's party. +fn rich(dir: &Path, named_employee: bool) -> Store { + let (mut s, fx) = fixture(dir); + let irs = s.add_party("IRS", &[Role::TaxAgency]).unwrap(); + let dev = s.add_party("Dev Shop", &[Role::Contractor]).unwrap(); + let alice = s.add_party("Alice", &[Role::Employee]).unwrap(); + s.update_party(dev, PartyField::Website(Some("https://dev.example".into()))) + .unwrap(); + s.update_party(dev, PartyField::DisplayName(Some("Dev".into()))) + .unwrap(); + s.update_party(irs, PartyField::Notes(Some("EFTPS".into()))) + .unwrap(); + s.set_party_role(dev, Role::Landlord, true).unwrap(); + s.set_party_role(dev, Role::Landlord, false).unwrap(); + s.set_party_w9(dev, Some(w9("6789"))).unwrap(); + s.set_party_w4(alice, Some(w4("UT"))).unwrap(); + let v = s.add_vendor("dev shop").unwrap(); + let c = s.add_customer("Acme").unwrap(); + s.add_vendor("Acme").unwrap(); + let _ = c; + s.set_entry_party(fx.entry, Some(irs)).unwrap(); + s.set_entry_vendor(fx.entry, Some(v)).unwrap(); + let old = s.add_party("Old Name", &[]).unwrap(); + s.update_party(old, PartyField::Archived(true)).unwrap(); + s.add_vendor("Old Name").unwrap(); + s.run_payroll( + date(2026, 2, 27), + vec![ + pay_line("Alice", named_employee.then_some(alice)), + pay_line("Bob", None), + ], + fx.cash, + ) + .unwrap(); + s +} + +// ---------------------------------------------------------------- journaling + +/// Oracle: Store::add_party doc ("journaled as party.added") and the +/// task's payload {id, name, roles}; roles by wire key. +#[test] +fn add_party_journals_party_added() { + let dir = test_dir("added"); + let (mut s, _) = fixture(&dir); + let next = s.ledger().next_party_id(); + let (id, p) = one_event(&mut s, &dir, "party.added", |s| { + s.add_party("Internal Revenue Service", &[Role::TaxAgency]) + .unwrap() + }); + assert_eq!(id, next); + assert_eq!(p["id"], json!(id)); + assert_eq!(p["name"], json!("Internal Revenue Service")); + assert_eq!(p["roles"], json!(["tax_agency"])); + + let (id2, p) = one_event(&mut s, &dir, "party.added", |s| { + s.add_party("Stephen", &[Role::Member, Role::Employee]) + .unwrap() + }); + assert_eq!(p["id"], json!(id2)); + let mut roles: Vec = p["roles"] + .as_array() + .unwrap() + .iter() + .map(|r| r.as_str().unwrap().to_string()) + .collect(); + roles.sort(); + assert_eq!(roles, vec!["employee".to_string(), "member".to_string()]); +} + +/// Oracle: Store::update_party doc ("journaled as party.updated") with +/// {id, change}; PartyField's wire form is `{field, value}` (its serde +/// attributes: snake_case, tag "field", content "value"). +#[test] +fn update_party_journals_party_updated() { + let dir = test_dir("updated"); + let (mut s, _) = fixture(&dir); + let id = s.add_party("Acme", &[]).unwrap(); + let cases = [ + ( + PartyField::Website(Some("https://acme.example".into())), + json!({"field": "website", "value": "https://acme.example"}), + ), + ( + PartyField::DisplayName(Some("ACME".into())), + json!({"field": "display_name", "value": "ACME"}), + ), + ( + PartyField::DisplayName(None), + json!({"field": "display_name", "value": null}), + ), + ( + PartyField::Address(Some("1 Main St".into())), + json!({"field": "address", "value": "1 Main St"}), + ), + ( + PartyField::Notes(Some("net 30".into())), + json!({"field": "notes", "value": "net 30"}), + ), + ( + PartyField::Name("Acme LLC".into()), + json!({"field": "name", "value": "Acme LLC"}), + ), + ( + PartyField::Archived(true), + json!({"field": "archived", "value": true}), + ), + ]; + for (field, want) in cases { + let ((), p) = one_event(&mut s, &dir, "party.updated", |s| { + s.update_party(id, field.clone()).unwrap() + }); + assert_eq!(p["id"], json!(id)); + assert_eq!(p["change"], want, "{field:?}"); + } + let got = s.ledger().get_party(id).unwrap(); + assert_eq!(got.name, "Acme LLC"); + assert!(got.archived); + assert_eq!(got.notes.as_deref(), Some("net 30")); +} + +/// Oracle: Store::set_party_role doc ("journaled as party.role_set") with +/// {id, role, on}. +#[test] +fn set_party_role_journals_party_role_set() { + let dir = test_dir("role"); + let (mut s, _) = fixture(&dir); + let id = s.add_party("Kat", &[]).unwrap(); + let ((), p) = one_event(&mut s, &dir, "party.role_set", |s| { + s.set_party_role(id, Role::PayoutPlatform, true).unwrap() + }); + assert_eq!(p, json!({"id": id, "role": "payout_platform", "on": true})); + let ((), p) = one_event(&mut s, &dir, "party.role_set", |s| { + s.set_party_role(id, Role::PayoutPlatform, false).unwrap() + }); + assert_eq!(p, json!({"id": id, "role": "payout_platform", "on": false})); +} + +/// Oracle: Store::set_party_w9 / set_party_w4 docs (`party.w9_set` / +/// `party.w4_set` {id, facts}); facts null when cleared. +#[test] +fn w9_and_w4_journal_their_facts() { + let dir = test_dir("facts"); + let (mut s, _) = fixture(&dir); + let c = s.add_party("Contractor", &[Role::Contractor]).unwrap(); + let ((), p) = one_event(&mut s, &dir, "party.w9_set", |s| { + s.set_party_w9(c, Some(w9("6789"))).unwrap() + }); + assert_eq!(p["id"], json!(c)); + assert_eq!(p["facts"]["tin_last4"], json!("6789")); + assert_eq!(p["facts"]["received"], json!("2026-03-01")); + assert_eq!( + p["facts"]["entity_type"], + json!("individual/sole proprietor") + ); + assert_eq!(p["facts"]["kept_at"], json!("wanderbooks vault")); + let back: W9Facts = serde_json::from_value(p["facts"].clone()).unwrap(); + assert_eq!(back, w9("6789")); + let ((), p) = one_event(&mut s, &dir, "party.w9_set", |s| { + s.set_party_w9(c, None).unwrap() + }); + assert_eq!(p, json!({"id": c, "facts": null})); + + let e = s.add_party("Employee", &[Role::Employee]).unwrap(); + let ((), p) = one_event(&mut s, &dir, "party.w4_set", |s| { + s.set_party_w4(e, Some(w4("UT"))).unwrap() + }); + assert_eq!( + p, + json!({"id": e, "facts": {"received": "2026-03-01", "state": "UT", "kept_at": "CPA portal"}}) + ); + let ((), p) = one_event(&mut s, &dir, "party.w4_set", |s| { + s.set_party_w4(e, None).unwrap() + }); + assert_eq!(p, json!({"id": e, "facts": null})); +} + +/// Oracle: Store::set_entry_party doc (`entry.party_set {entry_id, +/// party_id}`, null when cleared). +#[test] +fn set_entry_party_journals_entry_party_set() { + let dir = test_dir("entry"); + let (mut s, fx) = fixture(&dir); + let p = s.add_party("Utah", &[Role::TaxAgency]).unwrap(); + let ((), pl) = one_event(&mut s, &dir, "entry.party_set", |s| { + s.set_entry_party(fx.entry, Some(p)).unwrap() + }); + assert_eq!(pl, json!({"entry_id": fx.entry, "party_id": p})); + assert_eq!(s.ledger().entry_party(fx.entry), Some(p)); + let ((), pl) = one_event(&mut s, &dir, "entry.party_set", |s| { + s.set_entry_party(fx.entry, None).unwrap() + }); + assert_eq!(pl, json!({"entry_id": fx.entry, "party_id": null})); + assert_eq!(s.ledger().entry_party(fx.entry), None); +} + +/// Oracle: add_vendor / add_customer docs ("journaled as vendor.added" / +/// "customer.added"): one event each; the party comes from the core +/// auto-link (so from replay), not a second event. +#[test] +fn add_vendor_and_customer_journal_one_event_and_link_a_party() { + let dir = test_dir("records"); + let (mut s, fx) = fixture(&dir); + let (v, _) = one_event(&mut s, &dir, "vendor.added", |s| { + s.add_vendor("Acme").unwrap() + }); + let (c, _) = one_event(&mut s, &dir, "customer.added", |s| { + s.add_customer("ACME").unwrap() + }); + let pv = s.ledger().party_of_vendor(v).unwrap(); + assert_eq!(s.ledger().party_of_customer(c), Some(pv)); + // set_entry_vendor: one entry.vendor_set line, and the party is tagged. + let ((), pl) = one_event(&mut s, &dir, "entry.vendor_set", |s| { + s.set_entry_vendor(fx.entry, Some(v)).unwrap() + }); + assert_eq!(pl["entry_id"], json!(fx.entry)); + assert_eq!(s.ledger().entry_party(fx.entry), Some(pv)); +} + +/// Oracle: payroll.run lines carry "employee" (the party id, or null). +#[test] +fn payroll_run_lines_carry_employee() { + let dir = test_dir("payroll"); + let (mut s, fx) = fixture(&dir); + let alice = s.add_party("Alice", &[Role::Employee]).unwrap(); + s.run_payroll( + date(2026, 2, 27), + vec![pay_line("Alice", Some(alice)), pay_line("Bob", None)], + fx.cash, + ) + .unwrap(); + let j = journal(&dir); + let (_, p) = j.iter().rev().find(|(t, _)| t == "payroll.run").unwrap(); + let lines = p["lines"].as_array().unwrap(); + assert_eq!(lines.len(), 2); + let by_name = |n: &str| { + lines + .iter() + .find(|l| l["employee_name"] == json!(n)) + .unwrap() + .clone() + }; + assert_eq!(by_name("Alice")["employee"], json!(alice)); + assert_eq!(by_name("Bob")["employee"], Value::Null); +} + +// ---------------------------------------------------------------- refusals + +/// Oracle: the core SCENARIOS (refusals) and the store's journal-first +/// contract: a refusal journals nothing. +#[test] +fn refusals_journal_nothing() { + let dir = test_dir("refusals"); + let (mut s, fx) = fixture(&dir); + let a = s.add_party("Alpha", &[Role::Member]).unwrap(); + s.add_party("Beta", &[]).unwrap(); + let emp = s.add_party("Emp", &[Role::Employee]).unwrap(); + let ghost = s.ledger().next_party_id() + 50; + refused_nothing_journaled(&mut s, &dir, "blank", |s| s.add_party(" ", &[])); + refused_nothing_journaled(&mut s, &dir, "tin", |s| s.add_party("Bob 123-45-6789", &[])); + refused_nothing_journaled(&mut s, &dir, "dup", |s| s.add_party(" alpha", &[])); + refused_nothing_journaled(&mut s, &dir, "vendor role", |s| { + s.add_party("Gamma", &[Role::Vendor]) + }); + refused_nothing_journaled(&mut s, &dir, "update unknown", |s| { + s.update_party(ghost, PartyField::Notes(None)) + }); + refused_nothing_journaled(&mut s, &dir, "update tin", |s| { + s.update_party(a, PartyField::Notes(Some("12-3456789".into()))) + }); + refused_nothing_journaled(&mut s, &dir, "rename taken", |s| { + s.update_party(a, PartyField::Name("BETA".into())) + }); + refused_nothing_journaled(&mut s, &dir, "customer role", |s| { + s.set_party_role(a, Role::Customer, true) + }); + refused_nothing_journaled(&mut s, &dir, "w9 without 1099 role", |s| { + s.set_party_w9(a, Some(w9("6789"))) + }); + refused_nothing_journaled(&mut s, &dir, "w4 without employee", |s| { + s.set_party_w4(a, Some(w4("UT"))) + }); + refused_nothing_journaled(&mut s, &dir, "bad w4", |s| { + s.set_party_w4(emp, Some(w4("Utah"))) + }); + s.set_party_w4(emp, Some(w4("UT"))).unwrap(); + refused_nothing_journaled(&mut s, &dir, "drop employee with w4", |s| { + s.set_party_role(emp, Role::Employee, false) + }); + refused_nothing_journaled(&mut s, &dir, "entry party unknown", |s| { + s.set_entry_party(fx.entry, Some(ghost)) + }); + refused_nothing_journaled(&mut s, &dir, "entry unknown", |s| { + s.set_entry_party(fx.entry + 99, Some(a)) + }); + refused_nothing_journaled(&mut s, &dir, "payroll non-employee", |s| { + s.run_payroll(date(2026, 2, 27), vec![pay_line("Alpha", Some(a))], fx.cash) + }); + refused_nothing_journaled(&mut s, &dir, "payroll unknown party", |s| { + s.run_payroll( + date(2026, 2, 27), + vec![pay_line("Ghost", Some(ghost))], + fx.cash, + ) + }); +} + +// ---------------------------------------------------------------- replay + +/// Oracle: Store::verify INVARIANT 2 (a fresh replay equals the live +/// ledger) and the journal as system of record: reopening, with or +/// without the database, gives the same directory, tags, and runs. +fn replay_check(name: &str, named_employee: bool, verify: bool) { + let dir = test_dir(name); + let s = rich(&dir, named_employee); + if verify { + s.verify().unwrap(); + } + let live = view(s.ledger()); + let live_ledger = s.ledger().clone(); + assert!(live.parties.len() >= 5); + drop(s); + + let s = Store::open(&dir).unwrap(); + assert_eq!(view(s.ledger()), live, "reopen"); + assert!( + *s.ledger() == live_ledger, + "reopen: {:?}", + s.ledger().differs_from(&live_ledger) + ); + if verify { + s.verify().unwrap(); + } + drop(s); + + delete_db(&dir); + let s = Store::open(&dir).unwrap(); + assert_eq!(view(s.ledger()), live, "replay without the database"); + assert!( + *s.ledger() == live_ledger, + "no db: {:?}", + s.ledger().differs_from(&live_ledger) + ); + if verify { + s.verify().unwrap(); + } +} + +#[test] +fn replay_on_reopen_and_without_db_gives_an_equal_ledger() { + replay_check("replay", false, true); +} + +/// Same, with the payroll run naming its employee's party: replay must +/// still agree (verify is the next test). +#[test] +fn replay_with_a_named_employee_gives_an_equal_ledger() { + replay_check("replay-employee", true, false); +} + +/// Oracle: Store::verify INVARIANT 2 ("the SQLite read-back's ... payroll +/// runs equal the replay's") with a run whose line names its employee. +#[test] +fn verify_passes_with_a_named_employee() { + replay_check("verify-employee", true, true); +} + +// ---------------------------------------------------------------- corruption + +/// Append `ty`/`payload` with a valid chain, then expect open to refuse. +fn assert_corrupt_on_open(name: &str, setup: impl FnOnce(&mut Store) -> Value, ty: &str) { + let dir = test_dir(name); + let (mut s, _) = fixture(&dir); + let payload = setup(&mut s); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append(ty, payload.clone()).unwrap(); + drop(w); + if let Ok(_s) = Store::open(&dir) { + panic!("{name}: a corrupt {ty} line {payload} opened"); + } + delete_db(&dir); + if let Ok(_s) = Store::open(&dir) { + panic!("{name}: a corrupt {ty} line {payload} opened without the db"); + } +} + +/// Oracle: the payloads' documented shapes (Role keys, W9Facts/W4Facts +/// and their check, integer ids, party.added's id = the next id) and the +/// invariants: a line that cannot replay is refused on open. +#[test] +fn corrupt_party_payloads_are_refused_on_open() { + assert_corrupt_on_open( + "bad-role", + |s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "boss", "on": true}), + "party.role_set", + ); + assert_corrupt_on_open( + "record-role", + |s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "vendor", "on": true}), + "party.role_set", + ); + assert_corrupt_on_open( + "role-on-not-bool", + |s| json!({"id": s.add_party("A", &[]).unwrap(), "role": "member", "on": "yes"}), + "party.role_set", + ); + assert_corrupt_on_open( + "w9-shape", + |s| json!({"id": s.add_party("A", &[Role::Contractor]).unwrap(), "facts": {"nonsense": 1}}), + "party.w9_set", + ); + assert_corrupt_on_open( + "w9-full-tin", + |s| { + let mut f = serde_json::to_value(w9("6789")).unwrap(); + f["tin_last4"] = json!("123456789"); + json!({"id": s.add_party("A", &[Role::Contractor]).unwrap(), "facts": f}) + }, + "party.w9_set", + ); + assert_corrupt_on_open( + "w9-no-1099-role", + |s| { + json!({"id": s.add_party("A", &[Role::Member]).unwrap(), + "facts": serde_json::to_value(w9("6789")).unwrap()}) + }, + "party.w9_set", + ); + assert_corrupt_on_open( + "w4-bad-state", + |s| { + json!({"id": s.add_party("A", &[Role::Employee]).unwrap(), + "facts": {"received": "2026-03-01", "state": "Utah", "kept_at": "x"}}) + }, + "party.w4_set", + ); + assert_corrupt_on_open( + "updated-bad-field", + |s| json!({"id": s.add_party("A", &[]).unwrap(), "change": {"field": "ssn", "value": "x"}}), + "party.updated", + ); + for (i, bad) in [json!("3"), json!(-3), json!({"id": 1}), json!(true)] + .into_iter() + .enumerate() + { + assert_corrupt_on_open( + &format!("party-id-{i}"), + |s| { + s.add_party("A", &[]).unwrap(); + json!({"entry_id": 0, "party_id": bad}) + }, + "entry.party_set", + ); + } + assert_corrupt_on_open( + "added-id-mismatch", + |s| json!({"id": s.ledger().next_party_id() + 5, "name": "X", "roles": []}), + "party.added", + ); + assert_corrupt_on_open( + "added-bad-role", + |s| json!({"id": s.ledger().next_party_id(), "name": "X", "roles": ["boss"]}), + "party.added", + ); + assert_corrupt_on_open( + "added-full-tin", + |s| json!({"id": s.ledger().next_party_id(), "name": "X 123456789", "roles": []}), + "party.added", + ); +} + +/// Control for the corruption test: a well-formed hand-appended +/// `party.added` and `entry.party_set` open and replay (so the refusals +/// above are about the payloads, not about appending by hand). +#[test] +fn a_well_formed_appended_party_line_opens() { + let dir = test_dir("well-formed"); + let (s, fx) = fixture(&dir); + let next = s.ledger().next_party_id(); + drop(s); + let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap(); + w.append( + "party.added", + json!({"id": next, "name": "Apple", "roles": ["payout_platform"]}), + ) + .unwrap(); + w.append( + "entry.party_set", + json!({"entry_id": fx.entry, "party_id": next}), + ) + .unwrap(); + drop(w); + let s = Store::open(&dir).unwrap(); + let p = s.ledger().get_party(next).unwrap(); + assert_eq!(p.name, "Apple"); + assert_eq!(p.roles, [Role::PayoutPlatform].into_iter().collect()); + assert_eq!(s.ledger().entry_party(fx.entry), Some(next)); + s.verify().unwrap(); +} + +// ---------------------------------------------------------------- migration + +/// Copy `from`'s journal into a fresh dir via `JournalWriter`, letting +/// `edit` drop or rewrite lines (a valid chain, old-format payloads). +fn rewrite_journal(from: &Path, to: &Path, edit: impl Fn(&str, Value) -> Option) { + let mut w = JournalWriter::open(&to.join(JOURNAL_FILE)).unwrap(); + for (t, p) in journal(from) { + if let Some(p) = edit(&t, p) { + w.append(&t, p).unwrap(); + } + } +} + +/// Oracle: docs "Migration": old journals replay unchanged; vendor.added +/// and customer.added create parties; payroll lines from before carry the +/// name only (`employee` absent). INVARIANT 4 holds after replay. +#[test] +fn books_from_before_parties_open_and_get_parties_by_replay() { + let src = test_dir("old-src"); + let (mut s, fx) = fixture(&src); + let v1 = s.add_vendor("Namecheap").unwrap(); + let v2 = s.add_vendor("Apple").unwrap(); + let c1 = s.add_customer("apple").unwrap(); + let c2 = s.add_customer("Beta Tester").unwrap(); + s.set_entry_vendor(fx.entry, Some(v1)).unwrap(); + s.run_payroll(date(2026, 2, 27), vec![pay_line("Alice", None)], fx.cash) + .unwrap(); + drop(s); + + let dst = test_dir("old-dst"); + rewrite_journal(&src, &dst, |t, mut p| { + assert!( + !t.starts_with("party.") && t != "entry.party_set", + "{t} in old books" + ); + if t == "payroll.run" { + for l in p["lines"].as_array_mut().unwrap() { + l.as_object_mut().unwrap().remove("employee"); + } + } + Some(p) + }); + let s = Store::open(&dst).unwrap(); + let l = s.ledger(); + // INVARIANT 4: each record links to exactly one party. + for (vid, name) in [(v1, "Namecheap"), (v2, "Apple")] { + let pid = l.party_of_vendor(vid).expect("vendor party"); + let owners: Vec<_> = l.parties().filter(|p| p.vendor == Some(vid)).collect(); + assert_eq!(owners.len(), 1); + assert_eq!(owners[0].id, pid); + assert_eq!(owners[0].name, name); + assert!(owners[0].all_roles().contains(&Role::Vendor)); + } + for cid in [c1, c2] { + let owners: Vec<_> = l.parties().filter(|p| p.customer == Some(cid)).collect(); + assert_eq!(owners.len(), 1); + assert_eq!(l.party_of_customer(cid), Some(owners[0].id)); + } + // The auto-link spec: "apple" the customer joins "Apple" the vendor. + assert_eq!(l.party_of_customer(c1), l.party_of_vendor(v2)); + assert_eq!(l.parties().count(), 3); + // entry.vendor_set replays as the party tag too. + assert_eq!(l.entry_vendor(fx.entry), Some(v1)); + assert_eq!(l.entry_party(fx.entry), l.party_of_vendor(v1)); + // The old payroll run has the name only. + let runs = l.list_payroll_runs(); + assert_eq!(runs.len(), 1); + assert_eq!(runs[0].lines[0].employee_name, "Alice"); + assert_eq!(runs[0].lines[0].employee, None); + s.verify().unwrap(); + let before = view(s.ledger()); + drop(s); + delete_db(&dst); + let s = Store::open(&dst).unwrap(); + assert_eq!(view(s.ledger()), before); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 7fa51c3..96fe205 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1197,6 +1197,7 @@ mod refusals { day(d), vec![ledger_core::PayrollLine { employee_name: "Pat".into(), + employee: None, gross_pay: Decimal::new(g, 2), federal_withholding: Decimal::new(w, 2), state_withholding: Decimal::ZERO, diff --git a/crates/ledger-vault/Cargo.toml b/crates/ledger-vault/Cargo.toml new file mode 100644 index 0000000..27016bb --- /dev/null +++ b/crates/ledger-vault/Cargo.toml @@ -0,0 +1,24 @@ +[package] +name = "ledger-vault" +version = "0.1.0" +edition.workspace = true +license.workspace = true +repository.workspace = true +description = "wanderbooks' encrypted TIN vault: full taxpayer identification numbers and W-9 files, outside the journal" + +[dependencies] +chacha20poly1305 = "0.11" +sha2 = "0.10" +getrandom = { version = "0.2", features = ["std"] } +hex = "0.4" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +thiserror = "2" +zeroize = { version = "1", features = ["serde"] } +base64 = "0.22" + +[lints] +workspace = true + +[dev-dependencies] +proptest = "1" diff --git a/crates/ledger-vault/src/lib.rs b/crates/ledger-vault/src/lib.rs new file mode 100644 index 0000000..26e9041 --- /dev/null +++ b/crates/ledger-vault/src/lib.rs @@ -0,0 +1,403 @@ +//! The TIN vault: full taxpayer identification numbers and W-9 files, +//! encrypted, outside the journal. +//! +//! ```text +//! GOAL: hand the CPA complete 1099 data (full TINs) without the books ever +//! holding one: the journal is permanent and replicated in git, so a +//! TIN written there could never be purged. +//! SOURCE: docs/parties-and-attachments.md "The TIN vault"; DECISIONS +//! (Stephen, 2026-10-15): wanderbooks holds full TINs in an +//! encrypted vault; no external dependencies (no OS keyring). +//! INVARIANTS: +//! 1. Records live in the vault directory (the books' `vault/`, which git +//! ignores), each encrypted with XChaCha20-Poly1305 under a 256-bit key +//! from a key file outside the books dir, a fresh random nonce per +//! write. +//! 2. A record is bound to its party and kind (associated data): moved, +//! renamed, or altered, it fails to open rather than yield another +//! party's TIN. +//! 3. A TIN is exactly nine digits (hyphens allowed when given); an ITIN +//! starts with 9 with fourth and fifth digits in 50-65, 70-88, 90-92, +//! or 94-99, and an SSN or EIN doesn't start with 9 (IRS Pub. 1915; +//! docs/spec/sources/irs-tin-formats.md). +//! 4. The vault never writes a TIN or a key anywhere but its own files; +//! errors never contain one. +//! 5. `record_hash` is the SHA-256 of a record's bytes on disk, so the +//! journal can prove a record existed without holding it. +//! 6. Purging deletes the record's file; nothing else keeps a copy. +//! SCENARIOS: +//! 1. IF the key file is missing: `open` creates one (mode 0600) only when +//! asked to (`create_key`), else the vault is locked (Error::NoKey). +//! 2. IF a record is missing: `tin` returns None. +//! 3. IF a record fails to decrypt (wrong key, tampered, moved): SHOULD +//! refuse with Error::Unreadable, naming the party, never guessing. +//! ``` + +use base64::Engine as _; +use chacha20poly1305::aead::{Aead, KeyInit, Payload}; +use chacha20poly1305::{XChaCha20Poly1305, XNonce}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::path::{Path, PathBuf}; +use thiserror::Error; +use zeroize::Zeroizing; + +/// What can go wrong. +#[derive(Debug, Error)] +pub enum Error { + /// The key file isn't there (the vault is locked). + #[error("the vault key file {0} is missing; the vault is locked")] + NoKey(PathBuf), + /// The key file isn't a 256-bit key. + #[error("the vault key file {0} is not a vault key (64 hex digits)")] + BadKey(PathBuf), + /// A TIN of the wrong form. + #[error("bad TIN: {0}")] + BadTin(String), + /// A record that fails to decrypt or parse. + #[error( + "the vault record for party {party} ({kind}) can't be read: wrong key, altered, or moved" + )] + Unreadable { + /// The party. + party: u64, + /// Its kind. + kind: &'static str, + }, + /// Filesystem trouble. + #[error("vault io: {0}")] + Io(#[from] std::io::Error), +} + +/// A TIN's type. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum TinType { + /// Social Security number (an individual). + Ssn, + /// Employer identification number (a business). + Ein, + /// IRS individual taxpayer identification number. + Itin, +} + +/// A full TIN: nine digits and its type. Its digits are wiped from memory +/// when dropped, and its Debug never prints them. +#[derive(Clone, Serialize, Deserialize)] +pub struct Tin { + digits: Zeroizing, + /// Its type. + pub kind: TinType, +} + +impl std::fmt::Debug for Tin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "Tin({:?}, last4 {})", self.kind, self.last4()) + } +} + +impl Tin { + /// A TIN from its digits (hyphens allowed), checked against its type + /// (INVARIANT 3). + pub fn new(text: &str, kind: TinType) -> Result { + let digits: String = text.chars().filter(|c| *c != '-').collect(); + if digits.len() != 9 || !digits.chars().all(|c| c.is_ascii_digit()) { + return Err(Error::BadTin("a TIN is nine digits".into())); + } + let starts_9 = digits.starts_with('9'); + match kind { + TinType::Itin if !starts_9 => { + return Err(Error::BadTin("an ITIN begins with 9".into())); + } + TinType::Itin => { + let group: u8 = digits[3..5].parse().expect("two digits"); + if !matches!(group, 50..=65 | 70..=88 | 90..=92 | 94..=99) { + return Err(Error::BadTin( + "an ITIN's fourth and fifth digits are 50-65, 70-88, 90-92, or 94-99" + .into(), + )); + } + } + TinType::Ssn | TinType::Ein if starts_9 => { + return Err(Error::BadTin( + "a nine-digit number beginning with 9 is an ITIN, not an SSN or EIN".into(), + )); + } + _ => {} + } + Ok(Tin { + digits: Zeroizing::new(digits), + kind, + }) + } + + /// The nine digits, for the 1099 worksheet only. + pub fn digits(&self) -> &str { + &self.digits + } + + /// The last four digits (what the books keep). + pub fn last4(&self) -> &str { + &self.digits[5..] + } + + /// Formatted as the IRS writes it: 123-45-6789 for an SSN or ITIN, + /// 12-3456789 for an EIN. + pub fn formatted(&self) -> String { + let d = self.digits(); + match self.kind { + TinType::Ein => format!("{}-{}", &d[..2], &d[2..]), + _ => format!("{}-{}-{}", &d[..3], &d[3..5], &d[5..]), + } + } +} + +/// A W-9 file kept in the vault. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct W9File { + /// Original file name. + pub file_name: String, + /// MIME type. + pub mime_type: String, + /// The bytes. + #[serde(with = "b64")] + pub bytes: Vec, +} + +mod b64 { + use base64::Engine as _; + use serde::{Deserialize, Deserializer, Serializer}; + pub fn serialize(b: &[u8], s: S) -> Result { + s.serialize_str(&base64::engine::general_purpose::STANDARD.encode(b)) + } + pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result, D::Error> { + let s = String::deserialize(d)?; + base64::engine::general_purpose::STANDARD + .decode(s) + .map_err(serde::de::Error::custom) + } +} + +const MAGIC: &[u8; 4] = b"WBV1"; +const NONCE_LEN: usize = 24; + +/// The two kinds of record. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Kind { + Tin, + W9, +} + +impl Kind { + fn name(self) -> &'static str { + match self { + Kind::Tin => "tin", + Kind::W9 => "w9", + } + } +} + +/// An open vault. +pub struct Vault { + dir: PathBuf, + cipher: XChaCha20Poly1305, +} + +impl std::fmt::Debug for Vault { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("Vault") + .field("dir", &self.dir) + .finish_non_exhaustive() + } +} + +/// The default key file: `$XDG_CONFIG_HOME/wanderbooks/vault.key`, else +/// `~/.config/wanderbooks/vault.key`. +pub fn default_key_file() -> PathBuf { + let base = std::env::var_os("XDG_CONFIG_HOME") + .map(PathBuf::from) + .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config"))) + .unwrap_or_else(|| PathBuf::from(".")); + base.join("wanderbooks").join("vault.key") +} + +impl Vault { + /// Open the vault in `dir` with the key in `key_file`. With + /// `create_key`, a missing key file is created (a fresh random key, + /// mode 0600); without, a missing one is Error::NoKey. The directory is + /// created on first write. + pub fn open(dir: &Path, key_file: &Path, create_key: bool) -> Result { + let key = match std::fs::read_to_string(key_file) { + Ok(text) => { + let text = Zeroizing::new(text); + let bytes = Zeroizing::new( + hex::decode(text.trim()).map_err(|_| Error::BadKey(key_file.to_path_buf()))?, + ); + if bytes.len() != 32 { + return Err(Error::BadKey(key_file.to_path_buf())); + } + let mut k = Zeroizing::new([0u8; 32]); + k.copy_from_slice(&bytes); + k + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound && create_key => { + let mut k = Zeroizing::new([0u8; 32]); + getrandom::getrandom(&mut k[..]) + .map_err(|e| Error::Io(std::io::Error::other(e)))?; + write_private(key_file, hex::encode(&k[..]).as_bytes())?; + k + } + Err(e) if e.kind() == std::io::ErrorKind::NotFound => { + return Err(Error::NoKey(key_file.to_path_buf())); + } + Err(e) => return Err(Error::Io(e)), + }; + let cipher = XChaCha20Poly1305::new_from_slice(&key[..]).expect("32-byte key"); + Ok(Vault { + dir: dir.to_path_buf(), + cipher, + }) + } + + fn path(&self, party: u64, kind: Kind) -> PathBuf { + self.dir.join(format!("{}-{party}.bin", kind.name())) + } + + fn aad(party: u64, kind: Kind) -> Vec { + format!("wanderbooks-vault-v1|{}|party:{party}", kind.name()).into_bytes() + } + + fn seal(&self, party: u64, kind: Kind, plain: &[u8]) -> Result { + let mut nonce = [0u8; NONCE_LEN]; + getrandom::getrandom(&mut nonce).map_err(|e| Error::Io(std::io::Error::other(e)))?; + let aad = Self::aad(party, kind); + let ct = self + .cipher + .encrypt( + &XNonce::from(nonce), + Payload { + msg: plain, + aad: &aad, + }, + ) + .expect("encryption of an in-memory buffer"); + let mut out = Vec::with_capacity(4 + NONCE_LEN + ct.len()); + out.extend_from_slice(MAGIC); + out.extend_from_slice(&nonce); + out.extend_from_slice(&ct); + std::fs::create_dir_all(&self.dir)?; + write_private(&self.path(party, kind), &out)?; + Ok(hex::encode(Sha256::digest(&out))) + } + + fn open_record(&self, party: u64, kind: Kind) -> Result>>, Error> { + let bytes = match std::fs::read(self.path(party, kind)) { + Ok(b) => b, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None), + Err(e) => return Err(Error::Io(e)), + }; + let unreadable = Error::Unreadable { + party, + kind: kind.name(), + }; + if bytes.len() < 4 + NONCE_LEN || &bytes[..4] != MAGIC { + return Err(unreadable); + } + let aad = Self::aad(party, kind); + let plain = self + .cipher + .decrypt( + &XNonce::try_from(&bytes[4..4 + NONCE_LEN]).expect("length checked"), + Payload { + msg: &bytes[4 + NONCE_LEN..], + aad: &aad, + }, + ) + .map_err(|_| unreadable)?; + Ok(Some(Zeroizing::new(plain))) + } + + /// Store a party's TIN; returns the record's hash (INVARIANT 5). + pub fn put_tin(&self, party: u64, tin: &Tin) -> Result { + let plain = Zeroizing::new(serde_json::to_vec(tin).expect("serializes")); + self.seal(party, Kind::Tin, &plain) + } + + /// A party's TIN, if the vault holds one. + pub fn tin(&self, party: u64) -> Result, Error> { + match self.open_record(party, Kind::Tin)? { + None => Ok(None), + Some(plain) => serde_json::from_slice(&plain) + .map(Some) + .map_err(|_| Error::Unreadable { party, kind: "tin" }), + } + } + + /// Store a party's W-9 file; returns the record's hash. + pub fn put_w9(&self, party: u64, file: &W9File) -> Result { + let plain = Zeroizing::new(serde_json::to_vec(file).expect("serializes")); + self.seal(party, Kind::W9, &plain) + } + + /// A party's W-9 file, if the vault holds one. + pub fn w9(&self, party: u64) -> Result, Error> { + match self.open_record(party, Kind::W9)? { + None => Ok(None), + Some(plain) => serde_json::from_slice(&plain) + .map(Some) + .map_err(|_| Error::Unreadable { party, kind: "w9" }), + } + } + + /// The SHA-256 of a party's TIN record as stored, if any. + pub fn record_hash(&self, party: u64) -> Result, Error> { + match std::fs::read(self.path(party, Kind::Tin)) { + Ok(b) => Ok(Some(hex::encode(Sha256::digest(&b)))), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(Error::Io(e)), + } + } + + /// Delete everything the vault holds for a party (INVARIANT 6). Returns + /// whether anything was there. + pub fn purge(&self, party: u64) -> Result { + let mut any = false; + for kind in [Kind::Tin, Kind::W9] { + match std::fs::remove_file(self.path(party, kind)) { + Ok(()) => any = true, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => {} + Err(e) => return Err(Error::Io(e)), + } + } + Ok(any) + } +} + +/// Write a file readable by its owner only (0600 on Unix), creating its +/// directory. +fn write_private(path: &Path, bytes: &[u8]) -> Result<(), Error> { + if let Some(dir) = path.parent() { + std::fs::create_dir_all(dir)?; + } + let tmp = path.with_extension("tmp"); + { + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + use std::io::Write; + let mut f = opts.open(&tmp)?; + f.write_all(bytes)?; + f.sync_all()?; + } + std::fs::rename(&tmp, path)?; + Ok(()) +} + +/// Base64 of bytes (for callers handing a W-9 to a tool). +pub fn to_base64(bytes: &[u8]) -> String { + base64::engine::general_purpose::STANDARD.encode(bytes) +} diff --git a/crates/ledger-vault/tests/vault.rs b/crates/ledger-vault/tests/vault.rs new file mode 100644 index 0000000..9e95066 --- /dev/null +++ b/crates/ledger-vault/tests/vault.rs @@ -0,0 +1,182 @@ +//! The vault against its spec (crates/ledger-vault/src/lib.rs). + +use ledger_vault::{Error, Tin, TinType, Vault, W9File}; +use std::path::PathBuf; + +fn dir(tag: &str) -> PathBuf { + let d = std::env::temp_dir().join(format!("wb-vault-{tag}-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&d); + std::fs::create_dir_all(&d).unwrap(); + d +} + +fn vault(d: &std::path::Path) -> Vault { + Vault::open(&d.join("vault"), &d.join("keys/vault.key"), true).unwrap() +} + +/// INVARIANT 3: nine digits, hyphens allowed; ITINs start with 9 and SSNs +/// and EINs don't. +#[test] +fn tins_are_nine_digits_of_their_type() { + assert_eq!( + Tin::new("123-45-6789", TinType::Ssn).unwrap().digits(), + "123456789" + ); + assert_eq!( + Tin::new("12-3456789", TinType::Ein).unwrap().last4(), + "6789" + ); + // Pub. 1915: fourth and fifth digits 50-65, 70-88, 90-92, 94-99. + for ok in [ + "900-50-0000", + "900-65-0000", + "900-70-0000", + "900-88-0000", + "900-90-0000", + "900-92-0000", + "900-94-0000", + "900-99-0000", + ] { + assert!(Tin::new(ok, TinType::Itin).is_ok(), "{ok}"); + } + for bad in [ + "900-49-0000", + "900-66-0000", + "900-69-0000", + "900-89-0000", + "900-93-0000", + "900-00-0000", + ] { + assert!(Tin::new(bad, TinType::Itin).is_err(), "{bad}"); + } + for (bad, kind) in [ + ("12345678", TinType::Ssn), + ("1234567890", TinType::Ssn), + ("12345678a", TinType::Ein), + ("123 45 6789", TinType::Ssn), + ("912-70-1234", TinType::Ssn), + ("923456789", TinType::Ein), + ("123456789", TinType::Itin), + ] { + assert!(Tin::new(bad, kind).is_err(), "{bad} as {kind:?}"); + } + assert_eq!( + Tin::new("123456789", TinType::Ssn).unwrap().formatted(), + "123-45-6789" + ); + assert_eq!( + Tin::new("123456789", TinType::Ein).unwrap().formatted(), + "12-3456789" + ); +} + +/// INVARIANT 4: Debug and errors never show the digits. +#[test] +fn a_tin_never_prints() { + let tin = Tin::new("123-45-6789", TinType::Ssn).unwrap(); + let shown = format!("{tin:?}"); + assert!( + !shown.contains("12345") && shown.contains("6789"), + "{shown}" + ); + let err = Tin::new("123-45-678", TinType::Ssn) + .unwrap_err() + .to_string(); + assert!(!err.contains("12345678"), "{err}"); +} + +/// INVARIANT 1, 5: a TIN and a W-9 round-trip; the record on disk holds +/// neither in the clear; record_hash matches the bytes. +#[test] +fn records_round_trip_encrypted() { + let d = dir("roundtrip"); + let v = vault(&d); + let tin = Tin::new("12-3456789", TinType::Ein).unwrap(); + let hash = v.put_tin(7, &tin).unwrap(); + let back = v.tin(7).unwrap().unwrap(); + assert_eq!(back.digits(), "123456789"); + assert_eq!(back.kind, TinType::Ein); + let raw = std::fs::read(d.join("vault/tin-7.bin")).unwrap(); + assert!( + !raw.windows(9).any(|w| w == b"123456789"), + "TIN in the clear" + ); + assert_eq!(v.record_hash(7).unwrap().as_deref(), Some(hash.as_str())); + let w9 = W9File { + file_name: "w9.pdf".into(), + mime_type: "application/pdf".into(), + bytes: b"%PDF secret 123456789".to_vec(), + }; + v.put_w9(7, &w9).unwrap(); + assert_eq!(v.w9(7).unwrap().unwrap(), w9); + assert!(v.tin(8).unwrap().is_none(), "SCENARIO 2: missing is None"); + // A fresh nonce each write: the same TIN twice is two different records. + let again = v.put_tin(7, &tin).unwrap(); + assert_ne!(again, hash); +} + +/// INVARIANT 2 and SCENARIO 3: a record moved to another party, altered, +/// or opened with another key is unreadable, never another party's TIN. +#[test] +fn moved_altered_or_foreign_records_refuse() { + let d = dir("tamper"); + let v = vault(&d); + v.put_tin(1, &Tin::new("111-22-3333", TinType::Ssn).unwrap()) + .unwrap(); + std::fs::copy(d.join("vault/tin-1.bin"), d.join("vault/tin-2.bin")).unwrap(); + assert!(matches!(v.tin(2), Err(Error::Unreadable { party: 2, .. }))); + let mut raw = std::fs::read(d.join("vault/tin-1.bin")).unwrap(); + let last = raw.len() - 1; + raw[last] ^= 1; + std::fs::write(d.join("vault/tin-1.bin"), &raw).unwrap(); + assert!(matches!(v.tin(1), Err(Error::Unreadable { party: 1, .. }))); + let other = Vault::open(&d.join("vault"), &d.join("other.key"), true).unwrap(); + v.put_tin(3, &Tin::new("111-22-3333", TinType::Ssn).unwrap()) + .unwrap(); + assert!(matches!(other.tin(3), Err(Error::Unreadable { .. }))); +} + +/// SCENARIO 1: no key file and no permission to create one is a locked +/// vault; a created key file is 0600 and survives reopening. +#[test] +fn the_key_file() { + let d = dir("key"); + assert!(matches!( + Vault::open(&d.join("vault"), &d.join("missing.key"), false), + Err(Error::NoKey(_)) + )); + let v = vault(&d); + v.put_tin(1, &Tin::new("123456789", TinType::Ein).unwrap()) + .unwrap(); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(d.join("keys/vault.key")) + .unwrap() + .permissions() + .mode(); + assert_eq!(mode & 0o777, 0o600); + } + let reopened = Vault::open(&d.join("vault"), &d.join("keys/vault.key"), false).unwrap(); + assert_eq!(reopened.tin(1).unwrap().unwrap().digits(), "123456789"); + std::fs::write(d.join("bad.key"), "not hex").unwrap(); + assert!(matches!( + Vault::open(&d.join("vault"), &d.join("bad.key"), false), + Err(Error::BadKey(_)) + )); +} + +/// INVARIANT 6: purge deletes the party's records and only theirs. +#[test] +fn purge_removes_a_party() { + let d = dir("purge"); + let v = vault(&d); + let tin = Tin::new("123456789", TinType::Ein).unwrap(); + v.put_tin(1, &tin).unwrap(); + v.put_tin(2, &tin).unwrap(); + assert!(v.purge(1).unwrap()); + assert!(v.tin(1).unwrap().is_none()); + assert!(!d.join("vault/tin-1.bin").exists()); + assert!(v.tin(2).unwrap().is_some()); + assert!(!v.purge(1).unwrap(), "nothing left to purge"); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index f995f15..33386fd 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -111,8 +111,10 @@ below. - `books.chart` → `{ accounts: [{ id, code, name, type, parent, placeholder, balance }] }`. Balances included: one call answers "what do we have." -- `books.draft_entry { date: "2026-10-10", description: "..." }` - → `{ entry_id, status: "draft" }`. +- `books.draft_entry { date: "2026-10-10", description: "...", vendor?, + party? }` → `{ entry_id, status: "draft" }`. `party` tags the + counterparty (see "Tools — parties"); given with `vendor`, both must name + the same vendor record, else nothing is drafted. - `books.add_line { entry_id, account: "1000" | , side: "debit" | "credit", amount: "123.45" }` → `{ ok: true }`. A refused line (unknown account or class, bad amount) adds nothing. @@ -123,7 +125,8 @@ below. the receipt policy is enforced and `status` is `"posted"`. - `books.reverse_entry { entry_id, date, description, confirmed }` → same preview/confirm pattern as post. -- `books.get_entry { entry_id }` → full entry with lines. +- `books.get_entry { entry_id }` → full entry with lines, receipts, and + its `vendor`, `party_id`, and `party` (the party's label) tags. - `books.search_entries { account?, date_from?, date_to?, text?, limit?, offset? }` → entries with lines, newest first. `limit` caps at 100. - `books.trial_balance { as_of? }` → `as_of`, lines, totals, `balanced` @@ -222,12 +225,13 @@ through the policy. ## Tools — the viewer -- `books.show { entry_id | account | report | attachment_id | search | - page, start?, end? }` → `{ shown, note }`. Asks the human's viewer - (`ledger-gui`) to open an entry, an account register or a report (for - start/end dates if given), a stored file, a transactions search, or a - page. Exactly one target; entries, accounts, and attachments are checked - to exist. Never touches the books: the request is a small JSON file in +- `books.show { entry_id | account | report | attachment_id | asset | + party | search | page, start?, end? }` → `{ shown, note }`. Asks the + human's viewer (`ledger-gui`) to open an entry, an account register or a + report (for start/end dates if given), a stored file, an asset, a party + (by id or name, resolved as in "Tools — parties"), a transactions search, + or a page (`parties` among them). Exactly one target; entries, accounts, + attachments, assets, and parties are checked to exist. Never touches the books: the request is a small JSON file in the user's runtime directory (`$XDG_RUNTIME_DIR/wanderbooks/viewer-.json`), written atomically, watched by the viewer. A viewer started within two minutes of a request opens it; older requests @@ -269,19 +273,116 @@ overridable, always for the CPA to confirm. TINs are never stored, only whether a W-9 is on file. - `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?, - tin_on_file }] }`. + tin_on_file, party_id }] }`. - `books.set_vendor_1099 { vendor, form_1099: "nec" | "misc" | "none", tin_on_file? }` → the vendor. `vendor` is an id or a name. - `books.set_entry_vendor { entry_id, vendor? }` → the entry (as `books.get_entry`, which now shows `vendor`). Omitting `vendor` clears the tag. Metadata only: posted entries may be tagged. `books.draft_entry` takes `vendor` too; bills and their payments are tagged automatically. + `books.set_entry_party` tags the vendor too when the party has a vendor + record, so tagging by party counts for 1099s the same way. - `books.vendor_1099 { year, threshold? }` → `{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file, paid, reportable }], missing_w9, notes }`. For a year whose threshold wanderbooks doesn't know (after 2026 until the indexed amount is added) it refuses with what to do; pass `threshold` explicitly once the figure is confirmed. +## Tools — parties + +The directory of everyone the company deals with (`ledger_core::parties`; +design in `docs/parties-and-attachments.md`): vendors, contractors, +landlords, customers, payout platforms, tax agencies, banks and card +issuers, members, employees. Vendor and customer *records* (bills, +invoices, aging, 1099 marks) stay where they are; each links to exactly one +party. `books.add_vendor` and `books.add_customer` link their record to the +party of the same name (ignoring case and surrounding space, not archived) +that has no such record yet, or make a new party. + +**References.** A `party` argument is an id or a name: the name ignoring +case and surrounding space, among parties not archived (an archived party +goes by id). A number that is one party's id and another party's name is +refused, never guessed. + +**Roles:** `vendor`, `contractor`, `customer`, `employee`, `member`, +`payout_platform`, `tax_agency`, `financial_institution`, `landlord`. +Vendor and customer follow the records and are never set by hand. + +**Rules the engine enforces** (each refusal changes nothing, the journal +byte for byte): no party field holds a full TIN (a run of nine digits, +single spaces or hyphens allowed between them); a W-9's TIN is its last +four digits only; W-9 facts need a 1099 role (contractor or landlord), W-4 +facts the employee role; names are unique among parties not archived; +texts are at most 500 characters, no control characters. + +- `books.add_party { name, roles? }` → the party (below). Journaled as + `party.added`. +- `books.update_party { party, name?, display_name?, website?, address?, + notes?, archived? }` → the party. Give at least one field; `""` clears + `display_name`, `website`, `address`, or `notes`. Every field is checked + (in order, on a rehearsal) before any is written; each is journaled as + its own `party.updated`, in the order name, display_name, website, + address, notes, archived. Un-archiving is refused while another party + has the name. +- `books.set_party_roles { party, add?, remove? }` → the party. Adds apply + before removes (so a contractor's W-9 survives a swap to landlord); roles + it already has, or lacks, are skipped; each change is journaled as + `party.role_set`. Removing the last 1099 role with W-9 facts on file, or + employee with W-4 facts, is refused: clear the facts first. +- `books.set_w9 { party, received_on, entity_type, exempt_payee_code?, + fatca_code?, tin_last4, kept_at }` or `{ party, clear: true }` → the + party. Journaled as `party.w9_set`. A party with a vendor record also + gets the record's `tin_on_file` set to match (journaled as + `vendor.1099_set`), so `books.vendor_1099`'s `missing_w9` follows. +- `books.set_w4 { party, received_on, state, kept_at }` or `{ party, clear: + true }` → the party. Journaled as `party.w4_set`. +- `books.set_entry_party { entry_id, party? }` → the entry (as + `books.get_entry`). `party` null or omitted clears the tag. A party with + a vendor record also tags the entry's vendor; one without, or clearing, + clears the vendor tag. Metadata only; posted entries may be tagged. + Journaled as `entry.party_set`. +- `books.parties { role?, include_archived?, year? }` → `{ year, parties: + [{ party_id, name, label, roles, vendor_id?, customer_id?, w9_on_file, + tin_last4?, w4_on_file, archived, paid, received }] }`, by id. +- `books.party { party, year? }` → `{ party, year, totals, entries, + invoices, bills, payroll }`: the facts; the calendar year's totals + (default this year); every entry it is on (drafts included, newest + first); its invoices and bills (`{ id, date, due_date, total, paid, + balance, status, entry_id }`); and the payroll lines naming it + (`{ run_id, date, entry_id, gross_pay, net_pay }`). + +The party (every mutating party tool returns it): `{ party_id, name, +label, display_name?, roles, website?, address?, notes?, archived, +vendor_id?, customer_id?, w9?: { received_on, entity_type, +exempt_payee_code?, fatca_code?, tin_last4, kept_at }, w4?: { received_on, +state, kept_at } }`. `label` is the display name, else the name; `roles` +include vendor and customer when it has those records. + +**Totals** (`books.party` `totals`; `paid` and `received` in +`books.parties`), from the journal: + +- *The party's entries:* tagged with the party; tagged with its vendor + record (bills and their payments); its customer record's invoices and + the payments received on them; and reversals of any of these. Only + posted entries dated in the year count. +- *Money accounts:* the `[books] cash_accounts` (every asset account but + receivables when none are set), and every liability account except the + payables and payroll withholding roles (`books.roles`). A liability + counts as how the company paid or was paid: a card, a member paying for + it, a loan. +- `paid` / `received`: each entry's money lines, credits minus debits; a + positive net was paid to the party, a negative one received from it; a + reversal counts against its original's side. A bill isn't paid until its + payment is; an entry only moving money between money accounts nets to + zero. +- `invoiced` / `billed`: its invoices' and bills' totals dated in the year. + `owed` / `receivable`: its open bills' and invoices' balances now (every + year). +- `amount_1099` (vendor parties only): what `books.vendor_1099` counts as + paid to its vendor record in the year (asset accounts only, so card + payments drop out); `form_1099` is the record's mark. +- `gross_pay` / `net_pay`: payroll lines naming it, in the year. + ## Tools — receipts & documents - `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }` @@ -457,6 +558,11 @@ in the server's tool descriptions, so the model reads it at use time): wrong posting is a reversal. Never guess-post. 5. **The digest is the receipt for your work.** After a batch of bookkeeping, offer the digest; the human reviews, you fix. +6. **Keep the party directory current.** Every counterparty gets a party; + every entry is tagged with its party; contractors and landlords get W-9 + facts before they are paid, employees W-4 facts before their first + payroll, and every payroll line names its employee party. Never a full + TIN or SSN: the engine refuses nine-digit numbers. ## Out of scope for this phase @@ -492,8 +598,9 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer. ## Tools — A/R and A/P -- `books.add_customer { name }` → `{ id }`. -- `books.add_vendor { name }` → `{ id }`. +- `books.add_customer { name }` → `{ id, party_id }`. +- `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to + a party ("Tools — parties"). - `books.create_invoice { party_id, date, due_date, lines: [{ description, amount }] }` → `{ id, total, entry_id }`. Auto-posts Dr `1200` Accounts Receivable / Cr `4000` Revenue. @@ -522,13 +629,17 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer. No tax tables: the caller supplies every number; the engine cuts correct entries. -- `books.run_payroll { date, cash_account, lines: [{ employee_name, - gross_pay, federal_withholding, state_withholding, ss_employee, +- `books.run_payroll { date, cash_account, lines: [{ employee?, + employee_name?, gross_pay, federal_withholding, state_withholding, ss_employee, medicare_employee, ss_employer, medicare_employer, other_deductions }] }` → `{ run_id, entry_id, total_gross, total_net }`. One balanced entry: Dr 6100 Salary & Wage Expense (gross), Dr 6110 Payroll Tax Expense (employer SS + Medicare), Cr 2110–2150 withholding payables, Cr cash (net). Auto-creates the 61xx/21xx accounts. Respects closed periods. + `employee` names the employee's party (id or name; it must have the + employee role, else nothing is written); new runs should name it, and + `employee_name` then defaults to the party's name. A line needs one or + the other. - `books.remit_payroll_taxes { date, cash_account }` → `{ entry_id, total }`. Clears every payroll payable to cash in one entry. - `books.list_payroll_runs` → `{ runs: [{ id, date, employee_count, diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index ce9050a..f6feb4a 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -21,25 +21,30 @@ descriptions get records. ```text Party { - id: PartyId, // vendors and customers keep their ids (see Migration) + id: PartyId, // its own id space (vendor and customer records keep theirs) name: String, // legal or trade name, as on documents display_name: Option, - roles: BTreeSet, // vendor, contractor, customer, employee, member, - // payout_platform, tax_agency, financial_institution, landlord + roles: BTreeSet, // set by hand: contractor, employee, member, payout_platform, + // tax_agency, financial_institution, landlord (vendor and + // customer follow the records; see all_roles) website: Option, address: Option, // business address; no personal PII beyond it - w9: Option, // contractor/landlord: the TIN itself is in the vault - w4: Option, // employee: never the SSN - form_1099: Option, // carried over from Vendor - member: Option, // a member's name in wanderbooks.toml, when role member notes: Option, archived: bool, // no longer dealt with; history stays + w9: Option, // contractor/landlord: the TIN itself is in the vault + w4: Option, // employee: never the SSN + vendor: Option, // its vendor record (bills, payments, 1099 form) + customer: Option,// its customer record (invoices) } W9Facts { received: NaiveDate, entity_type: String, exempt_payee_code: Option, - fatca_code: Option, tin_last4: String, kept_at: String } + fatca_code: Option, tin_last4: String, kept_at: String, + vault_record: Option /* SHA-256 of the vault record */ } W4Facts { received: NaiveDate, state: String, kept_at: String } ``` +The 1099 form stays on the vendor record (`vendor.1099_set`); a party +reaches it through `vendor`. + Rules (to vendor as B-26 with sources): - A full TIN or SSN is never in the journal: `tin_last4` is exactly four @@ -65,29 +70,32 @@ Rules (to vendor as B-26 with sources): ### Journal events -`party.added {id, name, roles}`, `party.updated {id, field, value}` (one -field per event: name, display_name, website, address, notes, archived), -`party.role_added {id, role}`, `party.role_removed {id, role}`, +`party.added {id, name, roles}`, `party.updated {id, change}` (one +`PartyField` per event, `{field, value}`: name, display_name, website, +address, notes, archived), `party.role_set {id, role, on}`, `party.w9_set {id, facts|null}`, `party.w4_set {id, facts|null}`, -`entry.party_set`. Replayed and projected like the rest; every event gets -viewer journal text (the GUI test enforces it). +`entry.party_set {entry_id, party_id|null}`. Replayed and projected like +the rest; every event gets viewer journal text (the GUI test enforces it). ### Migration Old journals replay unchanged. `vendor.added` and `customer.added` keep -their meaning and create parties with the vendor or customer role; the -party id space starts above the highest vendor and customer ids, and a -vendor and a customer with the same name stay two parties until the -human merges them (`books.merge_parties`, journaled, re-pointing links). -`entry.vendor_set` replays as `entry.party_set`. `vendor.1099_set` sets -the party's `form_1099` and the contractor role. +their meaning and, on replay as live, link their record to a party: the +live party of that name (ignoring case and surrounding space) without +such a record yet, else a new party. So a vendor and a customer of the +same name share one party; a second vendor of a taken name gets a new +party named "Name (2)" (names stay unique). Vendor and customer names +obey the party text rules (no full TIN). `entry.vendor_set` also sets the +entry's party, and `entry.party_set` to a party with a vendor record sets +the vendor tag: one counterparty. ### Tools `books.add_party`, `books.update_party`, `books.party_roles`, `books.set_w9`, `books.set_w4`, `books.parties` (list, filter by role), `books.party` (one: facts, roles, totals this year, documents, links), -`books.merge_parties`, `books.set_entry_party`. `books.add_vendor` and +`books.set_entry_party`. (Merging two parties, `books.merge_parties`, is +not built yet: records of one name already share a party.) `books.add_vendor` and `books.add_customer` stay as shortcuts. ### Viewer @@ -111,10 +119,13 @@ worksheet it hands the CPA is complete, but never in the journal. W-9 file itself, each encrypted at rest (XChaCha20-Poly1305, one random nonce per record; the file format carries a version and the party id as associated data, so a record can't be moved to another party). -- **Key:** a random 256-bit key in the OS keyring (Secret Service on Linux, - Keychain on macOS), created on first use; never on disk in the books dir. - A recovery copy is the human's job: `just vault-export` writes the key - and records as one passphrase-encrypted file for a password manager. +- **Key:** a random 256-bit key in a key file outside the books dir + (default `~/.config/wanderbooks/vault.key`, mode 0600; `[vault] key_file` + in wanderbooks.toml overrides it), created on first use. No OS keyring + or other system service (Stephen, 2026-10-15: no external dependencies), + so the vault works anywhere wanderbooks runs. Recovery is a copy of that + file in the human's password manager; without it the vault can't be + read (the books still open and verify). - **Journal:** `party.w9_set` records that a W-9 was received, its date, entity type, codes, and last four, plus a hash of the vault record, so the books prove a W-9 existed without holding it. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 049762e..be96ec8 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -378,6 +378,15 @@ years after 2026, indexed to inflation and rounded to the nearest $100 (§6041(h)). Card payments are reported on Form 1099-K, not 1099-NEC/MISC. Attorneys' gross proceeds stay at $600 (not modeled). +What counts as paid: payments made in the calendar year (§6041(a)), so a +prepayment counts when made. The engine counts credits minus debits to the +payment sources on a vendor's tagged entries: `[books] cash_accounts` and +`financing_accounts` (a member or lender paying on the company's behalf; +the company is still the payer). A card charge leaves neither (1099-K); a +bill is not paid until it is (`form1099::vendor_1099_report_with`, +checked by `payments_come_from_cash_and_financing_only`). A W-9 is on +file by the vendor's flag or its party's W-9 facts. + **Enforced:** `form1099::default_threshold`, a dated rule (`ledger_core::rules::THRESHOLD_1099`, known through 2026, review from 2026-11-01). For a later year the engine refuses with `RuleNotKnown` @@ -494,3 +503,26 @@ non-negative, in cents), `Asset::tax_basis`, `Asset::built_in_gain`, `missing_tax_basis`). **Checked:** `ledger-core` `tests/spec_tax_basis.rs`; `ledger-store` `tests/asset_tags.rs` (`asset.tax_basis_set` journaling, replay, v9 migration, corrupt payloads); `ledger-mcp` `tests/spec_assets.rs`. + +## B-26 Parties: one directory, no full TIN in the books + +**Status:** AUTHORITY (truncated TINs: `sources/irs-truncated-tin.md`); +DECISIONS (Stephen, 2026-10-15) + +Everyone the company deals with is a party, with roles (vendor, +contractor, customer, employee, member, payout platform, tax agency, +financial institution, landlord). Vendor and customer records link to +their party; entries are tagged with theirs. Names are unique among parties +not archived, ignoring case and surrounding space. A W-9 is recorded as its +facts and the TIN's last four digits (the part a truncated TIN shows, +Treas. Reg. §301.6109-4(a)); no party field may hold a nine-digit number, +and the full TIN lives only in the encrypted vault, outside the journal +(DECISION). W-9 facts need a 1099 role (contractor, landlord); W-4 facts +need the employee role; payroll lines that name their employee name a +party with that role. + +**Enforced:** `crate::parties` (has_full_tin, check_text, W9Facts::check, +W4Facts::check), `Ledger::add_party`, `update_party`, `set_party_role`, +`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`. +**Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store` +`tests/parties.rs`; `ledger-mcp` `tests/spec_parties.rs`. diff --git a/docs/spec/sources/irs-tin-formats.md b/docs/spec/sources/irs-tin-formats.md new file mode 100644 index 0000000..f93b083 --- /dev/null +++ b/docs/spec/sources/irs-tin-formats.md @@ -0,0 +1,16 @@ +# TIN formats +Governs `ledger_vault::Tin::new`: a TIN is nine digits; an ITIN begins with 9 and its fourth and fifth digits fall in the ranges below; an SSN or EIN doesn't begin with 9 (a nine-digit number beginning with 9 is in the ITIN series). + +## IRS Publication 1915 (Rev. 11-2024), "Understanding Your IRS Individual Taxpayer Identification Number", General Information +Source: +Retrieved: 2026-10-15 +SHA-256 of the retrieved file (`p1915.pdf`): `63a3cf3e79c2e73ed15738d5a0875ffb7496c2309e8c3e085836ade16b2e0920` + +> An ITIN is a tax processing number, issued by the Internal Revenue Service, for certain resident and nonresident aliens, their spouses, and their dependents. It is a nine-digit number beginning with the number “9”, has a range of numbers from "50" to "65", "70" to "88", “90” to “92” and “94” to “99” for the fourth and fifth digits and is formatted like a SSN (i.e. 9XX-7X-XXXX). + +## Instructions for Form W-7 (Rev. December 2024), What's an ITIN +Source: +Retrieved: 2026-10-15 +SHA-256 of the retrieved file (`irs-iw7.html`): `1d674a26a63c34b59a2cb6e34d050a95f744c53ae35ea50172a137298b37a000` + +> An ITIN is a nine-digit number issued by the IRS to individuals who are required for federal tax purposes to have a U.S. taxpayer identification number but who don’t have and aren’t eligible to get a social security number (SSN). diff --git a/docs/spec/sources/irs-truncated-tin.md b/docs/spec/sources/irs-truncated-tin.md new file mode 100644 index 0000000..bd35a71 --- /dev/null +++ b/docs/spec/sources/irs-truncated-tin.md @@ -0,0 +1,11 @@ +# Truncated taxpayer identification numbers +Governs what of a TIN the party directory may hold (crates/ledger-core/src/parties.rs): the last four digits, the same part a truncated TIN shows. The full number lives only in the encrypted vault (docs/parties-and-attachments.md), never in the journal. + +## Treas. Reg. §301.6109-4(a) and (b)(1) +Source: +Retrieved: 2026-10-15 +SHA-256 of the retrieved file (`ecfr301-6109-4.html`): `45702c366f7f185e41452a32acf685bb5371e00b91bfb6214174ffd153eba949` + +> (a) In general — Definition. An IRS truncated taxpayer identification number (TTIN) is an individual's social security number (SSN), IRS individual taxpayer identification number (ITIN), IRS adoption taxpayer identification number (ATIN), or IRS employer identification number (EIN) in which the first five digits of the nine-digit number are replaced with Xs or asterisks. The TTIN takes the same format of the identifying number it replaces, for example XXX-XX-1234 when replacing an SSN, or XX-XXX1234 when replacing an EIN. + +> (b) Use of a TTIN. (1) In general. Except as provided in paragraph (b)(2) of this section, a TTIN may be used to identify any person on any statement or other document that the internal revenue laws require to be furnished to another person. Use of a TTIN is permissive and not mandatory.