diff --git a/Cargo.lock b/Cargo.lock index 6630906..406aa21 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3742,6 +3742,7 @@ dependencies = [ "ledger-core", "ledger-import", "ledger-store", + "ledger-vault", "mime_guess", "proptest", "rmcp", diff --git a/crates/ledger-cli/src/main.rs b/crates/ledger-cli/src/main.rs index a1e5cae..10be145 100644 --- a/crates/ledger-cli/src/main.rs +++ b/crates/ledger-cli/src/main.rs @@ -316,6 +316,12 @@ receipt_threshold = "25.00" # ("Wanderland Labs LLC" -> WL); 1 to 8 letters or digits. # asset_tag_prefix = "WL" +# Whether entries name their counterparty (the party paid or paid by): +# "suggest" (the default) has the agent propose one when an entry has none; +# "require" refuses to post until it names one or is marked as having none +# (a transfer between your own accounts); "off" never mentions it. +# parties = "suggest" + # Which accounts invoices, bills, payroll, and closes post to (receivables, # payables, revenue, payroll, retained earnings) is not set here: those # roles are part of the books, journaled with books.assign_role and listed diff --git a/crates/ledger-config/src/lib.rs b/crates/ledger-config/src/lib.rs index 74ead85..c451275 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, + /// `[books] parties`: how hard the agent asks for each entry's + /// counterparty. Unset: [`PartyPolicy::Suggest`]. + pub parties: PartyPolicy, /// `[vault] key_file`: the TIN vault's key file. Unset: the vault's /// default (`~/.config/wanderbooks/vault.key`). pub vault_key_file: Option, @@ -212,6 +215,7 @@ struct BooksSection { #[serde(default)] investing_accounts: Vec, asset_tag_prefix: Option, + parties: Option, } /// The return a federal tax classification files (docs/spec/sources/ @@ -532,9 +536,46 @@ pub fn parse(text: &str) -> Result { } None => None, }, + parties: match raw.books.parties.as_deref() { + None | Some("suggest") => PartyPolicy::Suggest, + Some("off") => PartyPolicy::Off, + Some("require") | Some("required") => PartyPolicy::Require, + Some(other) => { + return Err(format!( + "[books] parties {other:?}: \"off\", \"suggest\", or \"require\"" + )); + } + }, }) } +/// `[books] parties`: whether entries name their counterparty +/// (docs/parties-and-attachments.md "Counterparties"; DECISION, Stephen, +/// 2026-10-15: the default suggests, books may require or turn it off). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PartyPolicy { + /// Never mention counterparties. + Off, + /// A draft or preview without a party names the likely one; nothing + /// is refused. + #[default] + Suggest, + /// Posting is refused until the entry names a party or is marked as + /// having none. + Require, +} + +impl PartyPolicy { + /// Its wire name. + pub fn key(self) -> &'static str { + match self { + PartyPolicy::Off => "off", + PartyPolicy::Suggest => "suggest", + PartyPolicy::Require => "require", + } + } +} + /// Load `wanderbooks.toml` from a path. pub fn load(path: &Path) -> Result { let text = std::fs::read_to_string(path).map_err(|e| format!("{}: {e}", path.display()))?; @@ -545,6 +586,25 @@ pub fn load(path: &Path) -> Result { mod tests { use super::*; + /// `[books] parties`: suggest by default; off, suggest, require (and + /// "required") parse; anything else is refused, naming the choices. + #[test] + fn parties_policy() { + let base = "[company]\nname = \"Acme LLC\"\n[books]\n"; + assert_eq!(parse(base).unwrap().parties, PartyPolicy::Suggest); + for (v, want) in [ + ("off", PartyPolicy::Off), + ("suggest", PartyPolicy::Suggest), + ("require", PartyPolicy::Require), + ("required", PartyPolicy::Require), + ] { + let s = parse(&format!("{base}parties = \"{v}\"\n")).unwrap(); + assert_eq!(s.parties, want, "{v}"); + } + let e = parse(&format!("{base}parties = \"always\"\n")).unwrap_err(); + assert!(e.contains("require"), "{e}"); + } + fn d(y: i32, m: u32, day: u32) -> NaiveDate { NaiveDate::from_ymd_opt(y, m, day).unwrap() } diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index db352d6..3e3f4d1 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -266,6 +266,8 @@ pub struct Ledger { next_party_id: crate::PartyId, /// The party each entry is with (the counterparty). entry_parties: HashMap, + /// Entries marked as having no counterparty, with why. + entry_no_party: HashMap, } impl Ledger { @@ -308,6 +310,7 @@ impl Ledger { parties: std::collections::BTreeMap::new(), next_party_id: 1, entry_parties: HashMap::new(), + entry_no_party: HashMap::new(), } } @@ -605,6 +608,10 @@ impl Ledger { ("parties", self.parties == other.parties), ("next_party_id", self.next_party_id == other.next_party_id), ("entry_parties", self.entry_parties == other.entry_parties), + ( + "entry_no_party", + self.entry_no_party == other.entry_no_party, + ), ] .into_iter() .filter(|(_, same)| !same) @@ -2844,6 +2851,7 @@ impl Ledger { .ok_or(LedgerError::UnknownParty(p))? .vendor; self.entry_parties.insert(entry, p); + self.entry_no_party.remove(&entry); match vendor { Some(v) => { self.entry_vendors.insert(entry, v); @@ -2861,6 +2869,51 @@ impl Ledger { Ok(()) } + /// Mark an entry as having no counterparty, with why (a transfer + /// between the company's own accounts, an accrual, a correction), so a + /// books policy that requires parties can tell "none" from "not yet". + /// + /// ```text + /// SOURCE: docs/parties-and-attachments.md "Counterparties"; DECISION + /// (Stephen, 2026-10-15). + /// SCENARIOS: + /// 1. IF the entry is unknown, already names a party, or the reason + /// is blank or fails the party text rules: SHOULD refuse. + /// 2. Tagging the entry with a party later clears the mark. + /// ``` + pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> { + self.check_set_entry_no_party(entry, reason)?; + self.entry_no_party.insert(entry, reason.trim().to_string()); + Ok(()) + } + + /// The validation behind [`Self::set_entry_no_party`]. + pub fn check_set_entry_no_party( + &self, + entry: EntryId, + reason: &str, + ) -> Result<(), LedgerError> { + if !self.entries.contains_key(&entry) { + return Err(LedgerError::UnknownEntry(entry)); + } + if self.entry_parties.contains_key(&entry) { + return Err(LedgerError::InvalidParty(format!( + "entry {entry} names a party; clear it first" + ))); + } + if reason.trim().is_empty() { + return Err(LedgerError::InvalidParty( + "say why the entry has no counterparty".into(), + )); + } + crate::parties::check_text("reason", reason).map_err(LedgerError::InvalidParty) + } + + /// Why an entry has no counterparty, when it was marked so. + pub fn entry_no_party(&self, entry: EntryId) -> Option<&str> { + self.entry_no_party.get(&entry).map(String::as_str) + } + /// The vendor an entry is tagged with. pub fn entry_vendor(&self, entry: EntryId) -> Option { self.entry_vendors.get(&entry).copied() diff --git a/crates/ledger-gui/src/app.rs b/crates/ledger-gui/src/app.rs index bac9c0f..be8f424 100644 --- a/crates/ledger-gui/src/app.rs +++ b/crates/ledger-gui/src/app.rs @@ -199,6 +199,13 @@ impl App { } Route::Party(party_id) } + T::Reconciliation { id } => { + // Its own page: statement, balance, the lines it cleared. + if b.reconciliation(id).is_none() { + return; + } + Route::Reconciliation(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 eafd3c9..ce2bb07 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -2,7 +2,8 @@ //! 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, contractors and landlords paid without W-9 facts, and legal +//! tax basis, contractors and landlords paid without W-9 facts, bank and +//! card accounts not reconciled through last month, 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). @@ -47,7 +48,19 @@ use rust_decimal::Decimal; /// (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). +/// pages::parties::collect_w9_prompt, naming books.set_w9); a cash +/// or card account (Books::reconcilable_accounts) whose +/// Books::reconciliation_due is Some(date) (Todo, one per account, +/// titled "Reconcile through ", its prompt from +/// pages::reconciliation::reconcile_prompt, naming +/// books.record_statement and books.reconcile); when `[books] +/// parties` isn't off, the posted entries dated this calendar year +/// (Jan 1 through today) that name no party and aren't marked as +/// having none (Ledger::entry_no_party), leaving out the ones the +/// engine makes from a schedule (period closes, depreciation, +/// amortization: no counterparty by nature) (one item: Info under +/// suggest, Todo under require; its prompt names +/// books.set_entry_party). /// 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. @@ -356,6 +369,70 @@ pub fn find(b: &Books) -> Vec { } } + for a in b.reconcilable_accounts() { + let Some(through) = b.reconciliation_due(a) else { + continue; + }; + let name = b.account_name(a); + out.push(Attention { + severity: Severity::Todo, + title: format!("Reconcile {name} through {}", fmt::date(through)), + detail: match b.reconciled_through(a) { + Some(d) => format!( + "Reconciled through {}. The monthly close reconciles it to each statement since.", + fmt::date(d) + ), + None => "Never reconciled. The monthly close reconciles it to its statement.".into(), + }, + target: Some(Route::Account(a)), + prompt: crate::pages::reconciliation::reconcile_prompt(b, a, through), + }); + } + + let policy = b.settings.parties; + if policy != ledger_config::PartyPolicy::Off { + let year_start = chrono::NaiveDate::from_ymd_opt(today.year(), 1, 1).expect("valid"); + let unnamed: Vec<_> = b + .entries + .iter() + .filter(|e| { + e.status == EntryStatus::Posted + && e.date >= year_start + && e.date <= today + && e.party_id.is_none() + && b.ledger.entry_no_party(e.id).is_none() + && !matches!(e.origin, "Period close" | "Depreciation" | "Amortization") + }) + .collect(); + if !unnamed.is_empty() { + let list: Vec = unnamed + .iter() + .map(|e| format!("{} ({}, {})", e.id, e.description, e.date)) + .collect(); + out.push(Attention { + severity: if policy == ledger_config::PartyPolicy::Require { + Severity::Todo + } else { + Severity::Info + }, + title: format!( + "{} no counterparty", + fmt::count(unnamed.len(), "entry names", "entries name") + ), + detail: format!( + "Posted in {} with no party and not marked as having none. Tagging each entry with who it was with keeps the parties' totals and 1099s complete.", + today.year() + ), + target: Some(Route::Transactions), + prompt: format!( + "In wanderbooks, these posted entries from {} name no counterparty: {}. Propose a party for each, grouped by party (adding any that aren't in the directory yet), and once I agree, tag them with books.set_entry_party. For any with truly no counterparty (like a transfer between the company's own accounts), mark it with books.set_entry_party with party null and a no_party_reason.", + today.year(), + list.join("; ") + ), + }); + } + } + let due = b.depreciation_due(today); if due.amount > Decimal::ZERO { let months = if due.periods.is_empty() { diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 086754c..664e8b8 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -11,12 +11,14 @@ use ledger_core::{ 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 ledger_store::{Attachment, JournalLine, Reconciliation, Snapshot, Statement, Subject}; use rust_decimal::Decimal; use serde_json::Value; use std::collections::{BTreeMap, HashMap, HashSet}; use std::path::PathBuf; +use crate::nav::Route; + /// One entry, indexed for listing and detail. #[derive(Clone, Debug)] pub struct EntryInfo { @@ -28,7 +30,9 @@ pub struct EntryInfo { pub amount: Decimal, /// Accounts touched, debits first: "6300 Software". pub accounts: Vec, - /// Indexes into [`Books::attachments`] of linked, current receipts. + /// Indexes into [`Books::attachments`] of its current receipts: the + /// files filed on it and the files linked to it (`attachment.linked` + /// with an entry subject), as the server's receipt policy counts them. pub receipts: Vec, /// How the entry came to be, in words. pub origin: &'static str, @@ -216,6 +220,78 @@ pub struct Books { pub entry_assets: HashMap, /// Every party's dealings with the company, by party id. pub parties: BTreeMap, + /// Every attachment's subjects (see [`Books::links_of`]), by + /// attachment id; withdrawn attachments keep theirs. + pub links: BTreeMap>, + /// Recorded bank and card statements, by period end. + pub statements: Vec, + /// Reconciliations, voided ones included, by date then id. + pub reconciliations: Vec, +} + +/// A span of dates a party's money is summed over (the party page's and +/// the directory's period picker). +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum PartyPeriod { + /// January 1 of today's year through today. + #[default] + ThisYear, + /// Last calendar year. + LastYear, + /// One calendar year (through today, if it is this one). + Year(i32), + /// The books' first entry through today. + AllTime, +} + +/// What one party's posted entries moved over a span, and what for. +/// +/// ```text +/// GOAL: answer "how much did we spend with them, and how much did we +/// get back?" (Stephen, 2026-10-15) with the books' own numbers. +/// SOURCE: ledger_core::parties::activity (paid, received) and +/// ledger_core::parties::money_accounts (the money set), with +/// Books::cash_accounts, as PartyInfo uses them. +/// INVARIANTS: +/// 1. paid, received: activity(ledger, party, span, cash accounts)'s, +/// exactly. +/// 2. net() = paid − received. +/// 3. what_for: over the party's posted entries dated in the span, each +/// account not in money_accounts with its debits − credits, the +/// accounts whose sum is zero left out, largest magnitude first +/// (then by account id). Positive: what money paid for; negative: +/// where money received came from (or, on payables and +/// receivables, what is still owed). +/// 4. Σ what_for = net(), exactly: every entry balances, so its +/// non-money lines net to what its money lines moved (a reversal +/// reverses both). +/// 5. entries: how many of the party's entries are posted and dated +/// in the span. +/// ``` +#[derive(Clone, Debug, Default, PartialEq)] +pub struct PartyFlow { + pub paid: Decimal, + pub received: Decimal, + pub what_for: Vec<(AccountId, Decimal)>, + pub entries: usize, +} + +impl PartyFlow { + /// Paid out less received in. + pub fn net(&self) -> Decimal { + self.paid - self.received + } +} + +/// One month of a party's money, for the out-versus-in chart. +#[derive(Clone, Debug, PartialEq)] +pub struct PartyMonth { + /// The month's first day. + pub month: NaiveDate, + /// activity's paid over the month (clipped to the span). + pub paid: Decimal, + /// activity's received over the month (clipped to the span). + pub received: Decimal, } /// Whether what's on disk is what the journal says. @@ -343,12 +419,34 @@ impl Books { events.entry(id).or_default().push(i); } } - let mut receipts: HashMap> = HashMap::new(); - for (i, a) in attachments.iter().enumerate() { - if let (Some(e), None) = (a.entry_id, &a.withdrawn_at) { - receipts.entry(e).or_default().push(i); + let link_rows = snap.attachment_links().map_err(|e| e.to_string())?; + let mut links: BTreeMap> = BTreeMap::new(); + for a in &attachments { + if let Some(e) = a.entry_id { + links.entry(a.id).or_default().push(Subject::Entry(e)); } } + for (att, sub) in &link_rows { + links.entry(*att).or_default().push(*sub); + } + // An entry's receipts: filed on it, or linked to it (the server's + // receipt policy counts both). + let mut receipts: HashMap> = HashMap::new(); + for (i, a) in attachments.iter().enumerate() { + if a.withdrawn_at.is_some() { + continue; + } + for sub in links.get(&a.id).into_iter().flatten() { + if let Subject::Entry(e) = sub { + let r = receipts.entry(*e).or_default(); + if !r.contains(&i) { + r.push(i); + } + } + } + } + let statements = snap.statements(None).map_err(|e| e.to_string())?; + let reconciliations = snap.reconciliations(None).map_err(|e| e.to_string())?; let fresh_from_seq = prev_seq.unwrap_or(journal.len() as u64); let mut fresh = HashSet::new(); let closings = ledger.closing_entry_ids(); @@ -488,6 +586,9 @@ impl Books { asset_trails, entry_assets, parties: BTreeMap::new(), + links, + statements, + reconciliations, }; books.parties = books.party_infos(); books.kpis = books.compute_kpis(); @@ -821,6 +922,372 @@ impl Books { } } + // ------------------------------------------------------------------ + // Attachment links, statements, reconciliations + // ------------------------------------------------------------------ + + /// One attachment by id. + pub fn attachment(&self, id: u64) -> Option<&Attachment> { + self.attachments.iter().find(|a| a.id == id) + } + + /// Every subject an attachment is linked to: its filing entry (a + /// receipt filed on an entry) first, then its `attachment.linked` + /// subjects, as `Store::attachment_links` lists them. + pub fn links_of(&self, attachment_id: u64) -> &[Subject] { + self.links.get(&attachment_id).map_or(&[], |v| v.as_slice()) + } + + /// The current (not withdrawn) attachments linked to any of + /// `subjects`, each once, by id. + /// + /// ```text + /// GOAL: every subject's page shows the files that are evidence of it. + /// SOURCE: docs/parties-and-attachments.md (Attachments: an + /// attachment links to any number of subjects; an entry's are + /// those filed on it and those linked to it); + /// Store::attachments_for, which this mirrors. + /// INVARIANTS: + /// 1. a is listed iff it isn't withdrawn and links_of(a) shares a + /// subject with `subjects`. + /// 2. No attachment twice; ascending id. + /// ``` + pub fn documents_for(&self, subjects: &[Subject]) -> Vec<&Attachment> { + self.attachments + .iter() + .filter(|a| a.withdrawn_at.is_none()) + .filter(|a| self.links_of(a.id).iter().any(|s| subjects.contains(s))) + .collect() + } + + /// The subjects whose documents an entry's page shows: the entry, and + /// the invoice, bill, or payroll run it records (those have no page + /// of their own). + pub fn entry_subjects(&self, id: EntryId) -> Vec { + let l = &self.ledger; + let mut out = vec![Subject::Entry(id)]; + out.extend( + l.invoices() + .into_iter() + .filter(|i| i.entry_id == id) + .map(|i| Subject::Invoice(i.id)), + ); + out.extend( + l.bills() + .into_iter() + .filter(|b| b.entry_id == id) + .map(|b| Subject::Bill(b.id)), + ); + out.extend( + l.list_payroll_runs() + .into_iter() + .filter(|r| r.entry_id == id) + .map(|r| Subject::PayrollRun(r.id)), + ); + out + } + + /// A subject in words: "#12 Adobe (…)", "1000 Operating Checking", + /// "WL-0001 MacBook Pro", a party's label, "Invoice 3", "Period 2025", + /// "Reconciliation of 1000 Operating Checking through Sep 30, 2026", + /// "the company". + pub fn subject_label(&self, s: Subject) -> String { + let l = &self.ledger; + match s { + Subject::Entry(e) => self + .entry(e) + .map(|x| format!("#{} {}", x.id, x.description)) + .unwrap_or_else(|| format!("#{e}")), + Subject::Account(a) => self.account_label(a), + Subject::Asset(a) => self.asset_label(a), + Subject::Party(p) => self.party_label(p), + Subject::Invoice(i) => format!("Invoice {i}"), + Subject::Bill(i) => format!("Bill {i}"), + Subject::Period(i) => l + .list_periods() + .iter() + .find(|p| p.id == i) + .map(|p| format!("Period {}", p.name)) + .unwrap_or_else(|| format!("Period {i}")), + Subject::PayrollRun(i) => match l.get_payroll_run(i) { + Some(r) => format!("Payroll {}", crate::fmt::date(r.date)), + None => format!("Payroll run {i}"), + }, + Subject::Schedule(i) => l + .get_schedule(i) + .map(|x| x.description.clone()) + .unwrap_or_else(|| format!("Schedule {i}")), + Subject::Reconciliation(i) => match self.reconciliation(i) { + Some(r) => format!( + "Reconciliation of {} through {}", + self.account_label(r.account_id), + crate::fmt::date(r.statement_date) + ), + None => format!("Reconciliation {i}"), + }, + Subject::Company => "the company".into(), + } + } + + /// Where a subject opens: its page, or (an invoice, bill, or payroll + /// run) the entry that records it, or (a period, schedule, the + /// company) the company page. + pub fn subject_route(&self, s: Subject) -> Option { + let l = &self.ledger; + Some(match s { + Subject::Entry(e) => Route::Entry(e), + Subject::Account(a) => Route::Account(a), + Subject::Asset(a) => Route::Asset(a), + Subject::Party(p) => Route::Party(p), + Subject::Invoice(i) => Route::Entry(l.get_invoice(i)?.entry_id), + Subject::Bill(i) => Route::Entry(l.get_bill(i)?.entry_id), + Subject::PayrollRun(i) => Route::Entry(l.get_payroll_run(i)?.entry_id), + Subject::Reconciliation(i) => Route::Reconciliation(i), + Subject::Period(_) | Subject::Schedule(_) | Subject::Company => Route::Company, + }) + } + + /// The statement an attachment records, if any. + pub fn statement(&self, attachment_id: u64) -> Option<&Statement> { + self.statements + .iter() + .find(|s| s.attachment_id == attachment_id) + } + + /// One account's recorded statements, by period end. + pub fn statements_for(&self, account: AccountId) -> Vec<&Statement> { + self.statements + .iter() + .filter(|s| s.account_id == account) + .collect() + } + + /// One reconciliation by id. + pub fn reconciliation(&self, id: u64) -> Option<&Reconciliation> { + self.reconciliations.iter().find(|r| r.id == id) + } + + /// One account's reconciliations, voided ones included, by date. + pub fn reconciliations_for(&self, account: AccountId) -> Vec<&Reconciliation> { + self.reconciliations + .iter() + .filter(|r| r.account_id == account) + .collect() + } + + /// The latest statement date an account is reconciled through: the + /// newest reconciliation of it not voided (None: never reconciled). + pub fn reconciled_through(&self, account: AccountId) -> Option { + self.reconciliations + .iter() + .filter(|r| r.account_id == account && r.voided.is_none()) + .map(|r| r.statement_date) + .max() + } + + /// The last day of the month before today's. + pub fn last_month_end(&self) -> NaiveDate { + self.month_start() - chrono::Days::new(1) + } + + /// The accounts the books expect to reconcile monthly: the cash + /// accounts, then the card accounts, each once. + pub fn reconcilable_accounts(&self) -> Vec { + let mut out = self.cash_accounts.clone(); + for a in &self.card_accounts { + if !out.contains(a) { + out.push(*a); + } + } + out + } + + /// When an account is due a reconciliation: the date it should be + /// reconciled through, else None. + /// + /// ```text + /// GOAL: the monthly close leaves every bank and card account + /// reconciled through last month's end (docs/parties-and- + /// attachments.md, Reconciliations: "an account with activity + /// not reconciled through last month's end"). + /// SOURCE: that rule; CONVENTION (a bank or card statement closes + /// monthly; AGENTS.md's monthly close reconciles the bank). + /// INVARIANTS: + /// 1. Some(last_month_end()) iff the account has a posted line + /// dated on or before last_month_end() (activity to reconcile) + /// and reconciled_through(account) is None or earlier than + /// last_month_end(). + /// 2. None otherwise (including activity only this month). + /// ``` + pub fn reconciliation_due(&self, account: AccountId) -> Option { + let through = self.last_month_end(); + let active = self + .ledger + .posted_entries() + .any(|e| e.date() <= through && e.lines().iter().any(|l| l.account == account)); + (active && self.reconciled_through(account).is_none_or(|d| d < through)).then_some(through) + } + + // ------------------------------------------------------------------ + // A party's money over a span + // ------------------------------------------------------------------ + + /// The dates a [`PartyPeriod`] covers: a year is January 1 to + /// December 31, cut at today; all time starts at the earliest entry + /// in the books (today when there is none). + pub fn party_span(&self, period: PartyPeriod) -> (NaiveDate, NaiveDate) { + let year = |y: i32| { + ( + NaiveDate::from_ymd_opt(y, 1, 1).expect("valid"), + NaiveDate::from_ymd_opt(y, 12, 31) + .expect("valid") + .min(self.today), + ) + }; + match period { + PartyPeriod::ThisYear => year(self.today.year()), + PartyPeriod::LastYear => year(self.today.year() - 1), + PartyPeriod::Year(y) => year(y), + PartyPeriod::AllTime => { + let first = self + .entries + .iter() + .map(|e| e.date) + .min() + .unwrap_or(self.today) + .min(self.today); + (first, self.today) + } + } + } + + /// The calendar year a period's 1099 amount is for (all time: this + /// year's). + pub fn party_period_year(&self, period: PartyPeriod) -> i32 { + match period { + PartyPeriod::LastYear => self.today.year() - 1, + PartyPeriod::Year(y) => y, + PartyPeriod::ThisYear | PartyPeriod::AllTime => self.today.year(), + } + } + + /// A period in words: "This year (2026)", "Last year (2025)", "2024", + /// "All time". + pub fn party_period_label(&self, period: PartyPeriod) -> String { + let y = self.today.year(); + match period { + PartyPeriod::ThisYear => format!("This year ({y})"), + PartyPeriod::LastYear => format!("Last year ({})", y - 1), + PartyPeriod::Year(x) => x.to_string(), + PartyPeriod::AllTime => "All time".into(), + } + } + + /// The periods to offer for a party (None: for the directory): this + /// year, last year, every other year with a posted entry (the + /// party's, or any for the directory), newest first, then all time. + pub fn party_periods(&self, party: Option) -> Vec { + let y = self.today.year(); + let mut years: Vec = self + .entries + .iter() + .filter(|e| e.status == EntryStatus::Posted && e.date <= self.today) + .filter(|e| party.is_none_or(|p| e.party_id == Some(p))) + .map(|e| e.date.year()) + .filter(|&x| x < y - 1) + .collect(); + years.sort_unstable_by(|a, b| b.cmp(a)); + years.dedup(); + let mut out = vec![PartyPeriod::ThisYear, PartyPeriod::LastYear]; + out.extend(years.into_iter().map(PartyPeriod::Year)); + out.push(PartyPeriod::AllTime); + out + } + + /// What a party's posted entries moved over `span` (see + /// [`PartyFlow`]). `with_breakdown` false leaves what_for empty (the + /// directory needs only the totals). + pub fn party_flow( + &self, + party: PartyId, + span: (NaiveDate, NaiveDate), + with_breakdown: bool, + ) -> PartyFlow { + let l = &self.ledger; + let a = ledger_core::parties::activity(l, party, span, &self.cash_accounts); + let posted: Vec<&ledger_core::JournalEntry> = a + .entries + .iter() + .filter_map(|&id| l.get_entry(id)) + .filter(|e| { + e.status() == EntryStatus::Posted && e.date() >= span.0 && e.date() <= span.1 + }) + .collect(); + let mut what_for = Vec::new(); + if with_breakdown { + let money = ledger_core::parties::money_accounts(l, &self.cash_accounts); + let mut by: BTreeMap = BTreeMap::new(); + for e in &posted { + for line in e.lines().iter().filter(|x| !money.contains(&x.account)) { + *by.entry(line.account).or_default() += match line.side { + Side::Debit => line.amount.amount, + Side::Credit => -line.amount.amount, + }; + } + } + what_for = by.into_iter().filter(|(_, v)| !v.is_zero()).collect(); + what_for.sort_by(|x, y| y.1.abs().cmp(&x.1.abs()).then(x.0.cmp(&y.0))); + } + PartyFlow { + paid: a.paid, + received: a.received, + what_for, + entries: posted.len(), + } + } + + /// A party's 1099 worksheet line for a calendar year and that year's + /// threshold: ledger-core's vendor_1099_report_with (cash and + /// financing accounts) line for its vendor record; None without a + /// vendor record in the report, or when the year's threshold isn't + /// known. + pub fn party_1099(&self, party: PartyId, year: i32) -> Option<(Vendor1099Line, Decimal)> { + let l = &self.ledger; + let vendor = l.get_party(party)?.vendor?; + let sources = ledger_core::PaymentAccounts { + cash: self.cash_accounts.clone(), + financing: self.financing_accounts.clone(), + }; + let r = ledger_core::vendor_1099_report_with(l, year, None, &sources).ok()?; + let line = r.vendors.iter().find(|v| v.vendor_id == vendor)?.clone(); + Some((line, r.threshold)) + } + + /// A party's money month by month over `span`: every month the span + /// touches, each month's activity clipped to the span (so the months + /// sum to the span's paid and received exactly). + pub fn party_months(&self, party: PartyId, span: (NaiveDate, NaiveDate)) -> Vec { + let mut out = Vec::new(); + let mut m = NaiveDate::from_ymd_opt(span.0.year(), span.0.month(), 1).expect("valid"); + while m <= span.1 { + let end = (m + chrono::Months::new(1) - chrono::Days::new(1)).min(span.1); + let start = m.max(span.0); + let a = ledger_core::parties::activity( + &self.ledger, + party, + (start, end), + &self.cash_accounts, + ); + out.push(PartyMonth { + month: m, + paid: a.paid, + received: a.received, + }); + m = m + chrono::Months::new(1); + } + out + } + /// Blob store path for an attachment. pub fn blob_root(&self) -> PathBuf { self.data_dir.join("blobs") diff --git a/crates/ledger-gui/src/demo.rs b/crates/ledger-gui/src/demo.rs index daf2692..b97f5bb 100644 --- a/crates/ledger-gui/src/demo.rs +++ b/crates/ledger-gui/src/demo.rs @@ -2,15 +2,17 @@ //! small design studio (single-member LLC) with client invoices, template //! sales paid out by a platform, contractors (one without a W-9), rent on a //! recurring schedule, a credit card, a laptop on depreciation, prepaid -//! insurance, owner draws, a closed year, PDF receipts, and a few loose -//! ends for the "needs attention" list. Deterministic for a given `today`. +//! insurance, owner draws, a closed year, PDF receipts, bank statements +//! with reconciliations (one voided and redone), documents linked to the +//! company, an asset, and a party, and a few loose ends for the "needs +//! attention" list. Deterministic for a given `today`. use chrono::{Datelike, Days, Months, NaiveDate}; use ledger_core::{ AccountType, CloseTo, Currency, Form1099, Frequency, Money, NewAsset, NewRecurring, NewSchedule, ScheduleKind, Side, arap::DocumentLine, }; -use ledger_store::{Store, StoreError}; +use ledger_store::{Statement, Store, StoreError, Subject}; use rust_decimal::Decimal; use std::path::Path; @@ -339,7 +341,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { let alex = s.add_party("Alex Rivera", &[ledger_core::Role::Member])?; // Formation documents. - s.file_document( + let (articles, _, _) = s.file_document( "formation", "Articles of Organization.pdf", "application/pdf", @@ -353,7 +355,7 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { ), Some("Filed with the state"), )?; - s.file_document( + let (ein_letter, _, _) = s.file_document( "tax", "IRS EIN confirmation.pdf", "application/pdf", @@ -894,5 +896,163 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> { month_index += 1; } let _ = (bank_fees, client_class); + + // The company's papers, linked to it; the laptop's warranty and + // photo, linked to the asset; Sam's contract, linked to Sam and the + // company. + s.link_attachment(articles, Subject::Company)?; + s.link_attachment(ein_letter, Subject::Company)?; + let (warranty, _, _) = s.file_document( + "warranty", + "MacBook Pro AppleCare.pdf", + "application/pdf", + &receipt_pdf( + "AppleCare+ for Mac", + &[ + "Agreement for MacBook Pro 14-inch".into(), + format!("Coverage from {}", laptop_date.format("%B %-d, %Y")), + "Three years of hardware coverage".into(), + ], + ), + Some("Warranty, three years"), + )?; + s.link_attachment(warranty, Subject::Asset(asset))?; + let sam_party = s.ledger().party_of_vendor(sam).expect("linked"); + let (contract_doc, _, _) = s.file_document( + "contract", + "Sam Okafor services agreement.pdf", + "application/pdf", + &receipt_pdf( + "Independent Contractor Agreement", + &[ + "Juniper Studio LLC and Sam Okafor".into(), + "Illustration services, billed per project".into(), + format!("Signed {}", (start + Months::new(1)).format("%B %-d, %Y")), + ], + ), + Some("Signed agreement"), + )?; + s.link_attachment(contract_doc, Subject::Party(sam_party))?; + s.link_attachment(contract_doc, Subject::Company)?; + + // Statements and reconciliations: savings reconciled through last + // month; checking through the month before (its first reconciliation + // voided and redone against the bank's reissued statement, one check + // outstanding at the first statement); the card never. + let month_end = |m: NaiveDate| m + Months::new(1) - Days::new(1); + let lines_on = |s: &Store, account: u64, through: NaiveDate| -> Vec<(u64, usize, NaiveDate)> { + let mut out = Vec::new(); + for e in s.ledger().posted_entries() { + if e.date() <= through { + for (i, l) in e.lines().iter().enumerate() { + if l.account == account && !s.ledger().is_cleared(e.id(), i) { + out.push((e.id(), i, e.date())); + } + } + } + } + out.sort_by_key(|x| (x.2, x.0, x.1)); + out + }; + let statement = |s: &mut Store, + account: u64, + name: &str, + month: NaiveDate, + note: &str, + opening: Option| + -> Result { + let end = month_end(month); + // The bank's opening is its last closing: what had cleared by then. + let opening = match opening { + Some(o) => o, + None => { + s.ledger() + .reconciliation_report(account, month - Days::new(1), Decimal::ZERO)? + .cleared_balance + } + }; + let closing = s + .ledger() + .reconciliation_report(account, end, Decimal::ZERO)? + .cleared_balance; + let (att, _, _) = s.file_document( + "statement", + &format!( + "{name} statement {}{}.pdf", + month.format("%Y-%m"), + if note.is_empty() { + String::new() + } else { + format!(" ({})", note.to_lowercase()) + } + ), + "application/pdf", + &receipt_pdf( + &format!("{name} statement"), + &[ + format!( + "{} through {}", + month.format("%B %-d, %Y"), + end.format("%B %-d, %Y") + ), + format!("Opening balance: ${opening}"), + format!("Closing balance: ${closing}"), + note.to_string(), + ], + ), + Some(&format!("{name}, {}", month.format("%B %Y"))), + )?; + s.record_statement(Statement { + attachment_id: att, + account_id: account, + period_start: month, + period_end: end, + opening_balance: opening, + closing_balance: closing, + })?; + Ok(att) + }; + let last_month = this_month - Months::new(1); + // Savings: every line through last month cleared. + for (e, i, _) in lines_on(&s, savings, month_end(last_month)) { + s.mark_cleared(e, i, true)?; + } + let st = statement(&mut s, savings, "Savings", last_month, "", None)?; + s.complete_reconciliation(st)?; + // Checking, three months back: the month's last check still out. + let m1 = this_month - Months::new(3); + let pending = lines_on(&s, checking, month_end(m1)); + let outstanding = pending.last().map(|x| (x.0, x.1)); + for (e, i, _) in pending { + if Some((e, i)) != outstanding { + s.mark_cleared(e, i, true)?; + } + } + let first = statement(&mut s, checking, "Operating Checking", m1, "", None)?; + let voided = s.complete_reconciliation(first)?; + s.void_reconciliation( + voided.id, + "The bank reissued this statement; reconciled again against the corrected copy", + )?; + let reissued = statement(&mut s, checking, "Operating Checking", m1, "Reissued", None)?; + let m1_closing = s + .ledger() + .reconciliation_report(checking, month_end(m1), Decimal::ZERO)? + .cleared_balance; + s.complete_reconciliation(reissued)?; + // Two months back: everything through it cleared. + let m2 = this_month - Months::new(2); + for (e, i, _) in lines_on(&s, checking, month_end(m2)) { + s.mark_cleared(e, i, true)?; + } + let second = statement( + &mut s, + checking, + "Operating Checking", + m2, + "", + Some(m1_closing), + )?; + s.complete_reconciliation(second)?; Ok(()) } diff --git a/crates/ledger-gui/src/nav.rs b/crates/ledger-gui/src/nav.rs index cdcc199..dff10d3 100644 --- a/crates/ledger-gui/src/nav.rs +++ b/crates/ledger-gui/src/nav.rs @@ -59,6 +59,9 @@ pub enum Route { Reports(ReportKind), Accounts, Account(AccountId), + /// One reconciliation: its statement, balance, and the lines it + /// cleared. + Reconciliation(u64), /// The fixed asset register. Assets, /// One fixed asset. @@ -83,7 +86,7 @@ impl Route { Route::Profit => Section::Profit, Route::Transactions | Route::Entry(_) => Section::Transactions, Route::Reports(_) => Section::Reports, - Route::Accounts | Route::Account(_) => Section::Accounts, + Route::Accounts | Route::Account(_) | Route::Reconciliation(_) => Section::Accounts, Route::Assets | Route::Asset(_) => Section::Assets, Route::Parties | Route::Party(_) => Section::Parties, Route::Documents | Route::Document(_) => Section::Documents, diff --git a/crates/ledger-gui/src/pages/accounts.rs b/crates/ledger-gui/src/pages/accounts.rs index ab9feb6..2b5da1f 100644 --- a/crates/ledger-gui/src/pages/accounts.rs +++ b/crates/ledger-gui/src/pages/accounts.rs @@ -1,6 +1,8 @@ //! The chart of accounts with balances, and one account's register: every -//! posting with a running balance and the balance over time. +//! posting with a running balance and the balance over time, its bank or +//! card statements, its reconciliation history, and its documents. +use super::assets::row; use super::page; use crate::app::Shell; use crate::books::Books; @@ -11,9 +13,11 @@ use crate::widgets; use chrono::NaiveDate; use egui::{CornerRadius, RichText, Sense, Ui, vec2}; use egui_phosphor::regular as icon; +use ledger_core::AccountId; use ledger_core::AccountType; use ledger_core::analysis::balance_history; use ledger_core::reports::general_ledger; +use ledger_store::Subject; use rust_decimal::Decimal; #[derive(Default)] @@ -185,7 +189,10 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { .color(p.muted), &fmt::plain(&fmt::dollars(bal)).unwrap_or_default(), ); - ui.add_space(14.0); + ui.add_space(6.0); + let mut go_to: Option = None; + reconciled_badge(ui, b, id); + ui.add_space(8.0); let hist = balance_history(&b.ledger, &[id]); if hist.len() > 1 && a.account_type.is_balance_sheet() { widgets::titled_card( @@ -196,6 +203,19 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { ); ui.add_space(12.0); } + statements_and_reconciliations(ui, b, id, &mut go_to); + super::documents::documents_card( + ui, + b, + "Documents", + &[Subject::Account(id)], + "No documents linked to this account (statements, the agreement that opened it).", + &mut go_to, + ); + ui.add_space(14.0); + if let Some(r) = go_to.take() { + shell.go(r); + } ui.horizontal_wrapped(|ui| { ui.label( RichText::new(format!( @@ -401,3 +421,179 @@ pub fn show_account(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { } }); } + +/// Where the account stands on reconciliation: "Reconciled through +/// " (in green when that covers last month, else with a notice and +/// the agent prompt), or "Never reconciled" for an account the books +/// expect to reconcile or one with statements. Nothing for other +/// accounts. +fn reconciled_badge(ui: &mut Ui, b: &Books, id: AccountId) { + let p = pal(ui.ctx()); + let expected = b.reconcilable_accounts().contains(&id); + let through = b.reconciled_through(id); + if !expected && through.is_none() && b.statements_for(id).is_empty() { + return; + } + let due = b.reconciliation_due(id); + ui.horizontal_wrapped(|ui| { + match through { + Some(d) => { + let (fg, bg) = if due.is_some() { + (p.warning, p.warning_soft) + } else { + (p.positive, p.positive_soft) + }; + widgets::pill( + ui, + &format!("{} Reconciled through {}", icon::CHECK_SQUARE, fmt::date(d)), + fg, + bg, + ); + } + None => { + widgets::pill(ui, "Never reconciled", p.warning, p.warning_soft); + } + } + if let Some(d) = due { + ui.label( + RichText::new(format!( + "Due through {}: the monthly close reconciles every bank and card account to its statement.", + fmt::date(d) + )) + .font(theme::regular(12.5)) + .color(p.muted), + ); + widgets::copy_prompt(ui, &super::reconciliation::reconcile_prompt(b, id, d)); + } + }); +} + +/// The account's statements (period, opening, closing, the file) and its +/// reconciliations (date, statement balance, cleared lines, standing), +/// side by side when there's room. Nothing when it has neither. +fn statements_and_reconciliations(ui: &mut Ui, b: &Books, id: AccountId, go: &mut Option) { + let p = pal(ui.ctx()); + let statements = b.statements_for(id); + let recs = b.reconciliations_for(id); + if statements.is_empty() && recs.is_empty() { + return; + } + super::columns(ui, 1100.0, &[1.0, 1.0], |col, ui| { + if col == 0 { + widgets::titled_card( + ui, + "Statements", + |_| {}, + |ui| { + if statements.is_empty() { + ui.label(RichText::new("No statements recorded.").color(p.muted)); + return; + } + let amt = widgets::AMOUNT_MIN + 8.0; + let w = ui.available_width().max(180.0 + amt * 2.0 + 120.0); + widgets::min_width(ui, "statements", w, |ui| { + let head = + |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Period", 0.0, false), + head("Opening", amt, true), + head("Closing", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + for st in statements.iter().rev() { + let file = b + .attachment(st.attachment_id) + .map_or_else(String::new, |a| a.file_name.clone()); + let resp = row( + ui, + w, + &[ + ( + format!( + "{} – {}", + fmt::date_short(st.period_start), + fmt::date(st.period_end) + ), + 0.0, + false, + p.text, + ), + (fmt::money(st.opening_balance), amt, true, p.muted), + (fmt::money(st.closing_balance), amt, true, p.text), + ], + Some((&format!("{} {file}", icon::FILE_PDF), p.faint)), + false, + true, + ); + if resp.on_hover_text("Open the statement").clicked() { + *go = Some(Route::Document(st.attachment_id)); + } + } + }); + }, + ); + } else { + widgets::titled_card( + ui, + "Reconciliations", + |_| {}, + |ui| { + if recs.is_empty() { + ui.label(RichText::new("Never reconciled.").color(p.muted)); + return; + } + let amt = widgets::AMOUNT_MIN + 8.0; + let w = ui.available_width().max(130.0 + amt + 70.0 + 110.0); + widgets::min_width(ui, "reconciliations", w, |ui| { + let head = + |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted); + row( + ui, + w, + &[ + head("Through", 0.0, false), + head("Balance", amt, true), + head("Lines", 70.0, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + for r in recs.iter().rev() { + use super::reconciliation::Standing; + let (sub, color) = match super::reconciliation::standing(b, r) { + Standing::Current => ("Current".to_string(), p.positive), + Standing::Superseded => ("Superseded".to_string(), p.faint), + Standing::Voided(why) => (format!("Voided: {why}"), p.warning), + }; + let text = if r.voided.is_some() { p.muted } else { p.text }; + let resp = row( + ui, + w, + &[ + (fmt::date(r.statement_date), 0.0, false, text), + (fmt::money(r.statement_balance), amt, true, text), + (r.cleared.len().to_string(), 70.0, true, p.muted), + ], + Some((&sub, color)), + false, + true, + ); + if resp.on_hover_text("Open this reconciliation").clicked() { + *go = Some(Route::Reconciliation(r.id)); + } + } + }); + }, + ); + } + }); +} diff --git a/crates/ledger-gui/src/pages/assets.rs b/crates/ledger-gui/src/pages/assets.rs index 63bf850..5ae2ee0 100644 --- a/crates/ledger-gui/src/pages/assets.rs +++ b/crates/ledger-gui/src/pages/assets.rs @@ -1164,58 +1164,16 @@ pub fn show_asset(ui: &mut Ui, b: &Books, id: AssetId, shell: &mut Shell<'_>) { }, ); ui.add_space(14.0); - let docs: Vec<_> = b - .attachments - .iter() - .filter(|x| { - x.withdrawn_at.is_none() && x.entry_id.is_some_and(|e| entries.contains(&e)) - }) - .collect(); - widgets::titled_card( + let mut subjects = vec![ledger_store::Subject::Asset(id)]; + subjects.extend(entries.iter().map(|e| ledger_store::Subject::Entry(*e))); + super::documents::documents_card_with( ui, + b, "Receipts and documents", - |_| {}, - |ui| { - if docs.is_empty() { - ui.label( - RichText::new("None linked to its entries (depreciation needs none: the schedule is its evidence).") - .color(p.muted), - ); - } - for d in &docs { - ui.horizontal(|ui| { - ui.label( - RichText::new(if d.mime_type == "application/pdf" { - icon::FILE_PDF - } else { - icon::FILE_IMAGE - }) - .color(p.muted), - ); - if widgets::link_truncated( - ui, - RichText::new(format!( - "{} · {} · entry #{}", - d.file_name, - fmt::label(&d.category), - d.entry_id.unwrap_or(0) - )) - .font(theme::regular(13.0)), - ) - .clicked() - { - go = Some(Route::Document(d.id)); - } - }); - if let Some(desc) = &d.description { - ui.label( - RichText::new(desc) - .font(theme::regular(11.5)) - .color(p.faint), - ); - } - } - }, + &subjects, + &[ledger_store::Subject::Asset(id)], + "None linked to it or its entries (photos, the warranty, the manual; depreciation needs none: the schedule is its evidence).", + &mut go, ); ui.add_space(14.0); diff --git a/crates/ledger-gui/src/pages/company.rs b/crates/ledger-gui/src/pages/company.rs index fe9568c..a316ebf 100644 --- a/crates/ledger-gui/src/pages/company.rs +++ b/crates/ledger-gui/src/pages/company.rs @@ -180,6 +180,32 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { ); } }); + let mut subjects = vec![ledger_store::Subject::Company]; + subjects.extend( + b.ledger + .list_periods() + .iter() + .map(|x| ledger_store::Subject::Period(x.id)), + ); + subjects.extend( + b.ledger + .schedules() + .map(|x| ledger_store::Subject::Schedule(x.id)), + ); + let mut go = None; + super::documents::documents_card_with( + ui, + b, + "Company documents", + &subjects, + &[ledger_store::Subject::Company], + "No documents linked to the company, its periods, or its schedules yet (formation papers, the operating agreement, tax filings).", + &mut go, + ); + ui.add_space(16.0); + if let Some(r) = go { + shell.go(r); + } assets(ui, b, shell); schedules(ui, b); recurring(ui, b); diff --git a/crates/ledger-gui/src/pages/documents.rs b/crates/ledger-gui/src/pages/documents.rs index 707e02f..db5f512 100644 --- a/crates/ledger-gui/src/pages/documents.rs +++ b/crates/ledger-gui/src/pages/documents.rs @@ -1,5 +1,7 @@ -//! Every receipt and document the books hold, rendered: what it is, what -//! entry it proves, and whether it was withdrawn (and why). +//! Every receipt and document the books hold, rendered: what it is, +//! everything it is linked to, and whether it was withdrawn (and why). +//! Also the compact "Documents" card every subject's page shows +//! ([`documents_card`]). use super::page; use crate::app::Shell; @@ -11,7 +13,7 @@ use crate::theme::{self, pal}; use crate::widgets; use egui::{CornerRadius, RichText, Sense, Stroke, Ui, vec2}; use egui_phosphor::regular as icon; -use ledger_store::Attachment; +use ledger_store::{Attachment, Subject}; #[derive(Default)] pub struct State { @@ -275,23 +277,36 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) { &a.blob_sha, ); ui.end_row(); - if let Some(e) = a.entry_id { - k(ui, "Proves"); - let label = b - .entry(e) - .map(|x| { - format!("#{} {} ({})", x.id, x.description, fmt::dollars(x.amount)) - }) - .unwrap_or_else(|| format!("#{e}")); - if widgets::link_truncated(ui, RichText::new(label).color(p.accent)) - .clicked() - { - shell.go(Route::Entry(e)); - } + if let Some(st) = b.statement(a.id) { + k(ui, "Statement"); + ui.add( + egui::Label::new(format!( + "{}, {} – {}", + b.account_label(st.account_id), + fmt::date(st.period_start), + fmt::date(st.period_end) + )) + .wrap(), + ); + ui.end_row(); + k(ui, "Opening"); + ui.label(fmt::dollars(st.opening_balance)); + ui.end_row(); + k(ui, "Closing"); + ui.label( + RichText::new(fmt::dollars(st.closing_balance)) + .font(theme::semibold(13.5)), + ); ui.end_row(); } }); ui.add_space(10.0); + let mut go = None; + links_section(ui, b, a, &mut go); + if let Some(r) = go { + shell.go(r); + } + ui.add_space(10.0); super::entry::render(ui, b, a, shell, ui.available_width()); if let Some(d) = &a.description { ui.add_space(6.0); @@ -308,3 +323,243 @@ fn detail(ui: &mut Ui, b: &Books, a: &Attachment, shell: &mut Shell<'_>) { } }); } + +/// The icon for a subject's kind. +pub fn subject_icon(s: Subject) -> &'static str { + match s { + Subject::Entry(_) => icon::RECEIPT, + Subject::Account(_) => icon::TREE_STRUCTURE, + Subject::Asset(_) => icon::PACKAGE, + Subject::Party(_) => icon::ADDRESS_BOOK, + Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT, + Subject::Period(_) => icon::CALENDAR_BLANK, + Subject::PayrollRun(_) => icon::USERS, + Subject::Schedule(_) => icon::CALENDAR_CHECK, + Subject::Reconciliation(_) => icon::CHECK_SQUARE, + Subject::Company => icon::BUILDINGS, + } +} + +/// A small chip naming a subject, which opens it (see +/// [`Books::subject_route`]) when it has somewhere to go. +pub fn subject_chip(ui: &mut Ui, b: &Books, s: Subject, go: &mut Option) { + let p = pal(ui.ctx()); + let route = b.subject_route(s); + // A reconciliation's chip names its date before anything is cut. + let mut label = match s { + Subject::Reconciliation(r) => b.reconciliation(r).map_or_else( + || b.subject_label(s), + |r| { + format!( + "Reconciliation, {} through {}", + b.account_name(r.account_id), + fmt::date(r.statement_date) + ) + }, + ), + _ => b.subject_label(s), + }; + if label.chars().count() > 60 { + label = label.chars().take(59).collect::() + "…"; + } + let r = ui.add( + egui::Button::new( + RichText::new(format!("{} {label}", subject_icon(s))) + .font(theme::regular(11.5)) + .color(if route.is_some() { p.accent } else { p.muted }), + ) + .fill(if route.is_some() { + p.accent_soft + } else { + p.hover + }) + .stroke(Stroke::NONE) + .corner_radius(CornerRadius::same(255)) + .min_size(vec2(0.0, 22.0)), + ); + if let Some(to) = route + && r.on_hover_text(format!("Open {}", b.subject_label(s))) + .clicked() + { + *go = Some(to); + } +} + +/// The compact list of the documents linked to `subjects`: each file's +/// name (opening its document page), category and description, and the +/// other places it is linked as chips that open them. +/// +/// ```text +/// GOAL: every subject's page shows the files that are evidence of it, +/// and where else each one is evidence. +/// SOURCE: docs/parties-and-attachments.md (Attachments, Viewer). +/// INVARIANTS: +/// 1. Lists exactly Books::documents_for(subjects), in that order. +/// 2. A file's chips are links_of(file) less `here` (the page's own +/// subject; [`documents_card`] passes `subjects`). +/// 3. Empty: `empty` in muted text (nothing else). +/// ``` +pub fn documents_card_with( + ui: &mut Ui, + b: &Books, + title: &str, + subjects: &[Subject], + here: &[Subject], + empty: &str, + go: &mut Option, +) { + let p = pal(ui.ctx()); + let docs = b.documents_for(subjects); + widgets::titled_card( + ui, + title, + |ui| { + if !docs.is_empty() { + widgets::pill(ui, &docs.len().to_string(), p.muted, p.hover); + } + }, + |ui| { + if docs.is_empty() { + ui.add(egui::Label::new(RichText::new(empty).color(p.muted)).wrap()); + return; + } + for (i, d) in docs.iter().enumerate() { + if i > 0 { + ui.add_space(4.0); + widgets::rule(ui); + ui.add_space(4.0); + } + document_row(ui, b, d, here, go); + } + }, + ); +} + +/// [`documents_card_with`], the chips leaving out `subjects` themselves. +pub fn documents_card( + ui: &mut Ui, + b: &Books, + title: &str, + subjects: &[Subject], + empty: &str, + go: &mut Option, +) { + documents_card_with(ui, b, title, subjects, subjects, empty, go); +} + +/// One file in a [`documents_card_with`]. +fn document_row(ui: &mut Ui, b: &Books, d: &Attachment, here: &[Subject], go: &mut Option) { + let p = pal(ui.ctx()); + ui.horizontal_top(|ui| { + ui.label(RichText::new(file_icon(d)).size(18.0).color(p.muted)); + ui.vertical(|ui| { + ui.spacing_mut().item_spacing.y = 2.0; + if widgets::link_truncated(ui, RichText::new(&d.file_name).font(theme::semibold(13.0))) + .on_hover_text("Open this document") + .clicked() + { + *go = Some(Route::Document(d.id)); + } + let mut sub = vec![]; + match b.statement(d.id) { + None => sub.push(fmt::label(&d.category)), + Some(st) => sub.push(format!( + "Statement {} – {}, closing {}", + fmt::date_short(st.period_start), + fmt::date(st.period_end), + fmt::dollars(st.closing_balance) + )), + } + if let Some(desc) = &d.description { + sub.push(desc.clone()); + } + ui.add( + egui::Label::new( + RichText::new(sub.join(" · ")) + .font(theme::regular(11.5)) + .color(p.muted), + ) + .wrap(), + ); + let elsewhere: Vec = b + .links_of(d.id) + .iter() + .copied() + .filter(|s| !here.contains(s)) + .collect(); + if !elsewhere.is_empty() { + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + ui.label( + RichText::new("Also on") + .font(theme::regular(11.5)) + .color(p.faint), + ); + for s in elsewhere { + subject_chip(ui, b, s, go); + } + }); + } + }); + }); +} + +/// A PDF or an image. +pub fn file_icon(a: &Attachment) -> &'static str { + if a.mime_type == "application/pdf" { + icon::FILE_PDF + } else if a.mime_type.starts_with("image/") { + icon::FILE_IMAGE + } else { + icon::FILE + } +} + +/// Everything a document is linked to: a chip per subject (an entry's +/// with its date and amount in the label), opening it; and, for a +/// statement, the reconciliations that used it. +fn links_section(ui: &mut Ui, b: &Books, a: &Attachment, go: &mut Option) { + let p = pal(ui.ctx()); + let links = b.links_of(a.id); + ui.label( + RichText::new("LINKED TO") + .font(theme::semibold(11.5)) + .color(p.faint), + ); + ui.add_space(4.0); + if links.is_empty() { + ui.label( + RichText::new("Nothing yet: filed on its own.") + .font(theme::regular(12.5)) + .color(p.muted), + ); + } + for s in links { + ui.horizontal(|ui| { + subject_chip(ui, b, *s, go); + if let Subject::Entry(e) = s + && let Some(x) = b.entry(*e) + { + ui.label( + RichText::new(format!( + "{} · {}", + fmt::date(x.date), + fmt::dollars(x.amount) + )) + .font(theme::regular(11.5)) + .color(p.muted), + ); + } + if let Subject::Reconciliation(r) = s + && let Some(r) = b.reconciliation(*r) + && let Some(why) = &r.voided + { + ui.label( + RichText::new(format!("voided: {why}")) + .font(theme::regular(11.5)) + .color(p.warning), + ); + } + }); + } +} diff --git a/crates/ledger-gui/src/pages/entry.rs b/crates/ledger-gui/src/pages/entry.rs index 7db2a36..866ad96 100644 --- a/crates/ledger-gui/src/pages/entry.rs +++ b/crates/ledger-gui/src/pages/entry.rs @@ -107,6 +107,19 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { shell.go(Route::Party(party.id)); } } + if e.party_id.is_none() + && let Some(why) = b.ledger.entry_no_party(e.id) + { + ui.add_space(4.0); + ui.add( + egui::Label::new( + RichText::new(format!("{} No counterparty: {why}", icon::USER_MINUS)) + .color(p.muted), + ) + .wrap(), + ) + .on_hover_text("Marked as having no counterparty (entry.no_party)"); + } if let Some(&asset) = b.entry_assets.get(&e.id) { ui.add_space(4.0); if widgets::link( @@ -268,18 +281,25 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { }); ui.add_space(18.0); - // Receipts. + // Receipts and documents: filed on it, linked to it, or linked + // to the invoice, bill, or payroll run it records; a withdrawn + // receipt filed on it stays, marked. + let subjects = b.entry_subjects(e.id); let all_for_entry: Vec<&Attachment> = b .attachments .iter() - .filter(|a| a.entry_id == Some(e.id)) + .filter(|a| { + a.entry_id == Some(e.id) + || (a.withdrawn_at.is_none() + && b.links_of(a.id).iter().any(|s| subjects.contains(s))) + }) .collect(); section( ui, if all_for_entry.len() == 1 { "Receipt" } else { - "Receipts" + "Receipts and documents" }, ); if all_for_entry.is_empty() { @@ -295,6 +315,29 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) { })); } for a in all_for_entry { + let elsewhere: Vec = b + .links_of(a.id) + .iter() + .copied() + .filter(|s| *s != ledger_store::Subject::Entry(e.id)) + .collect(); + if !elsewhere.is_empty() { + let mut go = None; + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(4.0, 4.0); + ui.label( + RichText::new("Linked to") + .font(theme::regular(11.5)) + .color(p.faint), + ); + for s in elsewhere { + super::documents::subject_chip(ui, b, s, &mut go); + } + }); + if let Some(r) = go { + shell.go(r); + } + } attachment_view(ui, b, a, shell, true); ui.add_space(12.0); } diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 17fe22e..12df6cc 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -93,25 +93,26 @@ fn tax_basis_text(b: &Books, asset_id: Option, p: &Value) -> String { } /// What an attachment link's subject (`{"kind": .., "id": ..}`) is, in -/// words, and where it opens. +/// words (Books::subject_label), and where it opens +/// (Books::subject_route). 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), + let parsed = + subject.and_then(|v| serde_json::from_value::(v.clone()).ok()); + match parsed { + Some(ledger_store::Subject::Company) => ("the company".into(), Some(Route::Company)), + Some(sub) => (b.subject_label(sub), b.subject_route(sub)), + None => ("a subject".into(), None), } } +/// A date from the payload, in words ("Sep 30, 2026"), or as written. +fn date_text(p: &Value, k: &str) -> String { + let t = s(p, k); + t.parse::() + .map(fmt::date) + .unwrap_or_else(|_| t.to_string()) +} + /// A party's label, or "a party" when the event names none. fn party_name(b: &Books, id: Option) -> String { match id { @@ -272,10 +273,10 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { Some(Event { icon: icon::FILE_TEXT, text: format!( - "Statement for {}, {} to {}: closing balance {closing}", + "Recorded {}'s statement, {} – {}: closing balance {closing}", account.map_or("an account".into(), |a| b.account_label(a)), - s(p, "period_start"), - s(p, "period_end") + date_text(p, "period_start"), + date_text(p, "period_end") ), route: Some(Route::Document(att)), also: account.map(Route::Account), @@ -287,25 +288,48 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { .parse() .map(fmt::dollars) .unwrap_or_default(); + let lines = p + .get("cleared") + .and_then(|v| v.as_array()) + .map_or(0, |v| v.len()); Some(Event { icon: icon::CHECK_SQUARE, text: format!( - "Reconciled {} through {} at {balance}", + "Reconciled {} through {} at {balance} ({} cleared)", account.map_or("an account".into(), |a| b.account_label(a)), - s(p, "statement_date") + date_text(p, "statement_date"), + fmt::count(lines, "line", "lines") ), - route: account.map(Route::Account), - also: p - .get("attachment_id") + route: p + .get("id") .and_then(|v| v.as_u64()) - .map(Route::Document), + .map(Route::Reconciliation), + also: account.map(Route::Account), + }) + } + "reconciliation.voided" => { + let r = p + .get("id") + .and_then(|v| v.as_u64()) + .and_then(|i| b.reconciliation(i)); + Some(Event { + icon: icon::PROHIBIT, + text: match r { + Some(r) => format!( + "Voided the reconciliation of {} through {}: {}", + b.account_label(r.account_id), + fmt::date(r.statement_date), + s(p, "reason") + ), + None => format!("Voided reconciliation {id}: {}", s(p, "reason")), + }, + route: p + .get("id") + .and_then(|v| v.as_u64()) + .map(Route::Reconciliation), + also: r.map(|r| Route::Account(r.account_id)), }) } - "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")), @@ -697,6 +721,15 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { ), entry_id.map(Route::Entry), ), + "entry.no_party" => ev( + icon::USER_MINUS, + format!( + "Entry #{} marked as having no counterparty: {}", + entry_id.unwrap_or(0), + s(p, "reason") + ), + entry_id.map(Route::Entry), + ), "line.cleared" => ev( icon::CHECK_SQUARE, format!("Cleared a line of #{}", entry_id.unwrap_or(0)), @@ -933,11 +966,14 @@ fn detail(ui: &mut Ui, b: &Books, line: &JournalLine, shell: &mut Shell<'_>) { for r in ev.route.iter().chain(&ev.also) { ui.add_space(6.0); if ui - .button(format!( - "{} Open {}", - icon::ARROW_SQUARE_OUT, - crate::palette::describe(b, r) - )) + .button(format!("{} Open {}", icon::ARROW_SQUARE_OUT, { + let d = crate::palette::describe(b, r); + if d.is_empty() { + r.section().title().to_string() + } else { + d + } + })) .clicked() { shell.go(r.clone()); diff --git a/crates/ledger-gui/src/pages/mod.rs b/crates/ledger-gui/src/pages/mod.rs index cab0ab2..3ed83f1 100644 --- a/crates/ledger-gui/src/pages/mod.rs +++ b/crates/ledger-gui/src/pages/mod.rs @@ -10,6 +10,7 @@ pub mod journal; pub mod overview; pub mod parties; pub mod profit; +pub mod reconciliation; pub mod reports; pub mod transactions; @@ -54,6 +55,7 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) { Route::Reports(kind) => reports::show(ui, b, *kind, shell), Route::Accounts => accounts::show_chart(ui, b, shell), Route::Account(id) => accounts::show_account(ui, b, *id, shell), + Route::Reconciliation(id) => reconciliation::show(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), diff --git a/crates/ledger-gui/src/pages/overview.rs b/crates/ledger-gui/src/pages/overview.rs index 24dcf89..199b2fd 100644 --- a/crates/ledger-gui/src/pages/overview.rs +++ b/crates/ledger-gui/src/pages/overview.rs @@ -21,28 +21,28 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { widgets::page_header(ui, b.company_name(), &headline(b)); kpis(ui, b, shell); let _ = k; - columns(ui, 980.0, &[2.0, 1.0], |i, ui| match i { - 0 => { - let (more, _) = widgets::titled_card( + // What needs doing gets the page's full width, under the numbers: + // long items wrap in their own room instead of squeezing the chart. + attention(ui, b, shell); + ui.add_space(16.0); + let (more, _) = widgets::titled_card( + ui, + "Cash over time", + |ui| { + widgets::link( ui, - "Cash over time", - |ui| { - widgets::link( - ui, - RichText::new("Cash details") - .font(theme::regular(12.5)) - .color(pal(ui.ctx()).accent), - ) - .clicked() - }, - |ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0), - ); - if more { - shell.go(Route::Cash); - } - } - _ => attention(ui, b, shell), - }); + RichText::new("Cash details") + .font(theme::regular(12.5)) + .color(pal(ui.ctx()).accent), + ) + .clicked() + }, + |ui| charts::balance_line(ui, &b.cash_history(), b.today, 230.0), + ); + if more { + shell.go(Route::Cash); + } + ui.add_space(16.0); columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i { 0 => income_vs_spending(ui, b, shell), _ => where_it_went(ui, b, shell), @@ -253,33 +253,52 @@ fn color_for(c: egui::Color32, p: &theme::Palette) -> egui::Color32 { } } +/// "Needs attention", full width: a summary of how many problems, to-dos, +/// and notes there are, then each item as a row (its severity, the title +/// that opens where to look, what's wrong, and the prompt for the agent at +/// the right; stacked under the text when narrow). The first few show; +/// the rest wait behind a button that says how many. fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { let p = pal(ui.ctx()); + let count = |s: Severity| b.attention.iter().filter(|a| a.severity == s).count(); widgets::titled_card( ui, "Needs attention", |ui| { - if !b.attention.is_empty() { - widgets::pill( - ui, - &b.attention.len().to_string(), - p.warning, - p.warning_soft, - ); + // Laid out right to left: problems end up first, reading on. + for (sev, one, many, fg, bg) in [ + (Severity::Info, "note", "notes", p.muted, p.hover), + (Severity::Todo, "to do", "to do", p.warning, p.warning_soft), + ( + Severity::Problem, + "problem", + "problems", + p.negative, + p.negative_soft, + ), + ] { + let n = count(sev); + if n > 0 { + widgets::pill(ui, &fmt::count(n, one, many), fg, bg); + } } }, |ui| { if b.attention.is_empty() { - widgets::empty( - ui, - icon::CHECK_CIRCLE, - "All clear", - "Nothing is missing or overdue.", - ); + ui.horizontal(|ui| { + ui.label( + RichText::new(icon::CHECK_CIRCLE) + .size(18.0) + .color(p.positive), + ); + ui.label( + RichText::new("All clear: nothing is missing or overdue.") + .font(theme::regular(13.5)) + .color(p.muted), + ); + }); return; } - // No inner scrolling: the card grows to fit. Past a handful of - // items, the rest sit behind a button that says how many. const FIRST: usize = 6; let id = ui.id().with("attention-expanded"); let mut expanded: bool = ui.ctx().data(|d| d.get_temp(id)).unwrap_or(false); @@ -288,34 +307,59 @@ fn attention(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { } else { b.attention.len().min(FIRST) }; + let wide = ui.available_width() >= 720.0; for (i, a) in b.attention.iter().take(shown).enumerate() { if i > 0 { - ui.add_space(4.0); + ui.add_space(6.0); widgets::rule(ui); - ui.add_space(4.0); + ui.add_space(6.0); } - let (glyph, color) = match a.severity { - Severity::Problem => (icon::WARNING_CIRCLE, p.negative), - Severity::Todo => (icon::CIRCLE_DASHED, p.warning), - Severity::Info => (icon::INFO, p.muted), + let (glyph, color, word) = match a.severity { + Severity::Problem => (icon::WARNING_CIRCLE, p.negative, "Problem"), + Severity::Todo => (icon::CIRCLE_DASHED, p.warning, "To do"), + Severity::Info => (icon::INFO, p.muted, "Note"), }; + let full = ui.available_width(); + let button_w = if wide { 150.0 } else { 0.0 }; ui.horizontal_top(|ui| { - ui.label(RichText::new(glyph).size(16.0).color(color)); - ui.vertical(|ui| { - let t = RichText::new(&a.title).font(theme::semibold(13.0)); - let r = widgets::link(ui, t); - if r.clicked() - && let Some(to) = &a.target - { - shell.go(to.clone()); - } - ui.label( - RichText::new(&a.detail) - .font(theme::regular(12.0)) - .color(p.muted), - ); - widgets::copy_prompt(ui, &a.prompt); - }); + ui.label(RichText::new(glyph).size(18.0).color(color)) + .on_hover_text(word); + let text_w = (full - 30.0 - button_w).max(120.0); + ui.allocate_ui_with_layout( + egui::vec2(text_w, 0.0), + egui::Layout::top_down(egui::Align::Min), + |ui| { + ui.set_width(text_w); + ui.spacing_mut().item_spacing.y = 2.0; + let t = RichText::new(&a.title).font(theme::semibold(13.5)); + let r = ui.add(egui::Label::new(t).wrap().sense(egui::Sense::click())); + if a.target.is_some() && r.hovered() { + ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand); + } + if r.clicked() + && let Some(to) = &a.target + { + shell.go(to.clone()); + } + ui.add( + egui::Label::new( + RichText::new(&a.detail) + .font(theme::regular(12.5)) + .color(p.muted), + ) + .wrap(), + ); + if !wide { + ui.add_space(4.0); + widgets::copy_prompt(ui, &a.prompt); + } + }, + ); + if wide { + ui.with_layout(egui::Layout::right_to_left(egui::Align::Min), |ui| { + widgets::copy_prompt(ui, &a.prompt); + }); + } }); } let hidden = b.attention.len() - shown; diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index 2d7b278..513cf3f 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -9,7 +9,7 @@ use super::assets::{Notice, facts, notice_box, row}; use super::page; use crate::app::Shell; -use crate::books::{Books, PartyInfo}; +use crate::books::{Books, PartyFlow, PartyInfo, PartyPeriod}; use crate::fmt; use crate::nav::{ReportKind, Route}; use crate::theme::{self, pal}; @@ -29,6 +29,59 @@ pub struct State { pub show_archived: bool, /// Search text: name or display name. pub query: String, + /// The span the money columns and a party's page sum over. + pub period: PartyPeriod, + /// How the directory is ordered. + pub sort: Sort, +} + +/// The directory's order: by name, or by an amount, largest first. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Sort { + #[default] + Name, + PaidOut, + ReceivedIn, + Net, +} + +/// The directory's rows for `st` in its order: [`listed`] by name, or by +/// the chosen amount over `st.period` largest first (ties by name), each +/// with its [`PartyFlow`] (totals only). +pub fn sorted<'a>(b: &'a Books, st: &State) -> Vec<(&'a Party, PartyFlow)> { + let span = b.party_span(st.period); + let mut rows: Vec<(&Party, PartyFlow)> = listed(b, st) + .into_iter() + .map(|p| (p, b.party_flow(p.id, span, false))) + .collect(); + let key = |f: &PartyFlow| match st.sort { + Sort::Name => Decimal::ZERO, + Sort::PaidOut => f.paid, + Sort::ReceivedIn => f.received, + Sort::Net => f.net(), + }; + // Stable: equal amounts keep the by-name order. + rows.sort_by_key(|x| std::cmp::Reverse(key(&x.1))); + rows +} + +/// The period chips: each of [`Books::party_periods`], the chosen one +/// lit. +fn period_chips(ui: &mut Ui, b: &Books, party: Option, period: &mut PartyPeriod) { + let p = pal(ui.ctx()); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 6.0); + ui.label( + RichText::new(format!("{} Period", icon::CALENDAR_BLANK)) + .font(theme::regular(12.5)) + .color(p.muted), + ); + for x in b.party_periods(party) { + if chip(ui, &b.party_period_label(x), *period == x) { + *period = x; + } + } + }); } /// A role in words, capitalized: "Payout platform". @@ -270,14 +323,11 @@ fn chip(ui: &mut Ui, text: &str, on: bool) -> bool { /// 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." - ), + "Everyone the company deals with — vendors, contractors, customers, platforms, agencies — and what the company paid each and received from each. Click one for everything the books hold about them.", ); if b.ledger.parties().next().is_none() { widgets::card(ui, |ui| { @@ -322,8 +372,30 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { } } }); + ui.add_space(8.0); + period_chips(ui, b, None, &mut st.period); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| { + ui.spacing_mut().item_spacing = vec2(6.0, 6.0); + ui.label( + RichText::new(format!("{} Sort", icon::SORT_DESCENDING)) + .font(theme::regular(12.5)) + .color(p.muted), + ); + for (x, label) in [ + (Sort::Name, "Name"), + (Sort::PaidOut, "Paid out"), + (Sort::ReceivedIn, "Received in"), + (Sort::Net, "Net"), + ] { + if chip(ui, label, st.sort == x) { + st.sort = x; + } + } + }); ui.add_space(12.0); - let rows = listed(b, st); + let period_label = b.party_period_label(st.period); + let rows = sorted(b, st); let missing: Vec<&Party> = b .ledger .parties() @@ -356,42 +428,56 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { } 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 amt = widgets::AMOUNT_MIN + 16.0; + // Narrow, the entry count and then the roles go (the role + // chips above still filter), so the amounts stay 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); + let show_roles = avail >= 900.0; + let roles_w = if show_roles { + (avail * 0.16).clamp(120.0, 200.0) + } else { + 0.0 + }; + let w9_w = (avail * 0.16).clamp(140.0, 210.0); + let n_w = if avail >= 1000.0 { 70.0 } else { 0.0 }; + let fixed = roles_w + w9_w + n_w + amt * 3.0; + let w = avail.max(fixed + 180.0); // Cells without the entry count when it has no room. - let cells = |c: [(String, f32, bool, egui::Color32); 5]| -> Vec<_> { + let cells = |c: [(String, f32, bool, egui::Color32); 7]| -> Vec<_> { c.into_iter() .enumerate() - .filter(|(i, _)| *i != 3 || n_w > 0.0) + .filter(|(i, _)| (*i != 3 || n_w > 0.0) && (*i != 1 || show_roles)) .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); + let head = |t: &str, cw: f32, right: bool, on: bool| { + ( + t.to_string(), + cw, + right, + if on { p.accent } else { p.muted }, + ) + }; + let sort = st.sort; 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), + head("Name", 0.0, false, sort == Sort::Name), + head("Roles", roles_w, false, false), + head("W-9", w9_w, false, false), + head("Entries", n_w, true, false), + head("Paid out", amt, true, sort == Sort::PaidOut), + head("Received in", amt, true, sort == Sort::ReceivedIn), + head("Net", amt, true, sort == Sort::Net), ]), None, true, false, ); widgets::rule(ui); - for x in &rows { - let info = b.party(x.id).cloned().unwrap_or_default(); + for (x, flow) in &rows { let text = if x.archived { p.muted } else { p.text }; let status = w9_status(b, x); let w9_color = match status { @@ -413,7 +499,16 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { sub.push(format!("Party {}", x.id)); } let sub = sub.join(" · "); - let (paid, paid_color) = paid_cell(info.paid_ytd, p); + let money = |d: Decimal, c| { + if d.is_zero() { + ("—".to_string(), p.faint) + } else { + (fmt::money(d), c) + } + }; + let (out_t, out_c) = money(flow.paid, p.text); + let (in_t, in_c) = money(flow.received, p.positive); + let (net_t, net_c) = paid_cell(flow.net(), p); let resp = row( ui, w, @@ -421,25 +516,21 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { (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), + (flow.entries.to_string(), n_w, true, p.muted), + (out_t, amt, true, out_c), + (in_t, amt, true, in_c), + (net_t, amt, true, net_c), ]), 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() - }); + let resp = resp.on_hover_text("Open this party"); 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()); + widgets::copy_item(ui, "Copy paid out", &flow.paid.to_string()); + widgets::copy_item(ui, "Copy received in", &flow.received.to_string()); }); if resp.clicked() { go = Some(x.id); @@ -451,7 +542,7 @@ pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) { 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." + "{period_label}: Paid out is what each party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company; Received in is what came back in; Net is paid out less received (a plus means more came in). Entries counts the posted ones in the period. The 1099 amount, on each party's page, is the 1099 worksheet's." )) .font(theme::regular(11.5)) .color(p.faint), @@ -541,18 +632,46 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { }); ui.add_space(14.0); - // This year's totals. + // The money over the chosen period: out, in, and the net. + let period = shell.state.parties.period; + period_chips(ui, b, Some(id), &mut shell.state.parties.period); + ui.add_space(10.0); + let span = b.party_span(period); + let flow = b.party_flow(id, span, true); + let label = b.party_period_label(period); + let span_words = format!("{} – {}", fmt::date(span.0), fmt::date(span.1)); 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)), + "Paid out".into(), + flow.paid, + format!("{label}, {span_words}: what this party's posted entries moved out of cash, cards, and what's owed back to whoever paid for the company (net of refunds and reversals of payments)."), + Some((label.clone(), p.muted)), + )); + tiles.push(( + "Received in".into(), + flow.received, + format!("{label}, {span_words}: what this party's posted entries brought into cash and those accounts (net of reversals)."), + Some((label.clone(), p.muted)), + )); + let net = flow.net(); + tiles.push(( + "Net".into(), + net.abs(), + "Paid out less received in.".into(), + Some(( + if net < Decimal::ZERO { + "more received than paid".to_string() + } else if net.is_zero() { + "even".to_string() + } else { + "more paid than received".to_string() + }, + if net < Decimal::ZERO { + p.positive + } else { + p.muted + }, + )), )); if party.customer.is_some() { tiles.push(( @@ -581,28 +700,33 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { }), )); } - if let Some(l) = &info.form_1099 { - let threshold = info.threshold_1099.unwrap_or_default(); + let tax_year = b.party_period_year(period); + let form = if tax_year == year { + info.form_1099.clone().zip(info.threshold_1099) + } else { + b.party_1099(id, tax_year) + }; + if let Some((l, threshold)) = &form { let sub = match l.form { Some(_) if l.reportable => ( - format!("Reportable · threshold {}", fmt::dollars(threshold)), + format!("Reportable · threshold {}", fmt::dollars(*threshold)), p.warning, ), Some(_) => ( - format!("Under the {} threshold", fmt::dollars(threshold)), + format!("Under the {} threshold", fmt::dollars(*threshold)), p.muted, ), None => ("Not marked for a 1099".into(), p.muted), }; tiles.push(( - format!("1099 amount, {year}"), + format!("1099 amount, {tax_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| { + super::grid(ui, n, 170.0, 6, |i, ui| { let (label, value, how, sub) = &tiles[i]; let r = widgets::kpi( ui, @@ -617,6 +741,13 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { 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 { + what_for_card(ui, b, &flow, &label, &mut go); + } else { + months_card(ui, b, id, span, &label); + } + }); super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| { if col == 0 { facts_card(ui, party, &mut go); @@ -648,6 +779,23 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) { payroll_card(ui, b, party, &info, &mut go); ui.add_space(14.0); } + let mut subjects = vec![ledger_store::Subject::Party(id)]; + subjects.extend( + info.invoices + .iter() + .map(|i| ledger_store::Subject::Invoice(*i)), + ); + subjects.extend(info.bills.iter().map(|i| ledger_store::Subject::Bill(*i))); + super::documents::documents_card_with( + ui, + b, + "Documents", + &subjects, + &[ledger_store::Subject::Party(id)], + "No documents linked to them (contracts, a W-9 is kept in the vault, correspondence).", + &mut go, + ); + ui.add_space(14.0); widgets::titled_card( ui, "History", @@ -1180,3 +1328,147 @@ fn payroll_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mu }, ); } + +/// "What for": the period's money by the account on the other side of +/// each entry ([`PartyFlow::what_for`]), which sums to the net exactly. +fn what_for_card(ui: &mut Ui, b: &Books, flow: &PartyFlow, label: &str, go: &mut Option) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + "What for", + |ui| { + ui.label( + RichText::new(label) + .font(theme::regular(12.0)) + .color(p.muted), + ); + }, + |ui| { + if flow.what_for.is_empty() { + ui.label( + RichText::new("No posted entries with them in this period.").color(p.muted), + ); + return; + } + let amt = widgets::AMOUNT_MIN + 16.0; + let w = ui.available_width().max(200.0 + amt); + let roles = [ + (ledger_core::PAYABLES, "billed, not yet paid"), + (ledger_core::RECEIVABLES, "invoiced, not yet collected"), + ]; + widgets::min_width(ui, "what_for", w, |ui| { + for (account, amount) in &flow.what_for { + let note = roles + .iter() + .find(|(r, _)| b.ledger.role_account(r) == Some(*account)) + .map(|(_, n)| *n); + let (text, color) = if *amount < Decimal::ZERO { + (format!("+{}", fmt::money(-*amount)), p.positive) + } else { + (fmt::money(*amount), p.text) + }; + let resp = row( + ui, + w, + &[ + (b.account_label(*account), 0.0, false, p.text), + (text, amt, true, color), + ], + note.map(|n| (n, p.faint)), + false, + true, + ); + if resp.on_hover_text("Open the account's register").clicked() { + *go = Some(Route::Account(*account)); + } + } + widgets::rule(ui); + let net = flow.net(); + row( + ui, + w, + &[ + ("Net".into(), 0.0, false, p.text), + ( + if net < Decimal::ZERO { + format!("+{}", fmt::money(-net)) + } else { + fmt::money(net) + }, + amt, + true, + if net < Decimal::ZERO { + p.positive + } else { + p.text + }, + ), + ], + None, + true, + false, + ); + }); + ui.add_space(4.0); + ui.add( + egui::Label::new( + RichText::new("Each entry's other side: what the money paid for, or (with a plus) where money received came from. Sums to the net exactly.") + .font(theme::regular(11.5)) + .color(p.faint), + ) + .wrap(), + ); + }, + ); +} + +/// Out versus in, month by month over the period ([`Books::party_months`]). +fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate), label: &str) { + let p = pal(ui.ctx()); + let months = b.party_months(id, span); + // All time can be long: the chart shows the last 24 months of it. + let shown: Vec<_> = months.iter().rev().take(24).rev().collect(); + widgets::titled_card( + ui, + "By month", + |ui| { + ui.label( + RichText::new(if months.len() > shown.len() { + format!("{label}, last {} months", shown.len()) + } else { + label.to_string() + }) + .font(theme::regular(12.0)) + .color(p.muted), + ); + }, + |ui| { + if shown + .iter() + .all(|m| m.paid.is_zero() && m.received.is_zero()) + { + widgets::empty(ui, icon::CHART_BAR, "No money moved", ""); + return; + } + let multi_year = + shown.first().map(|m| m.month.year()) != shown.last().map(|m| m.month.year()); + let groups: Vec = shown + .iter() + .map(|m| crate::charts::BarGroup { + label: if multi_year && m.month.month() == 1 { + m.month.format("%b %y").to_string() + } else { + m.month.format("%b").to_string() + }, + values: vec![m.paid, m.received], + }) + .collect(); + crate::charts::bar_chart( + ui, + &groups, + &[("Paid out", p.series[2]), ("Received in", p.series[1])], + 200.0, + ); + }, + ); +} diff --git a/crates/ledger-gui/src/pages/reconciliation.rs b/crates/ledger-gui/src/pages/reconciliation.rs new file mode 100644 index 0000000..fa28502 --- /dev/null +++ b/crates/ledger-gui/src/pages/reconciliation.rs @@ -0,0 +1,417 @@ +//! One reconciliation: the account and statement it agrees with, the +//! balance, every line it found cleared (each opening its entry), and the +//! lines on the account through that date it did not clear. Shared with +//! the account page and "Needs attention": the prompt that asks the agent +//! to reconcile an account, and where a reconciliation stands. + +use super::assets::{Notice, facts, notice_box, row}; +use super::page; +use crate::app::Shell; +use crate::books::Books; +use crate::fmt; +use crate::nav::Route; +use crate::theme::{self, pal}; +use crate::widgets; +use chrono::NaiveDate; +use egui::{RichText, Ui}; +use egui_phosphor::regular as icon; +use ledger_core::{AccountId, EntryId, Side}; +use ledger_store::{Reconciliation, Subject}; +use rust_decimal::Decimal; + +/// The agent prompt that reconciles an account through a date. +/// +/// ```text +/// GOAL: one paste gets the agent to do the monthly reconciliation the +/// way the books require it (docs/parties-and-attachments.md, +/// Reconciliations: a statement recorded and attached, the +/// difference exactly zero). +/// INVARIANTS: +/// 1. Starts "In wanderbooks,"; names the account's code and name, the +/// date (YYYY-MM-DD), books.record_statement, books.mark_cleared, +/// and books.reconcile. +/// 2. Says where it last stood: reconciled through a date, or never. +/// ``` +pub fn reconcile_prompt(b: &Books, account: AccountId, through: NaiveDate) -> String { + let since = match b.reconciled_through(account) { + Some(d) => format!("It is reconciled through {d}, so start with the statement after that"), + None => "It has never been reconciled, so start with its first statement".to_string(), + }; + format!( + "In wanderbooks, reconcile {} through {through}. {since}. For each monthly statement: file the PDF, record it with books.record_statement (its period and opening and closing balances), mark the lines that cleared with books.mark_cleared, then run books.reconcile against it (preview first; the difference must be exactly zero). Show me anything that doesn't match, and the account when it's done.", + b.account_label(account) + ) +} + +/// Where a reconciliation stands among its account's. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Standing { + /// The account's newest reconciliation not voided. + Current, + /// A later reconciliation not voided supersedes it. + Superseded, + /// Voided, with the reason. + Voided(String), +} + +/// A reconciliation's [`Standing`]. +pub fn standing(b: &Books, r: &Reconciliation) -> Standing { + match &r.voided { + Some(why) => Standing::Voided(why.clone()), + None if b.reconciled_through(r.account_id) == Some(r.statement_date) => Standing::Current, + None => Standing::Superseded, + } +} + +/// A line's amount in the account's normal direction (a debit raises an +/// asset, a credit raises a liability). +fn signed(b: &Books, account: AccountId, side: Side, amount: Decimal) -> Decimal { + let normal = b + .ledger + .chart() + .get(account) + .map_or(Side::Debit, |a| a.account_type.normal_side()); + if side == normal { amount } else { -amount } +} + +/// Every posted line on the reconciliation's account dated on or before +/// its statement date, as (entry, line index, date, description, signed +/// amount, cleared by it), by date then entry. +pub fn lines( + b: &Books, + r: &Reconciliation, +) -> Vec<(EntryId, usize, NaiveDate, String, Decimal, bool)> { + let mut out = Vec::new(); + for e in b.ledger.posted_entries() { + if e.date() > r.statement_date { + continue; + } + for (i, l) in e.lines().iter().enumerate() { + if l.account == r.account_id { + out.push(( + e.id(), + i, + e.date(), + e.description().to_string(), + signed(b, r.account_id, l.side, l.amount.amount), + r.cleared.contains(&(e.id(), i)), + )); + } + } + } + out.sort_by(|a, b| a.2.cmp(&b.2).then(a.0.cmp(&b.0)).then(a.1.cmp(&b.1))); + out +} + +pub fn show(ui: &mut Ui, b: &Books, id: u64, shell: &mut Shell<'_>) { + let p = pal(ui.ctx()); + let Some(r) = b.reconciliation(id) else { + page(ui, "reconciliation", |ui| { + widgets::empty( + ui, + icon::CHECK_SQUARE, + "No such reconciliation", + "It isn't in these books.", + ); + }); + return; + }; + let mut go: Option = None; + page(ui, "reconciliation", |ui| { + ui.horizontal(|ui| { + if widgets::link( + ui, + RichText::new(format!( + "{} {}", + icon::ARROW_LEFT, + b.account_label(r.account_id) + )) + .font(theme::regular(12.5)) + .color(p.muted), + ) + .clicked() + { + go = Some(Route::Account(r.account_id)); + } + widgets::copyable( + ui, + RichText::new(format!("· Reconciliation {id}")) + .font(theme::regular(12.5)) + .color(p.muted), + &id.to_string(), + ); + }); + ui.add_space(4.0); + ui.label( + RichText::new(format!( + "{} reconciled through {}", + b.account_name(r.account_id), + fmt::date(r.statement_date) + )) + .font(theme::bold(26.0)), + ); + ui.add_space(6.0); + ui.horizontal_wrapped(|ui| match standing(b, r) { + Standing::Current => { + widgets::pill( + ui, + &format!("{} Current", icon::CHECK), + p.positive, + p.positive_soft, + ); + } + Standing::Superseded => { + widgets::pill(ui, "Superseded by a later reconciliation", p.muted, p.hover); + } + Standing::Voided(why) => { + widgets::pill(ui, &format!("Voided: {why}"), p.warning, p.warning_soft); + } + }); + ui.add_space(14.0); + let all = lines(b, r); + let cleared: Vec<_> = all.iter().filter(|l| l.5).collect(); + let outstanding: Vec<_> = all.iter().filter(|l| !l.5).collect(); + let cleared_total: Decimal = cleared.iter().map(|l| l.4).sum(); + let st = b.statement(r.attachment_id); + super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| { + if col == 0 { + widgets::titled_card( + ui, + "Reconciliation", + |_| {}, + |ui| { + facts(ui, |ui, k| { + k(ui, "Account"); + if widgets::link_truncated( + ui, + RichText::new(b.account_label(r.account_id)).color(p.accent), + ) + .clicked() + { + go = Some(Route::Account(r.account_id)); + } + ui.end_row(); + k(ui, "Through"); + ui.label(fmt::date(r.statement_date)); + ui.end_row(); + k(ui, "Statement balance"); + widgets::copyable( + ui, + RichText::new(fmt::dollars(r.statement_balance)) + .font(theme::semibold(14.0)), + &r.statement_balance.to_string(), + ); + ui.end_row(); + k(ui, "Cleared lines"); + ui.label(format!( + "{} totaling {}", + fmt::count(r.cleared.len(), "line", "lines"), + fmt::dollars(cleared_total) + )); + ui.end_row(); + k(ui, "Books' balance then"); + ui.label(fmt::dollars(b.balance_on(r.account_id, r.statement_date))); + ui.end_row(); + }); + ui.add_space(6.0); + ui.add( + egui::Label::new( + RichText::new("Recorded only when the cleared lines agree with the statement to the cent. A reconciliation is never edited: a mistake is voided and redone.") + .font(theme::regular(11.5)) + .color(p.faint), + ) + .wrap(), + ); + }, + ); + } else { + widgets::titled_card( + ui, + "Statement", + |_| {}, + |ui| { + let file = b.attachment(r.attachment_id); + facts(ui, |ui, k| { + if let Some(st) = st { + k(ui, "Period"); + ui.label(format!( + "{} – {}", + fmt::date(st.period_start), + fmt::date(st.period_end) + )); + ui.end_row(); + k(ui, "Opening"); + ui.label(fmt::dollars(st.opening_balance)); + ui.end_row(); + k(ui, "Closing"); + ui.label(fmt::dollars(st.closing_balance)); + ui.end_row(); + } + k(ui, "File"); + let name = file.map_or_else( + || format!("attachment {}", r.attachment_id), + |a| a.file_name.clone(), + ); + if widgets::link_truncated( + ui, + RichText::new(format!("{} {name}", icon::FILE_PDF)).color(p.accent), + ) + .clicked() + { + go = Some(Route::Document(r.attachment_id)); + } + ui.end_row(); + }); + }, + ); + } + }); + lines_card( + ui, + b, + "Cleared lines", + &cleared, + "It cleared no lines (the account had no activity through this date).", + &mut go, + ); + ui.add_space(14.0); + if !outstanding.is_empty() { + notice_box( + ui, + &Notice { + problem: false, + text: format!( + "{} on the account through {} had not cleared the bank by this statement (outstanding then).", + fmt::count(outstanding.len(), "line", "lines"), + fmt::date(r.statement_date) + ), + }, + ); + lines_card(ui, b, "Outstanding then", &outstanding, "", &mut go); + ui.add_space(14.0); + } + super::documents::documents_card( + ui, + b, + "Documents", + &[Subject::Reconciliation(id)], + "No documents linked to it.", + &mut go, + ); + ui.add_space(14.0); + widgets::titled_card( + ui, + "History", + |_| {}, + |ui| { + for line in b.journal.iter().filter(|l| { + matches!( + l.line_type.as_str(), + "reconciliation.completed" | "reconciliation.voided" + ) && l.payload.get("id").and_then(|v| v.as_u64()) == Some(id) + }) { + 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(to) = go { + shell.go(to); + } +} + +type Line = (EntryId, usize, NaiveDate, String, Decimal, bool); + +/// A table of account lines, each opening its entry. +fn lines_card( + ui: &mut Ui, + b: &Books, + title: &str, + rows: &[&Line], + empty: &str, + go: &mut Option, +) { + let p = pal(ui.ctx()); + widgets::titled_card( + ui, + title, + |ui| { + if !rows.is_empty() { + widgets::pill(ui, &rows.len().to_string(), p.muted, p.hover); + } + }, + |ui| { + if rows.is_empty() { + ui.label(RichText::new(empty).color(p.muted)); + return; + } + let amt = widgets::AMOUNT_MIN + 16.0; + let w = ui.available_width().max(104.0 + 70.0 + 200.0 + amt); + widgets::min_width(ui, title, 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", 70.0, false), + head("Description", 0.0, false), + head("Amount", amt, true), + ], + None, + true, + false, + ); + widgets::rule(ui); + for (e, _, date, desc, amount, _) in rows.iter().copied() { + let resp = row( + ui, + w, + &[ + (fmt::date(*date), 104.0, false, p.muted), + (format!("#{e}"), 70.0, false, p.muted), + (desc.clone(), 0.0, false, p.text), + ( + fmt::money(*amount), + amt, + true, + if *amount < Decimal::ZERO { + p.negative + } else { + p.text + }, + ), + ], + None, + false, + true, + ); + if resp.on_hover_text("Open its entry").clicked() { + *go = Some(Route::Entry(*e)); + } + } + }); + let _ = b; + }, + ); +} diff --git a/crates/ledger-gui/src/palette.rs b/crates/ledger-gui/src/palette.rs index b8e8106..fee2176 100644 --- a/crates/ledger-gui/src/palette.rs +++ b/crates/ledger-gui/src/palette.rs @@ -1,7 +1,8 @@ //! Ctrl+K: jump to anything. Pages, reports, accounts, entries (by text, //! 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. +//! (vendors and customers among them, by name or display name), +//! documents, bank and card statements, and reconciliations (by account +//! and date), ranked by a simple fuzzy match. use crate::books::Books; use crate::fmt; @@ -234,6 +235,15 @@ pub fn fuzzy(hay: &str, query: &str) -> Option { /// case) adds 500. /// 6. Every party result opens Route::Party(its id); archived parties /// are found too, and say so. +/// INVARIANTS (statements and reconciliations): +/// 7. A statement is listed when the query fuzzily matches "statement", +/// its account's code and name, its period's dates (as shown and as +/// YYYY-MM-DD), and its file name; it opens Route::Document(its +/// attachment). +/// 8. A reconciliation is listed when the query fuzzily matches +/// "reconciliation reconciled", its account's code and name, and its +/// statement date (as shown and as YYYY-MM-DD); it opens +/// Route::Reconciliation(its id), voided ones too, saying so. /// ``` pub fn search(b: &Books, query: &str) -> Vec { let q = query.trim(); @@ -371,6 +381,61 @@ pub fn search(b: &Books, query: &str) -> Vec { }); } } + for st in &b.statements { + let file = b + .attachment(st.attachment_id) + .map_or("", |a| a.file_name.as_str()); + let hay = format!( + "statement {} {} {} {} {} {file}", + b.account_label(st.account_id), + fmt::date(st.period_start), + fmt::date(st.period_end), + st.period_start, + st.period_end + ); + if let Some(score) = fuzzy(&hay, q) { + out.push(Item { + icon: icon::FILE_TEXT, + title: format!( + "Statement: {}, {} – {}", + b.account_name(st.account_id), + fmt::date_short(st.period_start), + fmt::date(st.period_end) + ), + detail: format!("Closing {} · {file}", fmt::dollars(st.closing_balance)), + route: Route::Document(st.attachment_id), + score, + }); + } + } + for r in &b.reconciliations { + let hay = format!( + "reconciliation reconciled {} {} {}", + b.account_label(r.account_id), + fmt::date(r.statement_date), + r.statement_date + ); + if let Some(score) = fuzzy(&hay, q) { + let mut detail = vec![ + fmt::dollars(r.statement_balance), + fmt::count(r.cleared.len(), "cleared line", "cleared lines"), + ]; + if r.voided.is_some() { + detail.push("voided".into()); + } + out.push(Item { + icon: icon::CHECK_SQUARE, + title: format!( + "Reconciliation: {} through {}", + b.account_name(r.account_id), + fmt::date(r.statement_date) + ), + detail: detail.join(" · "), + route: Route::Reconciliation(r.id), + score, + }); + } + } for a in &b.attachments { let hay = format!("{} {}", a.file_name, a.description.as_deref().unwrap_or("")); if let Some(score) = fuzzy(&hay, q) { @@ -410,6 +475,7 @@ pub fn describe(b: &Books, r: &Route) -> String { .unwrap_or_default(), Route::Asset(id) => b.asset_label(*id), Route::Party(id) => b.party_label(*id), + Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)), Route::JournalEvent(seq) => format!("Event {seq}"), _ => String::new(), } diff --git a/crates/ledger-gui/tests/attachments.rs b/crates/ledger-gui/tests/attachments.rs new file mode 100644 index 0000000..0f71917 --- /dev/null +++ b/crates/ledger-gui/tests/attachments.rs @@ -0,0 +1,605 @@ +//! Attachment links, statements, and reconciliations in the viewer: each +//! subject shows exactly the files linked to it, each account its +//! statements and reconciliations, "Needs attention" asks for a +//! reconciliation until one covers last month, the journal says what +//! happened and opens it, and search finds statements and +//! reconciliations. + +use chrono::NaiveDate; +use egui_kittest::Harness; +use ledger_core::{AccountType, Currency, EntryId, Money, NewAsset, PartyId, Role, Side}; +use ledger_gui::books::Books; +use ledger_gui::nav::Route; +use ledger_gui::pages::journal::describe; +use ledger_gui::{App, Source, attention, data, palette}; +use ledger_store::{Statement, Store, Subject}; +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-attachments-{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() +} + +struct Fixture { + dir: TempDir, + store: Store, + checking: u64, + card: u64, + party: PartyId, + asset: u64, + contribution: EntryId, + software: EntryId, + linked_only: EntryId, + receipt: u64, + contract: u64, + photo: u64, +} + +/// Checking funded in July; software paid in August (its receipt filed on +/// it, and also linked to a second August entry that has no receipt of +/// its own); a contract linked to a party, the company, and the software +/// entry; a photo linked to an asset; a card account with no activity. +fn fixture(tag: &str) -> Fixture { + let dir = TempDir::new(tag); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let acct = |s: &mut Store, code: &str, name: &str, t| { + s.add_account(code, name, t, None, false).unwrap() + }; + let checking = acct(&mut s, "1000", "Checking", AccountType::Asset); + let equipment = acct(&mut s, "1500", "Equipment", AccountType::Asset); + let accum = acct( + &mut s, + "1510", + "Accumulated Depreciation", + AccountType::Asset, + ); + let card = acct(&mut s, "2050", "Business Card", AccountType::Liability); + let capital = acct(&mut s, "3000", "Capital", AccountType::Equity); + let software_acct = acct(&mut s, "6300", "Software", AccountType::Expense); + let depreciation = acct(&mut s, "6800", "Depreciation", AccountType::Expense); + let post = |s: &mut Store, date, what: &str, dr, cr, c, receipt: bool| { + let e = s.create_entry(date, what).unwrap(); + s.add_line(e, dr, Side::Debit, usd(c)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + if receipt { + s.attach_receipt( + Some(e), + &format!("{what}.pdf"), + "application/pdf", + what.as_bytes(), + None, + ) + .unwrap(); + } + s.post_entry(e).unwrap(); + e + }; + let contribution = post( + &mut s, + d(2026, 7, 5), + "Contribution", + checking, + capital, + 500_000, + true, + ); + let software = post( + &mut s, + d(2026, 8, 10), + "Software", + software_acct, + checking, + 12_000, + true, + ); + // Its receipt also covers this one (one invoice, two entries). + let e = s + .create_entry(d(2026, 8, 12), "Software, second seat") + .unwrap(); + s.add_line(e, software_acct, Side::Debit, usd(3_000)) + .unwrap(); + s.add_line(e, checking, Side::Credit, usd(3_000)).unwrap(); + let receipt = s.list_attachments(Some(software)).unwrap()[0].id; + s.link_attachment(receipt, Subject::Entry(e)).unwrap(); + s.post_entry(e).unwrap(); + let linked_only = e; + + let party = s.add_party("Acme Software", &[Role::Contractor]).unwrap(); + let (contract, _, _) = s + .file_document( + "contract", + "acme-agreement.pdf", + "application/pdf", + b"%PDF agreement", + Some("Signed agreement"), + ) + .unwrap(); + s.link_attachment(contract, Subject::Party(party)).unwrap(); + s.link_attachment(contract, Subject::Company).unwrap(); + s.link_attachment(contract, Subject::Entry(software)) + .unwrap(); + let asset = s + .register_asset(NewAsset { + description: "Laptop".into(), + purchase_date: d(2026, 7, 6), + cost: usd(200_000), + salvage_value: usd(0), + useful_life_months: 36, + cost_account: equipment, + accum_depr_account: accum, + expense_account: depreciation, + }) + .unwrap(); + let (photo, _, _) = s + .file_document("photo", "laptop.png", "image/png", b"png bytes", None) + .unwrap(); + s.link_attachment(photo, Subject::Asset(asset)).unwrap(); + Fixture { + dir, + store: s, + checking, + card, + party, + asset, + contribution, + software, + linked_only, + receipt, + contract, + photo, + } +} + +/// Clear every line on `account` through `through`, file and record its +/// statement for `month_start..=through`, and reconcile it. Returns the +/// statement's attachment id and the reconciliation id. +fn reconcile( + s: &mut Store, + account: u64, + month_start: NaiveDate, + through: NaiveDate, +) -> (u64, u64) { + let mut lines = Vec::new(); + for e in s.ledger().posted_entries() { + if e.date() <= through { + for (i, l) in e.lines().iter().enumerate() { + if l.account == account && !s.ledger().is_cleared(e.id(), i) { + lines.push((e.id(), i)); + } + } + } + } + for (e, i) in lines { + s.mark_cleared(e, i, true).unwrap(); + } + let report = |s: &Store, date| { + s.ledger() + .reconciliation_report(account, date, Decimal::ZERO) + .unwrap() + .cleared_balance + }; + let opening = report(s, month_start.pred_opt().unwrap()); + let closing = report(s, through); + let (att, _, _) = s + .file_document( + "statement", + &format!("statement-{through}.pdf"), + "application/pdf", + format!("statement {through}").as_bytes(), + None, + ) + .unwrap(); + s.record_statement(Statement { + attachment_id: att, + account_id: account, + period_start: month_start, + period_end: through, + opening_balance: opening, + closing_balance: closing, + }) + .unwrap(); + let r = s.complete_reconciliation(att).unwrap(); + (att, r.id) +} + +fn ids(docs: Vec<&ledger_store::Attachment>) -> Vec { + docs.into_iter().map(|a| a.id).collect() +} + +#[test] +fn each_subject_shows_exactly_its_links() { + let f = fixture("links"); + let b = load(&f.dir.0); + // An entry: filed on it, and linked to it. + assert_eq!( + ids(b.documents_for(&[Subject::Entry(f.software)])), + vec![f.receipt, f.contract] + ); + assert_eq!( + ids(b.documents_for(&[Subject::Entry(f.linked_only)])), + vec![f.receipt] + ); + assert_eq!( + ids(b.documents_for(&[Subject::Party(f.party)])), + vec![f.contract] + ); + assert_eq!(ids(b.documents_for(&[Subject::Company])), vec![f.contract]); + assert_eq!( + ids(b.documents_for(&[Subject::Asset(f.asset)])), + vec![f.photo] + ); + assert!(b.documents_for(&[Subject::Account(f.checking)]).is_empty()); + // Every link of the contract, in the store's order (by kind, id). + assert_eq!( + b.links_of(f.contract), + f.store.attachment_links(f.contract).unwrap().as_slice() + ); + assert_eq!(b.links_of(f.receipt)[0], Subject::Entry(f.software)); + assert!( + b.links_of(f.receipt) + .contains(&Subject::Entry(f.linked_only)) + ); + // The receipt policy counts a linked receipt (the server's rule). + let only = b.entry(f.linked_only).unwrap(); + assert!(!only.missing_receipt); + assert_eq!(only.receipts.len(), 1); + assert_eq!(b.attachments[only.receipts[0]].id, f.receipt); + // Every subject the books hold matches the store's own list. + for sub in [ + Subject::Entry(f.contribution), + Subject::Entry(f.software), + Subject::Entry(f.linked_only), + Subject::Party(f.party), + Subject::Company, + Subject::Asset(f.asset), + Subject::Account(f.checking), + ] { + assert_eq!( + ids(b.documents_for(&[sub])), + f.store + .attachments_for(sub) + .unwrap() + .iter() + .map(|a| a.id) + .collect::>(), + "{sub:?}" + ); + } + // Each chip opens its subject. + assert_eq!( + b.subject_route(Subject::Party(f.party)), + Some(Route::Party(f.party)) + ); + assert_eq!(b.subject_route(Subject::Company), Some(Route::Company)); + assert_eq!( + b.subject_route(Subject::Asset(f.asset)), + Some(Route::Asset(f.asset)) + ); +} + +#[test] +fn unlinking_and_withdrawing_take_a_file_off_its_subjects() { + let mut f = fixture("unlink"); + f.store + .unlink_attachment(f.contract, Subject::Company, "Not company-wide") + .unwrap(); + // A file comes off its links before it is withdrawn. + f.store + .unlink_attachment(f.photo, Subject::Asset(f.asset), "Blurry") + .unwrap(); + f.store + .withdraw_attachment(f.photo, "Blurry; retaking it") + .unwrap(); + let b = load(&f.dir.0); + assert!(b.documents_for(&[Subject::Company]).is_empty()); + assert!(b.documents_for(&[Subject::Asset(f.asset)]).is_empty()); + assert_eq!( + ids(b.documents_for(&[Subject::Party(f.party)])), + vec![f.contract] + ); + assert!(!b.links_of(f.contract).contains(&Subject::Company)); + assert!(b.links_of(f.photo).is_empty()); +} + +#[test] +fn statements_and_reconciliations_per_account() { + let mut f = fixture("recs"); + let (jul_st, jul) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31)); + f.store.void_reconciliation(jul, "Wrong statement").unwrap(); + let (_, jul2) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31)); + let (aug_st, aug) = reconcile(&mut f.store, f.checking, d(2026, 8, 1), d(2026, 8, 31)); + let b = load(&f.dir.0); + let st = b.statements_for(f.checking); + assert_eq!(st.len(), 3); + assert_eq!(st[0].attachment_id, jul_st); + assert_eq!(st.last().unwrap().attachment_id, aug_st); + assert_eq!( + st.iter().map(|s| (*s).clone()).collect::>(), + f.store.statements(Some(f.checking)).unwrap() + ); + let recs = b.reconciliations_for(f.checking); + assert_eq!( + recs.iter().map(|r| r.id).collect::>(), + vec![jul, jul2, aug] + ); + assert_eq!(recs[0].voided.as_deref(), Some("Wrong statement")); + // Cleared lines: the contribution's, then August's two payments. + let a = b.reconciliation(aug).unwrap(); + assert_eq!(a.cleared.len(), 3); + assert_eq!(a.statement_balance, cents(500_000 - 12_000 - 3_000)); + assert!(b.reconciliations_for(f.card).is_empty()); + assert_eq!(b.reconciled_through(f.checking), Some(d(2026, 8, 31))); + assert_eq!(b.reconciled_through(f.card), None); + use ledger_gui::pages::reconciliation::{Standing, lines, standing}; + assert_eq!(standing(&b, a), Standing::Current); + assert_eq!( + standing(&b, b.reconciliation(jul2).unwrap()), + Standing::Superseded + ); + assert_eq!( + standing(&b, b.reconciliation(jul).unwrap()), + Standing::Voided("Wrong statement".into()) + ); + // Its lines are the account's through the date, each marked. + let ls = lines(&b, a); + assert_eq!(ls.len(), 3); + assert!(ls.iter().all(|l| l.5)); + let total: Decimal = ls.iter().map(|l| l.4).sum(); + assert_eq!(total, a.statement_balance); + // The statement is linked to its account and its reconciliation. + assert!(b.links_of(aug_st).contains(&Subject::Account(f.checking))); + assert!(b.links_of(aug_st).contains(&Subject::Reconciliation(aug))); + assert_eq!( + b.subject_route(Subject::Reconciliation(aug)), + Some(Route::Reconciliation(aug)) + ); +} + +fn reconcile_items(b: &Books) -> Vec { + attention::find(b) + .into_iter() + .filter(|a| a.title.starts_with("Reconcile ")) + .map(|a| a.title) + .collect() +} + +#[test] +fn attention_asks_for_a_reconciliation_until_last_month_is_covered() { + let mut f = fixture("attention"); + let b = load(&f.dir.0); + // Checking has activity; the card has none (no item). + assert_eq!(b.last_month_end(), d(2026, 9, 30)); + assert_eq!( + reconcile_items(&b), + vec!["Reconcile Checking through Sep 30, 2026".to_string()] + ); + let item = attention::find(&b) + .into_iter() + .find(|a| a.title.starts_with("Reconcile ")) + .unwrap(); + assert!(item.prompt.starts_with("In wanderbooks,")); + assert!(item.prompt.contains("books.record_statement")); + assert!(item.prompt.contains("books.reconcile")); + assert!(item.prompt.contains("1000 Checking")); + assert!(item.prompt.contains("2026-09-30")); + assert!(item.prompt.contains("never been reconciled")); + assert_eq!(item.target, Some(Route::Account(f.checking))); + + // Through August only: still due. + reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 8, 31)); + let b = load(&f.dir.0); + assert_eq!(reconcile_items(&b).len(), 1); + assert!( + attention::find(&b) + .iter() + .any(|a| a.prompt.contains("reconciled through 2026-08-31")) + ); + + // Through September (last month): clear. + let (_, sep) = reconcile(&mut f.store, f.checking, d(2026, 9, 1), d(2026, 9, 30)); + let b = load(&f.dir.0); + assert!(reconcile_items(&b).is_empty(), "{:?}", reconcile_items(&b)); + assert_eq!(b.reconciliation_due(f.checking), None); + + // Voided, it's due again. + f.store.void_reconciliation(sep, "Redo").unwrap(); + let b = load(&f.dir.0); + assert_eq!(reconcile_items(&b).len(), 1); + + // Card activity before this month makes the card due too. + let e = f.store.create_entry(d(2026, 9, 3), "Card charge").unwrap(); + let software = f.store.ledger().chart().find_by_code("6300").unwrap().id; + f.store + .add_line(e, software, Side::Debit, usd(1_000)) + .unwrap(); + f.store + .add_line(e, f.card, Side::Credit, usd(1_000)) + .unwrap(); + f.store + .attach_receipt(Some(e), "c.pdf", "application/pdf", b"card", None) + .unwrap(); + f.store.post_entry(e).unwrap(); + let b = load(&f.dir.0); + assert!( + reconcile_items(&b).contains(&"Reconcile Business Card through Sep 30, 2026".to_string()) + ); +} + +#[test] +fn journal_text_names_and_opens_the_subjects() { + let mut f = fixture("journal"); + let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31)); + f.store.void_reconciliation(r, "Wrong statement").unwrap(); + f.store + .unlink_attachment(f.contract, Subject::Company, "Not company-wide") + .unwrap(); + let b = load(&f.dir.0); + let find = |t: &str| { + b.journal + .iter() + .filter(|l| l.line_type == t) + .map(|l| describe(&b, l).unwrap()) + .collect::>() + }; + let linked = find("attachment.linked"); + let party_link = linked + .iter() + .find(|e| e.also == Some(Route::Party(f.party))) + .unwrap(); + assert_eq!( + party_link.text, + "Linked acme-agreement.pdf to Acme Software" + ); + assert_eq!(party_link.route, Some(Route::Document(f.contract))); + assert!( + linked + .iter() + .any(|e| e.text == "Linked laptop.png to Laptop" + && e.also == Some(Route::Asset(f.asset))) + ); + assert!( + linked + .iter() + .any(|e| e.text == format!("Linked acme-agreement.pdf to #{} Software", f.software)) + ); + assert!( + linked + .iter() + .any(|e| e.also == Some(Route::Reconciliation(r))) + ); + let unlinked = &find("attachment.unlinked")[0]; + assert_eq!( + unlinked.text, + "Unlinked acme-agreement.pdf from the company: Not company-wide" + ); + assert_eq!(unlinked.also, Some(Route::Company)); + let recorded = &find("statement.recorded")[0]; + assert!( + recorded.text.contains("1000 Checking") && recorded.text.contains("Jul 31, 2026"), + "{}", + recorded.text + ); + assert_eq!(recorded.route, Some(Route::Document(st))); + assert_eq!(recorded.also, Some(Route::Account(f.checking))); + let done = &find("reconciliation.completed")[0]; + assert!( + done.text + .starts_with("Reconciled 1000 Checking through Jul 31, 2026"), + "{}", + done.text + ); + assert_eq!(done.route, Some(Route::Reconciliation(r))); + let voided = &find("reconciliation.voided")[0]; + assert_eq!( + voided.text, + "Voided the reconciliation of 1000 Checking through Jul 31, 2026: Wrong statement" + ); + assert_eq!(voided.route, Some(Route::Reconciliation(r))); +} + +#[test] +fn search_finds_statements_and_reconciliations() { + let mut f = fixture("search"); + let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31)); + let b = load(&f.dir.0); + let routes = |q: &str| -> Vec { + palette::search(&b, q) + .into_iter() + .map(|i| i.route) + .collect() + }; + assert!(routes("reconciliation checking").contains(&Route::Reconciliation(r))); + assert!(routes("checking 2026-07-31").contains(&Route::Reconciliation(r))); + assert!(routes("statement checking jul").contains(&Route::Document(st))); + assert!(!routes("statement savings").contains(&Route::Document(st))); +} + +#[test] +fn every_new_page_renders() { + let mut f = fixture("render"); + let (st, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31)); + for route in [ + Route::Overview, + Route::Account(f.checking), + Route::Account(f.card), + Route::Reconciliation(r), + Route::Reconciliation(999), + Route::Document(st), + Route::Document(f.contract), + Route::Entry(f.software), + Route::Entry(f.linked_only), + Route::Party(f.party), + Route::Asset(f.asset), + Route::Company, + Route::Parties, + ] { + let books = load(&f.dir.0); + let mut h = Harness::builder() + .with_size(egui::vec2(1100.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route.clone())); + h.run_steps(3); + assert_eq!(h.state_mut().route(), &route); + } +} + +#[test] +fn books_show_opens_a_reconciliation() { + use ledger_store::viewer::ViewerTarget; + let mut f = fixture("show"); + let (_, r) = reconcile(&mut f.store, f.checking, d(2026, 7, 1), d(2026, 7, 31)); + let books = load(&f.dir.0); + let mut h = Harness::builder() + .with_size(egui::vec2(1100.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, Route::Overview)); + h.state_mut().show(ViewerTarget::Reconciliation { id: r }); + h.run_steps(2); + assert_eq!(h.state_mut().route(), &Route::Reconciliation(r)); + // An unknown one goes nowhere. + h.state_mut().show(ViewerTarget::Reconciliation { id: 999 }); + assert_eq!(h.state_mut().route(), &Route::Reconciliation(r)); +} diff --git a/crates/ledger-gui/tests/parties.rs b/crates/ledger-gui/tests/parties.rs index df5523e..7e02c7a 100644 --- a/crates/ledger-gui/tests/parties.rs +++ b/crates/ledger-gui/tests/parties.rs @@ -628,3 +628,289 @@ fn attention_asks_for_a_w9_until_its_facts_are_recorded() { } ); } + +/// Paid out, received in, net, and what for, over each period, worked by +/// hand from the fixture; the breakdown and the months sum to the money +/// moved, exactly. +#[test] +fn party_money_by_period_and_what_for() { + use ledger_gui::books::PartyPeriod; + let f = fixture("periods"); + let b = &f.books; + let o = &f.oracle; + let code = |c: &str| o.chart().find_by_code(c).unwrap().id; + let labor = code("6050"); + let payables = o.role_account(&ledger_core::PAYABLES).unwrap(); + let receivables = o.role_account(&ledger_core::RECEIVABLES).unwrap(); + let flow = |p, period| b.party_flow(p, b.party_span(period), true); + + // The spans. + assert_eq!( + b.party_span(PartyPeriod::ThisYear), + (d(2026, 1, 1), today()) + ); + assert_eq!( + b.party_span(PartyPeriod::LastYear), + (d(2025, 1, 1), d(2025, 12, 31)) + ); + assert_eq!(b.party_span(PartyPeriod::AllTime), (d(2025, 1, 1), today())); + assert_eq!( + b.party_periods(Some(f.sam)), + vec![ + PartyPeriod::ThisYear, + PartyPeriod::LastYear, + PartyPeriod::AllTime + ] + ); + + // Sam this year: cash 1,000 + card 200 + owner 300 + (250 − 250 + // reversed) + 500 of the 800 bill paid = 2,000 out. What for: labor + // 1,000 + 200 + 300 + 250 − 250 = 1,500, the bill's 800 (general + // expenses, the bill's account), less the 300 still owed on the bill + // (payables −800 + 500). + let sam = flow(f.sam, PartyPeriod::ThisYear); + assert_eq!((sam.paid, sam.received), (cents(200_000), Decimal::ZERO)); + assert_eq!(sam.net(), cents(200_000)); + assert_eq!( + sam.what_for, + vec![ + (labor, cents(150_000)), + (code("6999"), cents(80_000)), + (payables, cents(-30_000)) + ] + ); + // Feb, Mar card, Apr owner, May and its reversal, the bill, its + // payment (the draft isn't posted). + assert_eq!(sam.entries, 7); + // Last year: December's 500. + let last = flow(f.sam, PartyPeriod::LastYear); + assert_eq!((last.paid, last.received), (cents(50_000), Decimal::ZERO)); + assert_eq!(last.what_for, vec![(labor, cents(50_000))]); + assert_eq!(last.entries, 1); + // All time: both. + let all = flow(f.sam, PartyPeriod::AllTime); + assert_eq!(all.paid, cents(250_000)); + assert_eq!(all.entries, 8); + + // Acme this year: 600 received on the 1,000 invoice. What for: the + // revenue (1,000 in), less the 400 still receivable. + let acme = flow(f.acme, PartyPeriod::ThisYear); + assert_eq!((acme.paid, acme.received), (Decimal::ZERO, cents(60_000))); + assert_eq!(acme.net(), cents(-60_000)); + let revenue = acme + .what_for + .iter() + .find(|(a, _)| *a != receivables) + .unwrap(); + assert_eq!(revenue.1, cents(-100_000)); + assert!(acme.what_for.contains(&(receivables, cents(40_000)))); + // Last year: invoiced 400, nothing moved; what for nets to zero. + let acme_last = flow(f.acme, PartyPeriod::LastYear); + assert_eq!( + (acme_last.paid, acme_last.received), + (Decimal::ZERO, Decimal::ZERO) + ); + assert_eq!( + acme_last.what_for.iter().map(|x| x.1).sum::(), + Decimal::ZERO + ); + + // Gumroad: the 90 deposit is 100 of sales less a 10 fee. + let g = flow(f.gumroad, PartyPeriod::ThisYear); + assert_eq!(g.received, cents(9_000)); + assert_eq!( + g.what_for, + vec![(code("4100"), cents(-10_000)), (code("6000"), cents(1_000))] + ); + + // Every party, every period: the breakdown sums to the net, and the + // months to paid and received, exactly; the totals are activity's. + for p in o.parties() { + for period in b.party_periods(Some(p.id)) { + let span = b.party_span(period); + let fl = b.party_flow(p.id, span, true); + let a = ledger_core::parties::activity(o, p.id, span, &b.cash_accounts); + assert_eq!((fl.paid, fl.received), (a.paid, a.received)); + assert_eq!( + fl.what_for.iter().map(|x| x.1).sum::(), + fl.net(), + "{} {period:?}", + p.name + ); + let months = b.party_months(p.id, span); + assert_eq!(months.iter().map(|m| m.paid).sum::(), fl.paid); + assert_eq!( + months.iter().map(|m| m.received).sum::(), + fl.received + ); + } + } + // Sam's months this year: Feb 1,000, Mar 200, Apr 300, May 0 (paid + // and reversed), Jun 500. + let months = b.party_months(f.sam, b.party_span(PartyPeriod::ThisYear)); + assert_eq!(months.len(), 10); + let paid: Vec = months.iter().map(|m| m.paid).collect(); + assert_eq!( + &paid[..6], + &[ + Decimal::ZERO, + cents(100_000), + cents(20_000), + cents(30_000), + Decimal::ZERO, + cents(50_000) + ] + ); + + // The 1099 amount follows the period's calendar year. + let (line, _) = b.party_1099(f.sam, 2025).unwrap(); + assert_eq!(line.paid, cents(50_000)); + let (line, _) = b.party_1099(f.sam, 2026).unwrap(); + assert_eq!(line.paid, cents(150_000)); +} + +#[test] +fn directory_sorts_by_the_periods_amounts() { + use ledger_gui::books::PartyPeriod; + use ledger_gui::pages::parties::{Sort, sorted}; + let f = fixture("sorted"); + let b = &f.books; + let names = + |st: &State| -> Vec { sorted(b, st).into_iter().map(|(p, _)| p.id).collect() }; + let mut st = State::default(); + assert_eq!( + names(&st), + listed(b, &st).into_iter().map(|p| p.id).collect::>() + ); + st.sort = Sort::PaidOut; + assert_eq!(names(&st)[0], f.sam); + st.sort = Sort::ReceivedIn; + assert_eq!(&names(&st)[..2], &[f.acme, f.gumroad]); + st.sort = Sort::Net; + assert_eq!(names(&st)[0], f.sam); + assert_eq!(*names(&st).last().unwrap(), f.acme); + // Last year, only Sam was paid. + st.period = PartyPeriod::LastYear; + st.sort = Sort::PaidOut; + let rows = sorted(b, &st); + assert_eq!(rows[0].0.id, f.sam); + assert_eq!(rows[0].1.paid, cents(50_000)); + assert!(rows[1..].iter().all(|(_, fl)| fl.paid.is_zero())); +} + +/// Books with three posted entries this year: one tagged with a party, +/// one marked as having none, one neither; under `parties` = `policy` +/// (None: unset). +fn counterparty_books( + tag: &str, + policy: Option<&str>, +) -> (TempDir, Store, EntryId, EntryId, EntryId) { + let dir = TempDir::new(tag); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let savings = s + .add_account("1010", "Savings", AccountType::Asset, None, false) + .unwrap(); + let labor = s + .add_account("6050", "Contract Labor", AccountType::Expense, None, false) + .unwrap(); + let riley = s.add_party("Riley Chen", &[Role::Contractor]).unwrap(); + let post = |s: &mut Store, date, what: &str, dr, cr| { + let e = s.create_entry(date, what).unwrap(); + s.add_line(e, dr, Side::Debit, usd(10_000)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(10_000)).unwrap(); + s.attach_receipt(Some(e), "r.pdf", "application/pdf", what.as_bytes(), None) + .unwrap(); + s.post_entry(e).unwrap(); + e + }; + let tagged = post(&mut s, d(2026, 3, 1), "Riley", labor, cash); + s.set_entry_party(tagged, Some(riley)).unwrap(); + let transfer = post(&mut s, d(2026, 4, 1), "To savings", savings, cash); + s.set_entry_no_party(transfer, "Between the company's own accounts") + .unwrap(); + let unnamed = post(&mut s, d(2026, 5, 1), "Mystery payment", labor, cash); + if let Some(p) = policy { + std::fs::write( + dir.0.join("wanderbooks.toml"), + format!("[books]\nparties = \"{p}\"\n"), + ) + .unwrap(); + } + (dir, s, tagged, transfer, unnamed) +} + +fn load_with_toml(dir: &Path) -> Books { + data::load_once(&Source { + data_dir: dir.join("books-data"), + books_toml: Some(dir.join("wanderbooks.toml")), + today: Some(today()), + }) + .unwrap() +} + +#[test] +fn entries_without_a_counterparty_need_attention_unless_off() { + use ledger_gui::books::Severity; + let item = |b: &Books| { + attention::find(b) + .into_iter() + .find(|a| a.title.ends_with("no counterparty")) + }; + // Unset is suggest: one note naming just the unmarked entry. + let (dir, mut s, _, transfer, unnamed) = counterparty_books("np-suggest", None); + let b = load_with_toml(&dir.0); + let a = item(&b).expect("an item"); + assert_eq!(a.title, "1 entry names no counterparty"); + assert_eq!(a.severity, Severity::Info); + assert!(a.prompt.starts_with("In wanderbooks,")); + assert!(a.prompt.contains("books.set_entry_party")); + assert!( + a.prompt + .contains(&format!("{unnamed} (Mystery payment, 2026-05-01)")) + ); + assert!(!a.prompt.contains(&format!("{transfer} ("))); + // The mark shows on the entry and reads in the journal. + assert_eq!( + b.ledger.entry_no_party(transfer), + Some("Between the company's own accounts") + ); + let line = b + .journal + .iter() + .find(|l| l.line_type == "entry.no_party") + .unwrap(); + let ev = describe(&b, line).unwrap(); + assert_eq!( + ev.text, + format!( + "Entry #{transfer} marked as having no counterparty: Between the company's own accounts" + ) + ); + assert_eq!(ev.route, Some(Route::Entry(transfer))); + let mut h = Harness::builder() + .with_size(egui::vec2(1100.0, 900.0)) + .build_eframe(|cc| App::with_books(&cc.egui_ctx, b, false, Route::Entry(transfer))); + h.run_steps(3); + use egui_kittest::kittest::Queryable; + h.get_by_label(&format!( + "{} No counterparty: Between the company's own accounts", + egui_phosphor::regular::USER_MINUS + )); + // Tagging the last one clears it. + let p = s.add_party("Mystery Co", &[Role::Contractor]).unwrap(); + s.set_entry_party(unnamed, Some(p)).unwrap(); + assert!(item(&load_with_toml(&dir.0)).is_none()); + + // Require: a to-do. + let (dir, _s, ..) = counterparty_books("np-require", Some("require")); + assert_eq!( + item(&load_with_toml(&dir.0)).unwrap().severity, + Severity::Todo + ); + // Off: never mentioned. + let (dir, _s, ..) = counterparty_books("np-off", Some("off")); + assert!(item(&load_with_toml(&dir.0)).is_none()); +} diff --git a/crates/ledger-gui/tests/screens.rs b/crates/ledger-gui/tests/screens.rs index ca006b1..26fdea8 100644 --- a/crates/ledger-gui/tests/screens.rs +++ b/crates/ledger-gui/tests/screens.rs @@ -64,6 +64,19 @@ fn screenshots() { .map(|p| p.id) }) .unwrap_or(1); + let reconciliation = loaded.reconciliations.last().map(|r| r.id).unwrap_or(1); + let voided = loaded + .reconciliations + .iter() + .find(|r| r.voided.is_some()) + .map_or(reconciliation, |r| r.id); + // A document linked to more than one subject, else the last. + let linked_doc = loaded + .attachments + .iter() + .rev() + .find(|a| loaded.links_of(a.id).len() > 1) + .map_or(last_doc, |a| a.id); drop(loaded); let routes: Vec<(&str, Route)> = vec![ ("overview", Route::Overview), @@ -98,6 +111,9 @@ fn screenshots() { ("parties", Route::Parties), ("party", Route::Party(party)), ("document", Route::Document(last_doc)), + ("document-linked", Route::Document(linked_doc)), + ("reconciliation", Route::Reconciliation(reconciliation)), + ("reconciliation-voided", Route::Reconciliation(voided)), ("event", Route::JournalEvent(last_event)), ("documents", Route::Documents), ("company", Route::Company), diff --git a/crates/ledger-gui/tests/spec_gui.rs b/crates/ledger-gui/tests/spec_gui.rs index 93cc4ca..74183d8 100644 --- a/crates/ledger-gui/tests/spec_gui.rs +++ b/crates/ledger-gui/tests/spec_gui.rs @@ -1210,10 +1210,11 @@ fn attention_is_pure_on_mixed_books() { assert_eq!(x.prompt, y.prompt); assert_eq!(x.severity, y.severity); } - // Two drafts, three posted without receipts. + // Two drafts, three posted without receipts (and the checking + // account, with activity before this month, due its reconciliation). let todos = first .iter() - .filter(|a| a.severity == Severity::Todo) + .filter(|a| a.severity == Severity::Todo && !a.title.starts_with("Reconcile ")) .count(); let problems = first .iter() diff --git a/crates/ledger-mcp/Cargo.toml b/crates/ledger-mcp/Cargo.toml index bbe0a2c..6351bba 100644 --- a/crates/ledger-mcp/Cargo.toml +++ b/crates/ledger-mcp/Cargo.toml @@ -15,6 +15,7 @@ ledger-core = { path = "../ledger-core" } ledger-store = { path = "../ledger-store" } ledger-import = { path = "../ledger-import" } ledger-config = { path = "../ledger-config" } +ledger-vault = { path = "../ledger-vault" } base64 = "0.22" mime_guess = "2" rust_decimal = "1" diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index bdf482d..53e0a02 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -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- 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."; +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- Attachments: file a file once and link it wherever it is evidence (books.link_attachment, or link_to on books.attach_receipt and books.file_document): an entry (a file linked to an entry counts as its receipt), an account, an asset, a party, an invoice, a bill, a period, a payroll run, a schedule, a reconciliation, or the company. books.attachments lists a subject's files; books.unlink_attachment takes a link off with a reason. File documents under their kind (statement, contract, photo, warranty, import_source, formation, legal, tax, accounting, other). File a bank export before importing it and pass source_attachment_id to books.import_bank_csv.\n- Reconciliations: every month, for each bank, cash, and card account, file the statement (books.file_document category statement), record what it says (books.record_statement), mark the lines it shows (books.mark_cleared), preview books.reconcile with statement_attachment, and confirm with confirmed=true once the difference is exactly zero: that records the reconciliation with its statement. Never confirm by forcing the numbers; find the difference. books.void_reconciliation (with a reason) undoes a mistake. books.digest lists unreconciled_accounts: accounts not reconciled through the end of last month.\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). Every post_entry preview says where the entry stands (party_policy, party_missing, party_suggestions): under [books] parties = \"suggest\" (the default), when party_missing, name the likely party to the human and tag it (or add it) before confirming; under \"require\", posting is refused (party_required) until the entry names a party or is marked as having none (books.set_entry_party with party null and no_party_reason, or draft_entry no_party_reason: only for an entry with truly no counterparty, like a transfer between the company's own accounts); under \"off\", don't raise it. Record W-9 facts (books.set_w9) for contractors and landlords before paying them, and W-4 facts (books.set_w4) for employees before their first payroll; name the employee party on every books.run_payroll line. Never put a full TIN or SSN anywhere in the books, only the last four digits: the engine refuses any nine-digit number in a party field. The full TIN goes only to the encrypted TIN vault: books.set_w9 with tin and tin_type (ssn, ein, itin), and the signed form with w9_file_path (never books.file_document or books.attach_receipt: the form shows the TIN). Never repeat a TIN back to the human, in a description, note, file name, commit, or anywhere else; tools show the last four only. books.get_w9 saves the form to a path outside the books for the human; books.purge_tin (preview, then confirmed, with a reason) deletes a TIN no longer needed. books.parties lists the directory, books.party shows one party with the year's totals, and books.show party opens it in the viewer. Archive (books.update_party archived) parties no longer dealt with; never delete history.\n- 1099s: mark contractors and landlords with books.set_vendor_1099 (and whether their W-9 is on file), and tag every payment to them with its vendor or party (books.draft_entry vendor or party, books.set_entry_vendor, or books.set_entry_party; books.pay_bill tags automatically). books.vendor_1099 is the year-end worksheet for the CPA; with include_tins: true (only when the human asks for the CPA's copy) it carries the full TINs from the vault, the one output that does: hand it over (save_csv_to, a path outside the books) and never repeat those numbers anywhere else.\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,89 @@ pub struct BooksServer { #[cfg(test)] pub use crate::settings::CashRoles; +/// Likely counterparties for an entry, best first (at most three): parties +/// named in its description, then in its receipts' names and +/// descriptions, then the party of an earlier entry with the same +/// description. Names match as whole words, ignoring case; a party also +/// matches by the first word of its name when that word has 4+ letters +/// ("Apple" for "Apple Inc."). +fn suggest_parties(store: &Store, entry: &JournalEntry) -> Result, String> { + let ledger = store.ledger(); + let words = |t: &str| -> Vec { + t.to_lowercase() + .split(|c: char| !c.is_alphanumeric()) + .filter(|w| !w.is_empty()) + .map(str::to_string) + .collect() + }; + let contains = |hay: &[String], needle: &[String]| { + !needle.is_empty() && hay.windows(needle.len()).any(|w| w == needle) + }; + let names_of = |p: &ledger_core::Party| -> Vec> { + let mut out = vec![words(&p.name)]; + if let Some(d) = &p.display_name { + out.push(words(d)); + } + if let Some(first) = words(&p.name).first() + && first.len() >= 4 + { + out.push(vec![first.clone()]); + } + out + }; + let receipts: String = store + .attachments_for(ledger_store::Subject::Entry(entry.id())) + .map_err(|e| e.to_string())? + .iter() + .map(|a| { + format!( + "{} {} ", + a.file_name, + a.description.clone().unwrap_or_default() + ) + }) + .collect(); + let desc = words(entry.description()); + let rec = words(&receipts); + let mut out: Vec = Vec::new(); + let push = |out: &mut Vec, p: &ledger_core::Party, why: String| { + if !out.iter().any(|s| s.party_id == p.id) { + out.push(PartySuggestionOut { + party_id: p.id, + name: p.name.clone(), + why, + }); + } + }; + let live: Vec<&ledger_core::Party> = ledger.parties().filter(|p| !p.archived).collect(); + for p in &live { + if names_of(p).iter().any(|n| contains(&desc, n)) { + push(&mut out, p, "named in the description".into()); + } + } + for p in &live { + if names_of(p).iter().any(|n| contains(&rec, n)) { + push(&mut out, p, "named in a receipt".into()); + } + } + let key = entry.description().trim().to_lowercase(); + for e in ledger.posted_entries() { + if e.id() != entry.id() + && e.description().trim().to_lowercase() == key + && let Some(p) = ledger.entry_party(e.id()).and_then(|i| ledger.get_party(i)) + && !p.archived + { + push( + &mut out, + p, + format!("tagged on entry {} with the same description", e.id()), + ); + } + } + out.truncate(3); + Ok(out) +} + /// 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). @@ -357,6 +440,113 @@ impl BooksServer { }) } + /// Cash and card accounts due for reconciliation: the `[books] + /// cash_accounts` and every liability with a recorded statement, with + /// posted activity through the end of last month, not reconciled + /// through it. + /// + /// ```text + /// GOAL: the monthly close's reconciliation step can't be forgotten. + /// SOURCE: docs/parties-and-attachments.md "Reconciliations" (Needs + /// attention: an account with activity not reconciled through + /// last month's end). + /// ``` + fn unreconciled_accounts( + &self, + store: &Store, + today: NaiveDate, + ) -> Result, String> { + let ledger = store.ledger(); + let due = chrono::Datelike::with_day(&today, 1) + .and_then(|d| d.pred_opt()) + .expect("a month before today"); + let mut accounts: Vec = self + .settings() + .roles + .cash + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect(); + for st in store.statements(None).map_err(|e| e.to_string())? { + if ledger + .chart() + .get(st.account_id) + .is_some_and(|a| a.account_type == AccountType::Liability) + { + accounts.push(st.account_id); + } + } + accounts.sort_unstable(); + accounts.dedup(); + let mut out = Vec::new(); + for id in accounts { + let active = ledger + .posted_entries() + .any(|e| e.date() <= due && e.lines().iter().any(|l| l.account == id)); + if !active { + continue; + } + let through = reconciled_through(store, id)?; + if through.is_some_and(|t| t >= due) { + continue; + } + let (code, name) = code_name(store, id); + out.push(UnreconciledAccountOut { + next_step: format!( + "file the {code} statement through {due} (books.file_document category statement), books.record_statement, mark the lines it shows (books.mark_cleared), then books.reconcile with statement_attachment and confirmed: true" + ), + code, + name, + reconciled_through: through.map(|d| d.to_string()), + due_through: due.to_string(), + }); + } + Ok(out) + } + + /// The TIN vault's directory: `vault/` in the books dir (git-ignored). + fn vault_dir(store: &Store) -> std::path::PathBuf { + books_dir(store).join("vault") + } + + /// Open the TIN vault with the key file in force (`[vault] key_file`, + /// else `~/.config/wanderbooks/vault.key`), creating the key only when + /// `create` (storing a TIN); reading never creates one. A key file + /// inside the books dir is refused: git would keep it beside the + /// records it unlocks. Storing into books kept in git is refused + /// unless their `.gitignore` lists `vault/`. + fn open_vault(&self, store: &Store, create: bool) -> Result { + let key = self.settings().key_file(); + if is_inside(&books_dir(store), &key) { + return Err(format!( + "the vault key file {} is inside the books dir: move it outside (default ~/.config/wanderbooks/vault.key) and set [vault] key_file in wanderbooks.toml", + key.display() + )); + } + // Writing: a git repository must ignore the vault, or its records + // would be committed (and could never be purged). + let books = books_dir(store); + if create && books.join(".git").exists() { + let ignored = std::fs::read_to_string(books.join(".gitignore")) + .unwrap_or_default() + .lines() + .any(|l| matches!(l.trim(), "vault/" | "/vault/" | "vault" | "/vault")); + if !ignored { + return Err(format!( + "the books dir {} is a git repository whose .gitignore doesn't list vault/: add a line \"vault/\" to it first, so the TIN vault is never committed", + books.display() + )); + } + } + ledger_vault::Vault::open(&Self::vault_dir(store), &key, create).map_err(|e| match e { + ledger_vault::Error::NoKey(k) => format!( + "the TIN vault is locked: its key file {} is missing (restore it from the human's password manager, or set [vault] key_file in wanderbooks.toml)", + k.display() + ), + other => other.to_string(), + }) + } + fn lock(&self) -> Result, String> { self.store .lock() @@ -668,6 +858,389 @@ fn resolve_party(store: &Store, party: &str) -> Result Result { + use ledger_store::Subject; + let kind = s.kind.trim(); + let text = match &s.id { + None => None, + Some(RefId::Number(n)) => Some(n.to_string()), + Some(RefId::Text(t)) => Some(t.trim().to_string()), + }; + if Subject::from_parts(kind, 0).is_none() { + return Err(format!( + "unknown subject kind {kind:?}: expected one of {SUBJECT_KINDS}" + )); + } + if kind == "company" { + return match text { + None => Ok(Subject::Company), + Some(_) => Err("the company subject takes no id".into()), + }; + } + let t = text.ok_or_else(|| format!("a {kind} subject needs its id"))?; + let sub = match kind { + "account" => Subject::Account(resolve_account(store, &t)?), + "asset" => Subject::Asset(resolve_asset_ref(store, &t)?), + "party" => Subject::Party(resolve_party(store, &t)?), + k => { + let id = t + .parse::() + .map_err(|_| format!("a {k} subject's id is a number, got {t:?}"))?; + Subject::from_parts(k, id).expect("kind checked") + } + }; + if !store.subject_exists(sub).map_err(|e| e.to_string())? { + return Err(format!("{} doesn't exist", sub.describe())); + } + Ok(sub) +} + +/// A subject for a tool's output, with human words for it. +fn subject_out(store: &Store, sub: ledger_store::Subject) -> SubjectOut { + use ledger_store::Subject as S; + let l = store.ledger(); + let label = match sub { + S::Entry(i) => l + .get_entry(i) + .map(|e| format!("entry #{i} {}", e.description())), + S::Account(i) => l + .chart() + .get(i) + .map(|a| format!("account {} {}", a.code, a.name)), + S::Asset(i) => l.get_asset(i).map(|a| match &a.tag { + Some(t) => format!("asset {t} {}", a.description), + None => format!("asset {i} {}", a.description), + }), + S::Party(i) => l.get_party(i).map(|p| format!("party {i} {}", p.label())), + S::Period(i) => l + .list_periods() + .iter() + .find(|p| p.id == i) + .map(|p| format!("period {}", p.name)), + S::PayrollRun(i) => l + .get_payroll_run(i) + .map(|r| format!("payroll run {i} of {}", r.date)), + S::Schedule(i) => l + .get_schedule(i) + .map(|s| format!("schedule {i} {}", s.description)), + S::Reconciliation(i) => store.reconciliations(None).ok().and_then(|rs| { + rs.into_iter().find(|r| r.id == i).map(|r| { + let code = l + .chart() + .get(r.account_id) + .map(|a| a.code.clone()) + .unwrap_or_default(); + format!("reconciliation {i} of {code} through {}", r.statement_date) + }) + }), + S::Invoice(_) | S::Bill(_) | S::Company => None, + } + .unwrap_or_else(|| sub.describe()); + SubjectOut { + kind: sub.kind().to_string(), + id: (sub != S::Company).then(|| sub.id()), + label, + } +} + +/// Everything an attachment is linked to, for a tool's output. +fn links_out(store: &Store, attachment_id: u64) -> Result, String> { + Ok(store + .attachment_links(attachment_id) + .map_err(|e| e.to_string())? + .into_iter() + .map(|s| subject_out(store, s)) + .collect()) +} + +/// A stored attachment's metadata, by id. +fn find_attachment(store: &Store, id: u64) -> Result { + store + .list_attachments(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|a| a.id == id) + .ok_or_else(|| format!("unknown attachment {id}")) +} + +/// An attachment for a listing, with its links. +fn receipt_out(store: &Store, a: ledger_store::Attachment) -> Result { + Ok(ReceiptOut { + links: links_out(store, a.id)?, + attachment_id: a.id, + sha256: a.blob_sha, + entry_id: a.entry_id, + category: a.category, + file_name: a.file_name, + mime_type: a.mime_type, + size_bytes: a.size_bytes, + description: a.description, + withdrawn: a.withdrawn_at.is_some(), + withdrawn_reason: a.withdrawn_reason, + }) +} + +/// Check every `link_to` subject before anything is filed: each resolves, +/// none twice, none the entry the file is filed on (all or nothing). +fn check_link_to( + store: &Store, + filed_on: Option, + link_to: &[SubjectParam], +) -> Result, String> { + let mut out: Vec = Vec::new(); + for s in link_to { + let sub = resolve_subject(store, s)?; + if out.contains(&sub) { + return Err(format!("link_to names {} twice", sub.describe())); + } + if filed_on.map(ledger_store::Subject::Entry) == Some(sub) { + return Err(format!( + "link_to names {}, which it is filed on already", + sub.describe() + )); + } + out.push(sub); + } + Ok(out) +} + +/// An account's code and name. +fn code_name(store: &Store, id: AccountId) -> (String, String) { + store + .ledger() + .chart() + .get(id) + .map(|a| (a.code.clone(), a.name.clone())) + .unwrap_or_default() +} + +/// A recorded statement for a tool's output. +fn statement_out(store: &Store, st: &ledger_store::Statement) -> Result { + let (account_code, account_name) = code_name(store, st.account_id); + let file_name = find_attachment(store, st.attachment_id)?.file_name; + let reconciliation_id = store + .reconciliations(Some(st.account_id)) + .map_err(|e| e.to_string())? + .into_iter() + .find(|r| r.attachment_id == st.attachment_id && r.voided.is_none()) + .map(|r| r.id); + Ok(StatementOut { + attachment_id: st.attachment_id, + file_name, + account_code, + account_name, + period_start: st.period_start.to_string(), + period_end: st.period_end.to_string(), + opening_balance: money(st.opening_balance), + closing_balance: money(st.closing_balance), + reconciliation_id, + }) +} + +/// A recorded reconciliation for a tool's output. +fn reconciliation_out(store: &Store, r: &ledger_store::Reconciliation) -> ReconciliationOut { + let (account_code, account_name) = code_name(store, r.account_id); + ReconciliationOut { + reconciliation_id: r.id, + account_code, + account_name, + statement_date: r.statement_date.to_string(), + statement_balance: money(r.statement_balance), + statement_attachment: r.attachment_id, + cleared: r + .cleared + .iter() + .map(|&(entry_id, line_index)| ClearedRefOut { + entry_id, + line_index, + }) + .collect(), + voided: r.voided.is_some(), + voided_reason: r.voided.clone(), + } +} + +/// The latest statement date an account is reconciled through +/// (reconciliations not voided), if any. +fn reconciled_through(store: &Store, account: AccountId) -> Result, String> { + Ok(store + .reconciliations(Some(account)) + .map_err(|e| e.to_string())? + .into_iter() + .filter(|r| r.voided.is_none()) + .map(|r| r.statement_date) + .max()) +} + +/// A line of a reconciliation report. +fn reconcile_line( + entry_id: u64, + line_index: usize, + date: NaiveDate, + description: &str, + side: Side, + amount: Decimal, +) -> ReconcileLineOut { + ReconcileLineOut { + entry_id, + line_index, + date: date.to_string(), + description: description.to_string(), + side: match side { + Side::Debit => "debit", + Side::Credit => "credit", + } + .to_string(), + amount: money(amount), + } +} + +/// The books dir: the data dir's parent, where wanderbooks.toml, the git +/// repository, and `vault/` live. +fn books_dir(store: &Store) -> std::path::PathBuf { + let d = store.data_dir(); + let d = d.canonicalize().unwrap_or_else(|_| d.to_path_buf()); + d.parent().map(std::path::Path::to_path_buf).unwrap_or(d) +} + +/// `path` made absolute through its nearest existing ancestor (symlinks +/// resolved), so a path that doesn't exist yet can still be placed. +fn resolved(path: &std::path::Path) -> std::path::PathBuf { + let abs = if path.is_absolute() { + path.to_path_buf() + } else { + std::env::current_dir() + .unwrap_or_else(|_| std::path::PathBuf::from("/")) + .join(path) + }; + let mut rest = Vec::new(); + let mut at = abs.as_path(); + loop { + if let Ok(c) = at.canonicalize() { + let mut out = c; + for part in rest.iter().rev() { + out.push(part); + } + return out; + } + match (at.parent(), at.file_name()) { + (Some(parent), Some(name)) => { + rest.push(name.to_os_string()); + at = parent; + } + _ => return abs, + } + } +} + +/// Whether `path` is `dir` or inside it. +fn is_inside(dir: &std::path::Path, path: &std::path::Path) -> bool { + resolved(path).starts_with(resolved(dir)) +} + +/// Where a tool may write a file holding a full TIN: a new file, in a +/// folder that exists, outside the books dir (which git commits). +fn outside_books(store: &Store, path: &str) -> Result { + let p = std::path::PathBuf::from(path.trim()); + if path.trim().is_empty() { + return Err("give a path to write to".into()); + } + if is_inside(&books_dir(store), &p) { + return Err(format!( + "{} is inside the books dir ({}), which git keeps forever: a file with a full TIN must go elsewhere", + p.display(), + books_dir(store).display() + )); + } + match p.parent() { + Some(d) if d.as_os_str().is_empty() || d.is_dir() => {} + _ => return Err(format!("the folder for {} doesn't exist", p.display())), + } + if p.exists() { + return Err(format!("{} exists already: name a new file", p.display())); + } + Ok(p) +} + +/// Write a new file readable by its owner only (0600 on Unix). +fn write_private_new(path: &std::path::Path, bytes: &[u8]) -> Result<(), String> { + use std::io::Write; + let mut opts = std::fs::OpenOptions::new(); + opts.write(true).create_new(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + opts.mode(0o600); + } + let mut f = opts + .open(path) + .map_err(|e| format!("couldn't create {}: {e}", path.display()))?; + f.write_all(bytes) + .and_then(|()| f.sync_all()) + .map_err(|e| format!("couldn't write {}: {e}", path.display())) +} + +/// A TIN type from a tool: ssn, ein, or itin. +fn parse_tin_type(s: &str) -> Result { + match s.trim().to_lowercase().as_str() { + "ssn" => Ok(ledger_vault::TinType::Ssn), + "ein" => Ok(ledger_vault::TinType::Ein), + "itin" => Ok(ledger_vault::TinType::Itin), + _ => Err("tin_type is ssn, ein, or itin".into()), + } +} + +/// A TIN type's wire name. +fn tin_type_key(t: ledger_vault::TinType) -> &'static str { + match t { + ledger_vault::TinType::Ssn => "ssn", + ledger_vault::TinType::Ein => "ein", + ledger_vault::TinType::Itin => "itin", + } +} + +/// Read a W-9 file (PDF, PNG, or JPEG, 25 MB at most) for the vault. +fn read_w9_file(path: &str) -> Result { + let p = std::path::Path::new(path.trim()); + let mime_type = match p + .extension() + .and_then(|e| e.to_str()) + .map(str::to_lowercase) + .as_deref() + { + Some("pdf") => "application/pdf", + Some("png") => "image/png", + Some("jpg" | "jpeg") => "image/jpeg", + _ => return Err("w9_file_path is a PDF, PNG, or JPEG (.pdf, .png, .jpg)".into()), + }; + let meta = std::fs::metadata(p).map_err(|e| format!("can't read {}: {e}", p.display()))?; + if !meta.is_file() { + return Err(format!("{} is not a file", p.display())); + } + if meta.len() > MAX_UPLOAD_BYTES as u64 { + return Err(format!( + "{} is {} bytes; the limit is {MAX_UPLOAD_BYTES}", + p.display(), + meta.len() + )); + } + let bytes = std::fs::read(p).map_err(|e| format!("can't read {}: {e}", p.display()))?; + Ok(ledger_vault::W9File { + file_name: p + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "w9".into()), + mime_type: mime_type.to_string(), + bytes, + }) +} + /// Parse role names (`Role::key`), refusing any unknown one. fn parse_roles(roles: &[String]) -> Result, String> { roles @@ -714,6 +1287,7 @@ fn party_out(p: &ledger_core::Party) -> PartyOut { fatca_code: w.fatca_code.clone(), tin_last4: w.tin_last4.clone(), kept_at: w.kept_at.clone(), + in_vault: w.vault_record.is_some(), }), w4: p.w4.as_ref().map(|w| W4Out { received_on: w.received.to_string(), @@ -1320,6 +1894,16 @@ impl BooksServer { .as_deref() .map(|r| resolve_party(&store, r)) .transpose()?; + let no_party = p + .no_party_reason + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()); + if no_party.is_some() && (party.is_some() || vendor.is_some()) { + return Err( + "no_party_reason is for an entry with no counterparty: pass it without party or vendor".into(), + ); + } 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) { @@ -1339,6 +1923,8 @@ impl BooksServer { store .set_entry_vendor(id, vendor) .map_err(|e| e.to_string())?; + } else if let Some(r) = no_party { + store.set_entry_no_party(id, r).map_err(|e| e.to_string())?; } Ok(Json(DraftEntryOut { entry_id: id, @@ -1451,6 +2037,20 @@ impl BooksServer { receipt_required = true; } let receipt_missing = receipt_required && receipts.is_empty(); + let party_policy = self.settings().parties; + let party_id = store.ledger().entry_party(p.entry_id); + let no_party_reason = store + .ledger() + .entry_no_party(p.entry_id) + .map(str::to_string); + let party_missing = party_policy != ledger_config::PartyPolicy::Off + && party_id.is_none() + && no_party_reason.is_none(); + let party_suggestions = if party_missing { + suggest_parties(&store, &entry)? + } else { + Vec::new() + }; let out = PostEntryOut { status: "preview".to_string(), entry_id: p.entry_id, @@ -1462,10 +2062,37 @@ impl BooksServer { receipt_required, receipt_missing, split_detected: split_group.clone(), + party_policy: party_policy.key().to_string(), + party_id, + party: party_id + .and_then(|i| store.ledger().get_party(i)) + .map(|x| x.name.clone()), + no_party_reason, + party_missing, + party_suggestions, }; if !p.confirmed.unwrap_or(false) { return Ok(Json(out)); } + if party_missing && party_policy == ledger_config::PartyPolicy::Require { + let names: Vec = out + .party_suggestions + .iter() + .map(|s| format!("{} (party {}, {})", s.name, s.party_id, s.why)) + .collect(); + return Err(serde_json::json!({ + "code": "party_required", + "message": format!( + "entry {} names no counterparty, and these books require one ([books] parties = \"require\"). Tag it with books.set_entry_party (add the party with books.add_party first if it's new), or, if it truly has none, call books.set_entry_party with party null and no_party_reason.{}", + p.entry_id, + if names.is_empty() { String::new() } else { format!(" Likely: {}.", names.join("; ")) } + ), + "entry_id": p.entry_id, + "remedy_tool": "books.set_entry_party", + "party_suggestions": out.party_suggestions, + }) + .to_string()); + } if receipt_missing { let split_note = if split_group.is_empty() { String::new() @@ -1554,7 +2181,7 @@ impl BooksServer { #[tool( name = "books.attach_receipt", - description = "Attach a receipt (base64 file bytes) to an entry, or file it unlinked and link later with books.link_receipt. Files are content-addressed and deduplicated." + description = "Attach a receipt (base64 file bytes) to an entry, or file it unlinked and link later with books.link_receipt. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing." )] async fn attach_receipt( &self, @@ -1573,6 +2200,12 @@ impl BooksServer { )); } let mut store = self.lock()?; + if let Some(e) = p.entry_id + && store.ledger().get_entry(e).is_none() + { + return Err(format!("unknown entry {e}")); + } + let link_to = check_link_to(&store, p.entry_id, &p.link_to)?; let (attachment_id, sha256, deduplicated) = store .attach_receipt( p.entry_id, @@ -1582,7 +2215,13 @@ impl BooksServer { p.description.as_deref(), ) .map_err(|e| e.to_string())?; + for sub in link_to { + store + .link_attachment(attachment_id, sub) + .map_err(|e| e.to_string())?; + } Ok(Json(AttachReceiptOut { + links: links_out(&store, attachment_id)?, attachment_id, sha256, deduplicated, @@ -1591,7 +2230,7 @@ impl BooksServer { #[tool( name = "books.file_document", - description = "File a formation, legal, tax, or accounting document into the content-addressed store." + description = "File a document into the content-addressed store: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, the company, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. Never file a W-9 here (it shows the full TIN): books.set_w9 w9_file_path puts it in the vault." )] async fn file_document( &self, @@ -1599,10 +2238,11 @@ impl BooksServer { ) -> Result, String> { let p = params.0; match p.category.as_str() { - "formation" | "legal" | "tax" | "accounting" | "other" => {} + "formation" | "legal" | "tax" | "accounting" | "statement" | "contract" | "photo" + | "warranty" | "import_source" | "other" => {} _ => { return Err(format!( - "bad category {:?}: expected formation, legal, tax, accounting, or other", + "bad category {:?}: expected formation, legal, tax, accounting, statement, contract, photo, warranty, import_source, or other", p.category )); } @@ -1619,6 +2259,7 @@ impl BooksServer { )); } let mut store = self.lock()?; + let link_to = check_link_to(&store, None, &p.link_to)?; let (attachment_id, sha256, deduplicated) = store .file_document( &p.category, @@ -1628,7 +2269,13 @@ impl BooksServer { p.description.as_deref(), ) .map_err(|e| e.to_string())?; + for sub in link_to { + store + .link_attachment(attachment_id, sub) + .map_err(|e| e.to_string())?; + } Ok(Json(AttachReceiptOut { + links: links_out(&store, attachment_id)?, attachment_id, sha256, deduplicated, @@ -1707,9 +2354,142 @@ impl BooksServer { Ok(Json(WithdrawAttachmentOut { ok: true })) } + /// ```text + /// GOAL: one file, linked wherever it is evidence, without refiling. + /// SOURCE: docs/parties-and-attachments.md "Attachments"; + /// Store::link_attachment. + /// INVARIANTS: + /// 1. Journaled as `attachment.linked`; a refusal journals nothing. + /// 2. A file linked to an entry counts as that entry's receipt. + /// SCENARIOS: + /// 1. IF the attachment is unknown or withdrawn, the subject kind is + /// unknown, or the subject doesn't exist: SHOULD refuse. + /// 2. IF it is already linked there (or filed on that entry): SHOULD + /// refuse. + /// ``` + #[tool( + name = "books.link_attachment", + description = "Link a stored receipt or document to a subject, so one file is evidence wherever it applies without filing it again. subject: {kind, id}: entry (id), account (code or id: its statements, the agreement that opened it), asset (tag or id: photos, warranty, manual), party (id or name: contracts, correspondence), invoice, bill, period (its close package), payroll_run, schedule (the prepaid contract), reconciliation, or company (no id: formation documents). A file linked to an entry counts as one of its receipts. Journaled as attachment.linked. Returns the attachment's links." + )] + async fn link_attachment( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + find_attachment(&store, p.attachment_id)?; + let sub = resolve_subject(&store, &p.subject)?; + store + .link_attachment(p.attachment_id, sub) + .map_err(|e| e.to_string())?; + Ok(Json(AttachmentLinksOut { + attachment_id: p.attachment_id, + links: links_out(&store, p.attachment_id)?, + })) + } + + /// ```text + /// GOAL: take a link off, on the record, without leaving evidence + /// missing where a rule needs it. + /// SOURCE: R-13a (receipt corrections are append-only events); + /// Store::unlink_attachment. + /// INVARIANTS: + /// 1. Journaled as `attachment.unlinked` with the reason; the file + /// stays filed. A refusal journals nothing. + /// SCENARIOS: + /// 1. IF the reason is blank, or it isn't linked to the subject: + /// SHOULD refuse. + /// 2. IF it is filed on that entry (rather than linked): SHOULD refuse, + /// naming books.unlink_receipt. + /// 3. IF it is a posted entry's last receipt and the receipt policy + /// covers the entry: SHOULD refuse (receipt_required). + /// 4. IF it is the statement of a reconciliation not voided: SHOULD + /// refuse (void the reconciliation first). + /// ``` + #[tool( + name = "books.unlink_attachment", + description = "Take a link (books.link_attachment) off a subject, with the reason recorded in the journal. The file stays filed and keeps its other links. A receipt filed on an entry comes off with books.unlink_receipt instead. Refused when it would leave a posted entry the receipt policy covers without a receipt (attach the replacement first), or take the statement off a reconciliation that isn't voided. Journaled as attachment.unlinked. Returns the attachment's links." + )] + async fn unlink_attachment( + &self, + params: Parameters, + ) -> Result, String> { + use ledger_store::Subject; + let p = params.0; + let mut store = self.lock()?; + let a = find_attachment(&store, p.attachment_id)?; + let sub = resolve_subject(&store, &p.subject)?; + let linked = store + .attachment_links(a.id) + .map_err(|e| e.to_string())? + .contains(&sub); + match sub { + Subject::Entry(e) if a.entry_id == Some(e) => { + return Err(format!( + "attachment {} is filed on entry {e}, not linked to it: take it off with books.unlink_receipt", + a.id + )); + } + Subject::Entry(e) if linked => { + let others = store + .list_attachments(Some(e)) + .map_err(|e| e.to_string())? + .iter() + .filter(|x| x.id != a.id) + .count(); + if others == 0 { + self.ensure_receipt_not_required(&store, e)?; + } + } + Subject::Reconciliation(r) + if store + .reconciliations(None) + .map_err(|e| e.to_string())? + .iter() + .any(|x| x.id == r && x.attachment_id == a.id && x.voided.is_none()) => + { + return Err(format!( + "attachment {} is the statement reconciliation {r} rests on: void the reconciliation first (books.void_reconciliation)", + a.id + )); + } + _ => {} + } + store + .unlink_attachment(a.id, sub, &p.reason) + .map_err(|e| e.to_string())?; + Ok(Json(AttachmentLinksOut { + attachment_id: a.id, + links: links_out(&store, a.id)?, + })) + } + + #[tool( + name = "books.attachments", + description = "The files on a subject ({kind, id} as in books.link_attachment): for an entry, the receipts filed on it and the files linked to it; for anything else, the files linked to it. Withdrawn files are left out. Each comes with all its links.", + annotations(read_only_hint = true) + )] + async fn attachments( + &self, + params: Parameters, + ) -> Result, String> { + let store = self.lock()?; + let sub = resolve_subject(&store, ¶ms.0.subject)?; + let attachments = store + .attachments_for(sub) + .map_err(|e| e.to_string())? + .into_iter() + .map(|a| receipt_out(&store, a)) + .collect::>()?; + Ok(Json(AttachmentsOut { + subject: subject_out(&store, sub), + attachments, + })) + } + #[tool( name = "books.list_receipts", - description = "List receipts and filed documents, optionally filtered to one entry. Withdrawn attachments are hidden unless include_withdrawn=true.", + description = "List receipts and filed documents, optionally filtered to one entry (filed on it or linked to it), each with everything it is linked to (links). Withdrawn attachments are hidden unless include_withdrawn=true. For any other subject's files, books.attachments.", annotations(read_only_hint = true) )] async fn list_receipts( @@ -1724,25 +2504,14 @@ impl BooksServer { .map_err(|e| e.to_string())? .into_iter() .filter(|a| include_withdrawn || a.withdrawn_at.is_none()) - .map(|a| ReceiptOut { - attachment_id: a.id, - sha256: a.blob_sha, - entry_id: a.entry_id, - category: a.category, - file_name: a.file_name, - mime_type: a.mime_type, - size_bytes: a.size_bytes, - description: a.description, - withdrawn: a.withdrawn_at.is_some(), - withdrawn_reason: a.withdrawn_reason, - }) - .collect(); + .map(|a| receipt_out(&store, a)) + .collect::>()?; Ok(Json(ListReceiptsOut { receipts })) } #[tool( name = "books.get_receipt", - description = "Fetch a receipt or document's metadata and base64 bytes. Bytes are hash-verified on read.", + description = "Fetch a receipt or document's metadata (with everything it is linked to) and base64 bytes. Bytes are hash-verified on read.", annotations(read_only_hint = true) )] async fn get_receipt( @@ -1751,16 +2520,13 @@ impl BooksServer { ) -> Result, String> { let p = params.0; let store = self.lock()?; - let meta = store - .list_attachments(None) - .map_err(|e| e.to_string())? - .into_iter() - .find(|a| a.id == p.attachment_id) - .ok_or_else(|| format!("unknown attachment {}", p.attachment_id))?; + let meta = find_attachment(&store, p.attachment_id)?; let bytes = store .attachment_bytes(p.attachment_id) .map_err(|e| e.to_string())?; Ok(Json(GetReceiptOut { + links: links_out(&store, meta.id)?, + description: meta.description.clone(), attachment_id: meta.id, sha256: meta.blob_sha, entry_id: meta.entry_id, @@ -1880,7 +2646,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 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.", + 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 reconciliation (by id: its account with its statements and reconciliations), 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> { @@ -1895,6 +2661,7 @@ impl BooksServer { p.attachment_id.is_some(), p.asset.is_some(), p.party.is_some(), + p.reconciliation.is_some(), p.search.is_some(), p.page.is_some(), ] @@ -1903,7 +2670,7 @@ impl BooksServer { .count(); if given != 1 { return Err( - "give exactly one of entry_id, account, report, attachment_id, asset, party, search, page" + "give exactly one of entry_id, account, report, attachment_id, asset, party, reconciliation, search, page" .into(), ); } @@ -1957,6 +2724,21 @@ impl BooksServer { ViewerTarget::Party { party_id: id }, format!("party {id}, {}", party.label()), ) + } else if let Some(id) = p.reconciliation { + let r = store + .reconciliations(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|r| r.id == id) + .ok_or_else(|| format!("unknown reconciliation {id}"))?; + let (code, name) = code_name(&store, r.account_id); + ( + ViewerTarget::Reconciliation { id }, + format!( + "reconciliation {id}, {code} {name} through {}", + r.statement_date + ), + ) } else if let Some(id) = p.entry_id { let e = ledger .get_entry(id) @@ -2098,7 +2880,7 @@ impl BooksServer { #[tool( name = "books.digest", - description = "What changed since a date: entries posted, per-account movement, and receipt coverage. Built for human review.", + description = "What changed since a date: entries posted, per-account movement, and receipt coverage; plus what needs doing: rules due for review, settings problems, and cash and card accounts not reconciled through the end of last month (unreconciled_accounts). Built for human review.", annotations(read_only_hint = true) )] async fn digest(&self, params: Parameters) -> Result, String> { @@ -2164,7 +2946,9 @@ impl BooksServer { saved_as, }) .collect(); + let unreconciled_accounts = self.unreconciled_accounts(&store, today)?; Ok(Json(DigestOut { + unreconciled_accounts, since: since.to_string(), entries, totals_by_account: totals, @@ -2185,7 +2969,7 @@ impl BooksServer { #[tool( name = "books.import_bank_csv", - description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Review with books.digest, then post." + description = "Import a QuickBooks-style bank/credit-card CSV: 3 columns Date,Description,Amount (payments negative), or 4 columns Date,Description, money out, money in (Intuit heads them Credit,Debit). Slash dates must settle month-first or day-first from the file itself or from date_order (\"month_first\" for mm/dd, \"day_first\" for dd/mm); ask the human when the tool says the dates read either way. Creates one DRAFT entry per transaction, all or none: bank account on one side, categorized offset on the other (the longest matching rule pattern, else default_offset_code). Review with books.digest, then post. File the export first (books.file_document category import_source) and pass source_attachment_id: it is linked to the bank account, so the import traces to its source (never to the drafts: a bank export is not a receipt)." )] async fn import_bank_csv( &self, @@ -2211,6 +2995,20 @@ impl BooksServer { .find_by_code(&p.bank_account_code) .map(|a| (a.id, a.code.clone())) .ok_or_else(|| format!("unknown bank account code: {}", p.bank_account_code))?; + // The source file links to the account, checked before any draft. + let link_source = match p.source_attachment_id { + None => false, + Some(att) => { + let a = find_attachment(&store, att)?; + if a.withdrawn_at.is_some() { + return Err(format!("attachment {att} is withdrawn")); + } + !store + .attachment_links(att) + .map_err(|e| e.to_string())? + .contains(&ledger_store::Subject::Account(bank.0)) + } + }; let default_offset = store .ledger() .chart() @@ -2287,8 +3085,18 @@ impl BooksServer { offset_account: offset_code, }); } + if link_source && let Some(att) = p.source_attachment_id { + store + .link_attachment(att, ledger_store::Subject::Account(bank.0)) + .map_err(|e| e.to_string())?; + } let count = imported.len(); - Ok(Json(ImportBankCsvOut { imported, count })) + Ok(Json(ImportBankCsvOut { + imported, + count, + source_attachment_id: p.source_attachment_id, + source_linked: link_source, + })) } #[tool( @@ -2673,7 +3481,7 @@ impl BooksServer { #[tool( name = "books.vendor_1099", - description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to asset accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 in 2026, indexed for inflation after that), and which are missing a W-9. For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and update the rule for everyone (pull request or issue at github.com/swaits/wanderbooks). For the CPA to confirm.", + description = "1099 worksheet for a calendar year: cash paid to each vendor (credits minus debits to asset accounts on entries tagged with the vendor, so card payments, which the card processor reports on 1099-K, are excluded), which vendors are reportable (marked NEC/MISC and paid at least the threshold: $600 through 2025, $2,000 in 2026, indexed for inflation after that), which are missing a W-9, and each payee's TIN last four. include_tins: true adds each payee's full TIN from the TIN vault (or why it can't): only when the human asks for the CPA's worksheet, and never repeat those TINs anywhere else; save_csv_to also writes the worksheet as CSV to a new file outside the books dir. For a year whose threshold wanderbooks doesn't know yet it refuses rather than guess: get the figure from the IRS or the human, pass it as `threshold`, and update the rule for everyone (pull request or issue at github.com/swaits/wanderbooks). For the CPA to confirm.", annotations(read_only_hint = true) )] async fn vendor_1099( @@ -2722,23 +3530,110 @@ impl BooksServer { p.year )); } + let ledger = store.ledger(); + // With include_tins, the vault is opened once (never creating a + // key: a locked vault becomes a note on each line). + let vault = p.include_tins.then(|| self.open_vault(&store, false)); + let mut vendors = Vec::new(); + for v in r.vendors { + let party_id = ledger.party_of_vendor(v.vendor_id); + let facts = party_id + .and_then(|id| ledger.get_party(id)) + .and_then(|p| p.w9.clone()); + let mut line = Vendor1099LineOut { + vendor_id: v.vendor_id, + name: v.name, + form_1099: form_1099_label(v.form), + tin_on_file: v.tin_on_file, + paid: money(v.paid), + reportable: v.reportable, + party_id, + tin_last4: facts.as_ref().map(|f| f.tin_last4.clone()), + tin: None, + tin_type: None, + tin_note: None, + }; + if let Some(vault) = &vault { + line.tin_note = match (vault, party_id) { + (_, None) => Some("no party for this vendor".into()), + (Err(e), _) => Some(e.clone()), + (Ok(vault), Some(pid)) => match vault.tin(pid) { + Err(e) => Some(e.to_string()), + Ok(None) => Some( + "not in the vault: record it with books.set_w9 tin (from the W-9)" + .into(), + ), + Ok(Some(t)) => { + let hash = vault.record_hash(pid).ok().flatten(); + match &facts { + None => Some( + "the vault holds a TIN but the books have no W-9 facts: record them with books.set_w9" + .into(), + ), + Some(f) if f.tin_last4 != t.last4() => Some(format!( + "the vault's TIN ends in {}, the W-9 facts say {}: check the W-9 and fix with books.set_w9", + t.last4(), + f.tin_last4 + )), + Some(f) if f.vault_record.is_some() && f.vault_record != hash => { + Some("the vault record isn't the one the books recorded: check the W-9 and record it again with books.set_w9 tin".into()) + } + Some(_) => { + line.tin = Some(t.formatted()); + line.tin_type = Some(tin_type_key(t.kind).to_string()); + None + } + } + } + }, + }; + } + vendors.push(line); + } + let warning = p.include_tins.then(|| { + "This worksheet holds full TINs: for the CPA only. Don't paste it into email, chat, notes, or the books; delete any saved copy once the CPA has it." + .to_string() + }); + let saved_csv = match &p.save_csv_to { + None => None, + Some(path) => { + let dest = outside_books(&store, path)?; + let mut csv = + String::from("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4"); + if p.include_tins { + csv.push_str(",tin,tin_type,tin_note"); + } + csv.push('\n'); + for l in &vendors { + let mut cells = vec![ + l.vendor_id.to_string(), + csv_escape(&l.name), + l.form_1099.clone().unwrap_or_default(), + l.paid.clone(), + l.reportable.to_string(), + l.tin_on_file.to_string(), + l.tin_last4.clone().unwrap_or_default(), + ]; + if p.include_tins { + cells.push(l.tin.clone().unwrap_or_default()); + cells.push(l.tin_type.clone().unwrap_or_default()); + cells.push(csv_escape(l.tin_note.as_deref().unwrap_or_default())); + } + csv.push_str(&cells.join(",")); + csv.push('\n'); + } + write_private_new(&dest, csv.as_bytes())?; + Some(dest.display().to_string()) + } + }; Ok(Json(Vendor1099Out { year: r.year, threshold: money(r.threshold), - vendors: r - .vendors - .into_iter() - .map(|v| Vendor1099LineOut { - vendor_id: v.vendor_id, - name: v.name, - form_1099: form_1099_label(v.form), - tin_on_file: v.tin_on_file, - paid: money(v.paid), - reportable: v.reportable, - }) - .collect(), + vendors, missing_w9, notes, + warning, + saved_csv, })) } @@ -3042,6 +3937,7 @@ impl BooksServer { tax_form: c.tax_form.map(|f| f.label().to_string()), basis: c.basis.clone(), receipt_rule: cfg.policy.rule(), + parties: cfg.parties.key().to_string(), members: c .members .iter() @@ -3980,54 +4876,298 @@ impl BooksServer { Ok(Json(MarkClearedOut { ok: true })) } + /// ```text + /// GOAL: a monthly reconciliation that leaves proof: which statement, + /// which balance, which lines it cleared. + /// SOURCE: CONVENTION (bank reconciliation); docs/parties-and- + /// attachments.md "Reconciliations"; Store::complete_reconciliation. + /// INVARIANTS: + /// 1. Without confirmed=true nothing is recorded: the preview reports + /// the difference, the cleared and uncleared lines, and the + /// problems a confirmation would hit. + /// 2. A reconciliation is recorded only against a recorded statement + /// (statement_attachment), only at a difference of exactly zero, + /// and only after the account's last reconciliation not voided. + /// 3. A refusal journals nothing. + /// SCENARIOS: + /// 1. IF statement_attachment is given: the account, date, and + /// balance are the statement's; any given that disagree: SHOULD + /// refuse. + /// 2. IF it isn't: account, statement_date, and statement_balance are + /// required, and confirmed=true SHOULD refuse. + /// 3. IF confirmed=true and problems is not empty: SHOULD refuse, + /// naming them. + /// ``` #[tool( name = "books.reconcile", - description = "Reconcile a bank/cash account against a statement. Returns book balance, cleared balance, statement balance, the difference (zero means reconciled), and the list of uncleared lines." + description = "Reconcile a bank, cash, or card account against a statement. Give statement_attachment (a statement recorded with books.record_statement: its account, period end, and closing balance), or account, statement_date, and statement_balance for a quick check against any balance. Returns the book balance, cleared balance, statement balance, the difference (cleared minus statement; zero means reconciled), the cleared and uncleared lines through the statement date, and the problems that would stop it being recorded. Without confirmed=true it is a preview and records nothing. With confirmed=true and a recorded statement it records the reconciliation (journaled as reconciliation.completed, linked to the statement), refused unless the difference is exactly zero and it comes after the account's last reconciliation. Mark lines cleared with books.mark_cleared first." )] async fn reconcile( &self, params: Parameters, ) -> Result, String> { let p = params.0; - let store = self.lock()?; - let account = resolve_account(&store, &p.account)?; + let confirmed = p.confirmed.unwrap_or(false); + let mut store = self.lock()?; + let given_account = p + .account + .as_deref() + .map(|a| resolve_account(&store, a)) + .transpose()?; + let given_date = p.statement_date.as_deref().map(parse_date).transpose()?; + let given_balance = p + .statement_balance + .as_deref() + .map(parse_amount) + .transpose()?; + let (account, date, balance) = match p.statement_attachment { + Some(att) => { + find_attachment(&store, att)?; + let st = store + .statement(att) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + format!( + "attachment {att} records no statement: record it with books.record_statement first" + ) + })?; + if let Some(a) = given_account.filter(|a| *a != st.account_id) { + return Err(format!( + "statement {att} is for account {}, not {}", + code_name(&store, st.account_id).0, + code_name(&store, a).0 + )); + } + if let Some(d) = given_date.filter(|d| *d != st.period_end) { + return Err(format!("statement {att} ends {}, not {d}", st.period_end)); + } + if let Some(b) = given_balance.filter(|b| *b != st.closing_balance) { + return Err(format!( + "statement {att} closes at {}, not {}", + money(st.closing_balance), + money(b) + )); + } + (st.account_id, st.period_end, st.closing_balance) + } + None => { + if confirmed { + return Err("a reconciliation is recorded only against a recorded statement: file it (books.file_document category statement), record what it says (books.record_statement), then pass statement_attachment".into()); + } + match (given_account, given_date, given_balance) { + (Some(a), Some(d), Some(b)) => (a, d, b), + _ => { + return Err("give statement_attachment, or account, statement_date, and statement_balance".into()); + } + } + } + }; let report = store .ledger() - .reconciliation_report( - account, - parse_date(&p.statement_date)?, - parse_amount(&p.statement_balance)?, - ) + .reconciliation_report(account, date, balance) .map_err(|e| e.to_string())?; - let code = store - .ledger() - .chart() - .get(account) - .map(|a| a.code.clone()) - .unwrap_or_default(); - let uncleared = report - .uncleared_lines - .into_iter() - .map(|l| UnclearedLineOut { - entry_id: l.entry_id, - line_index: l.line_index, - date: l.date.to_string(), - description: l.description, - side: match l.side { - Side::Debit => "debit".to_string(), - Side::Credit => "credit".to_string(), - }, - amount: l.amount.to_string(), - }) + let ledger = store.ledger(); + let mut cleared_lines = Vec::new(); + for e in ledger.posted_entries() { + if e.date() > date { + continue; + } + for (i, l) in e.lines().iter().enumerate() { + if l.account == account && ledger.is_cleared(e.id(), i) { + cleared_lines.push(reconcile_line( + e.id(), + i, + e.date(), + e.description(), + l.side, + l.amount.amount, + )); + } + } + } + cleared_lines.sort_by(|a, b| { + a.date + .cmp(&b.date) + .then(a.entry_id.cmp(&b.entry_id)) + .then(a.line_index.cmp(&b.line_index)) + }); + let mut problems = Vec::new(); + if p.statement_attachment.is_none() { + problems.push("no recorded statement: file it, books.record_statement, then pass statement_attachment".to_string()); + } + if !report.difference.is_zero() { + problems.push(format!( + "the cleared balance differs from the statement by {}: mark the lines the statement shows with books.mark_cleared (or find what's missing)", + money(report.difference) + )); + } + if let Some(last) = reconciled_through(&store, account)? + && last >= date + { + problems.push(format!( + "already reconciled through {last}: void that reconciliation (books.void_reconciliation) to redo it" + )); + } + let mut out = ReconcileOut { + status: "preview".to_string(), + account_code: code_name(&store, account).0, + statement_date: date.to_string(), + statement_attachment: p.statement_attachment, + book_balance: money(report.book_balance), + cleared_balance: money(report.cleared_balance), + statement_balance: money(report.statement_balance), + difference: money(report.difference), + uncleared_lines: report + .uncleared_lines + .into_iter() + .map(|l| { + reconcile_line( + l.entry_id, + l.line_index, + l.date, + &l.description, + l.side, + l.amount, + ) + }) + .collect(), + cleared_lines, + problems, + reconciliation: None, + }; + if !confirmed { + return Ok(Json(out)); + } + if !out.problems.is_empty() { + return Err(format!("not recorded: {}", out.problems.join("; "))); + } + let att = p.statement_attachment.expect("required when confirmed"); + let r = store + .complete_reconciliation(att) + .map_err(|e| e.to_string())?; + out.status = "reconciled".to_string(); + out.reconciliation = Some(reconciliation_out(&store, &r)); + Ok(Json(out)) + } + + /// ```text + /// GOAL: the bank and card statements are in the books, so a + /// reconciliation names the one it agrees with. + /// SOURCE: Store::record_statement. + /// INVARIANTS: + /// 1. Journaled as `statement.recorded`, then `attachment.linked` to + /// the account (unless linked already). A refusal journals nothing. + /// SCENARIOS: + /// 1. IF the attachment is unknown, withdrawn, or already records a + /// statement; the account is unknown or not an asset or liability; + /// the period runs backwards; or a balance isn't a decimal string + /// in cents: SHOULD refuse. + /// ``` + #[tool( + name = "books.record_statement", + description = "Record what a filed bank or card statement says (file it first: books.file_document category statement): the account it is for (code or id; an asset or liability), the period it covers (period_start, period_end: the statement date), and its opening and closing balances as printed, in the account's normal direction (what the bank holds; what the card is owed), decimal strings. Links the statement to the account. Then reconcile to it: books.reconcile statement_attachment. Journaled as statement.recorded. Returns the statement." + )] + async fn record_statement( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + find_attachment(&store, p.attachment_id)?; + let st = ledger_store::Statement { + attachment_id: p.attachment_id, + account_id: resolve_account(&store, &p.account)?, + period_start: parse_date(&p.period_start)?, + period_end: parse_date(&p.period_end)?, + opening_balance: parse_amount(&p.opening_balance)?, + closing_balance: parse_amount(&p.closing_balance)?, + }; + store + .record_statement(st.clone()) + .map_err(|e| e.to_string())?; + Ok(Json(statement_out(&store, &st)?)) + } + + #[tool( + name = "books.statements", + description = "Recorded bank and card statements (books.record_statement), optionally one account's (code or id), by statement date: the attachment, account, period, opening and closing balances, and the reconciliation made against it, if any.", + annotations(read_only_hint = true) + )] + async fn statements( + &self, + params: Parameters, + ) -> Result, String> { + let store = self.lock()?; + let account = params + .0 + .account + .as_deref() + .map(|a| resolve_account(&store, a)) + .transpose()?; + let statements = store + .statements(account) + .map_err(|e| e.to_string())? + .iter() + .map(|st| statement_out(&store, st)) + .collect::>()?; + Ok(Json(StatementsOut { statements })) + } + + #[tool( + name = "books.reconciliations", + description = "Recorded reconciliations (books.reconcile confirmed), optionally one account's (code or id), by statement date: the account, the statement date and balance, the statement's attachment, the lines it found cleared, and whether it was voided (and why).", + annotations(read_only_hint = true) + )] + async fn reconciliations( + &self, + params: Parameters, + ) -> Result, String> { + let store = self.lock()?; + let account = params + .0 + .account + .as_deref() + .map(|a| resolve_account(&store, a)) + .transpose()?; + let reconciliations = store + .reconciliations(account) + .map_err(|e| e.to_string())? + .iter() + .map(|r| reconciliation_out(&store, r)) .collect(); - Ok(Json(ReconcileOut { - account_code: code, - book_balance: report.book_balance.to_string(), - cleared_balance: report.cleared_balance.to_string(), - statement_balance: report.statement_balance.to_string(), - difference: report.difference.to_string(), - uncleared_lines: uncleared, - })) + Ok(Json(ReconciliationsOut { reconciliations })) + } + + /// ```text + /// GOAL: undo a mistaken reconciliation on the record, to redo it. + /// SOURCE: Store::void_reconciliation. + /// SCENARIOS: + /// 1. IF it is unknown or already voided, or the reason is blank: + /// SHOULD refuse, journaling nothing. + /// 2. Otherwise: journaled as `reconciliation.voided`; it stays + /// listed, marked voided, and the account can be reconciled + /// through that date again. + /// ``` + #[tool( + name = "books.void_reconciliation", + description = "Void a recorded reconciliation that was a mistake, with the reason recorded in the journal, so the account can be reconciled through that date again. Nothing is erased: it stays listed in books.reconciliations, marked voided. Journaled as reconciliation.voided. Returns it." + )] + async fn void_reconciliation( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let mut store = self.lock()?; + store + .void_reconciliation(p.reconciliation_id, &p.reason) + .map_err(|e| e.to_string())?; + let r = store + .reconciliations(None) + .map_err(|e| e.to_string())? + .into_iter() + .find(|r| r.id == p.reconciliation_id) + .ok_or_else(|| format!("unknown reconciliation {}", p.reconciliation_id))?; + Ok(Json(reconciliation_out(&store, &r))) } // ------------------------------------------------------------------ @@ -4205,12 +5345,48 @@ impl BooksServer { Ok(Json(party_out(party))) } + /// ```text + /// GOAL: the books know a W-9 arrived and what it said, and the full + /// TIN (and the form, which shows it) is kept where it can be + /// handed to the CPA and purged, never in the journal. + /// SOURCE: docs/parties-and-attachments.md "The TIN vault"; B-26; + /// Treas. Reg. §301.6109-4 (truncated TINs); DECISION (Stephen, + /// 2026-10-15): full TINs only in the encrypted vault. + /// INVARIANTS: + /// 1. A full TIN goes only to the vault (`vault/` in the books dir, + /// key outside it, created on first use); the facts journaled + /// (`party.w9_set`) carry its last four and the vault record's + /// hash. The output never holds more than the last four. + /// 2. The W-9 file goes only to the vault, never the attachment store. + /// 3. The facts are checked (on a copy of the ledger) before the vault + /// is written; a refusal journals nothing and writes no record. + /// 4. The facts never disagree with the vault: a TIN in the vault has + /// the facts' last four. + /// SCENARIOS: + /// 1. IF tin is given: SHOULD validate it (Tin::new, with tin_type), + /// take tin_last4 from it (refusing a given one that differs), and + /// default kept_at to "wanderbooks vault". + /// 2. IF no tin is given but the vault holds one whose last four + /// differ: SHOULD refuse (pass the new TIN, or books.purge_tin). + /// 3. IF clearing while the vault holds a record: SHOULD refuse + /// (books.purge_tin first). + /// ``` #[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." + 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, and where the form is kept. Give the full TIN as tin with tin_type (ssn, ein, itin) and it goes only to the encrypted TIN vault (vault/ beside the books, git-ignored; its key outside the books, created on first use): the books keep its last four (tin_last4 is then optional, and refused if it disagrees) and a hash of the vault record, and kept_at defaults to \"wanderbooks vault\". Give w9_file_path (a local PDF, PNG, or JPEG of the signed form) and the form goes to the vault too, never to the receipts and documents; read it back with books.get_w9. Never put a TIN in any other field, note, description, or file: the engine refuses any nine-digit number, and no tool output echoes one. 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). Clearing W-9 facts whose TIN is in the vault needs books.purge_tin first. Journaled as party.w9_set. Returns the party (the TIN's last four only)." )] async fn set_w9(&self, params: Parameters) -> Result, String> { let p = params.0; + let vault_input = p.tin.is_some() || p.tin_type.is_some() || p.w9_file_path.is_some(); + let tin = match (&p.tin, &p.tin_type) { + (Some(t), Some(k)) => Some( + ledger_vault::Tin::new(t.0.trim(), parse_tin_type(k)?) + .map_err(|e| e.to_string())?, + ), + (Some(_), None) => return Err("tin needs tin_type: ssn, ein, or itin".into()), + (None, Some(_)) => return Err("tin_type goes with tin".into()), + (None, None) => None, + }; let facts = if p.clear { if p.received_on.is_some() || p.entity_type.is_some() @@ -4218,6 +5394,7 @@ impl BooksServer { || p.fatca_code.is_some() || p.tin_last4.is_some() || p.kept_at.is_some() + || vault_input { return Err("clear: true takes no other W-9 fact".into()); } @@ -4228,18 +5405,102 @@ impl BooksServer { .map(|s| s.trim().to_string()) .ok_or_else(|| format!("{name} is required (or pass clear: true)")) }; + let tin_last4 = match (&tin, p.tin_last4.as_deref().map(str::trim)) { + (Some(t), Some(given)) if given != t.last4() => { + return Err( + "tin_last4 doesn't match the TIN's last four digits (leave it out: it comes from tin)" + .into(), + ); + } + (Some(t), _) => t.last4().to_string(), + (None, _) => need(&p.tin_last4, "tin_last4")?, + }; + let kept_at = match &p.kept_at { + Some(k) => k.trim().to_string(), + None if vault_input => "wanderbooks vault".to_string(), + None => need(&p.kept_at, "kept_at")?, + }; 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")?, + tin_last4, + kept_at, vault_record: None, }) }; + let w9_file = p.w9_file_path.as_deref().map(read_w9_file).transpose()?; let mut store = self.lock()?; let id = resolve_party(&store, &p.party)?; + let existing = store.ledger().get_party(id).and_then(|p| p.w9.clone()); + let vault_dir = Self::vault_dir(&store); + let (held_tin, held_w9) = + ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; + let facts = match facts { + None => { + if held_tin + || held_w9 + || existing.as_ref().is_some_and(|f| f.vault_record.is_some()) + { + return Err(format!( + "the TIN vault holds party {id}'s TIN or W-9: purge it first (books.purge_tin, with the reason), then clear" + )); + } + None + } + Some(mut f) => { + // The facts must agree with a TIN the vault keeps + // (INVARIANT 4). + if tin.is_none() && held_tin { + let held = self + .open_vault(&store, false)? + .tin(id) + .map_err(|e| e.to_string())?; + if let Some(t) = held + && t.last4() != f.tin_last4 + { + return Err(format!( + "the TIN vault holds a TIN ending in {} for party {id}: pass the new tin (with tin_type), or purge the old one first (books.purge_tin)", + t.last4() + )); + } + } + // Rehearse with a stand-in hash before writing the vault + // (INVARIANT 3). + let mut rehearsal = store.ledger().clone(); + f.vault_record = Some("0".repeat(64)); + rehearsal + .set_party_w9(id, Some(f.clone())) + .map_err(|e| e.to_string())?; + f.vault_record = if tin.is_some() || w9_file.is_some() { + let vault = self.open_vault(&store, true)?; + let tin_hash = match &tin { + Some(t) => Some(vault.put_tin(id, t).map_err(|e| e.to_string())?), + None => None, + }; + let w9_hash = match &w9_file { + Some(w) => Some(vault.put_w9(id, w).map_err(|e| e.to_string())?), + None => None, + }; + match tin_hash { + Some(h) => Some(h), + None if held_tin => vault.record_hash(id).map_err(|e| e.to_string())?, + None => w9_hash, + } + } else if held_tin { + ledger_vault::Vault::open(&vault_dir, &self.settings().key_file(), false) + .ok() + .and_then(|v| v.record_hash(id).ok().flatten()) + .or_else(|| existing.as_ref().and_then(|e| e.vault_record.clone())) + } else if held_w9 { + existing.as_ref().and_then(|e| e.vault_record.clone()) + } else { + None + }; + Some(f) + } + }; 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. @@ -4260,6 +5521,154 @@ impl BooksServer { Ok(Json(party_out(party))) } + /// ```text + /// GOAL: hand the human (for the CPA) the signed W-9 the vault keeps, + /// without it passing through the books or the conversation. + /// SOURCE: docs/parties-and-attachments.md "The TIN vault". + /// INVARIANTS: + /// 1. The file is written only to the path given, outside the books + /// dir, as a new file readable by its owner only; the output is + /// the path, never the contents. + /// 2. Nothing is journaled. + /// SCENARIOS: + /// 1. IF the path is inside the books dir, exists, or its folder + /// doesn't: SHOULD refuse. + /// 2. IF the vault is locked or holds no W-9 file for the party: + /// SHOULD refuse, saying which. + /// ``` + #[tool( + name = "books.get_w9", + description = "Write a party's signed W-9 file from the TIN vault to a local path (save_to: a new file outside the books dir, since the form shows the full TIN) and return the path, never the contents. Give the human the path; never open, read, or attach the file yourself. Nothing is journaled." + )] + async fn get_w9(&self, params: Parameters) -> Result, String> { + let p = params.0; + let store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let dest = outside_books(&store, &p.save_to)?; + let file = self + .open_vault(&store, false)? + .w9(id) + .map_err(|e| e.to_string())? + .ok_or_else(|| { + format!( + "the TIN vault holds no W-9 file for party {id}: store one with books.set_w9 w9_file_path" + ) + })?; + write_private_new(&dest, &file.bytes)?; + Ok(Json(GetW9Out { + party_id: id, + path: dest.display().to_string(), + file_name: file.file_name, + size_bytes: file.bytes.len() as u64, + note: format!( + "Saved {} (readable by you only). It shows the full TIN: give it to the CPA, keep it out of the books, email, and chat, and delete it when done.", + dest.display() + ), + })) + } + + /// ```text + /// GOAL: a TIN kept no longer than needed can be destroyed, on the + /// record. + /// SOURCE: docs/parties-and-attachments.md "The TIN vault" ("Purge"); + /// IRS Pub. 583 (keep records while needed; then dispose). + /// INVARIANTS: + /// 1. Without confirmed=true: a preview, changing nothing. + /// 2. Confirmed: the party's vault records (TIN and W-9 file) are + /// deleted, needing no key; and when the party has W-9 facts, + /// `party.w9_set` journals them with the vault record cleared and + /// kept_at saying when and why (the last four stay). + /// 3. The new facts are checked before anything is deleted; a refusal + /// changes nothing. + /// SCENARIOS: + /// 1. IF the reason is blank: SHOULD refuse. + /// 2. IF the vault holds nothing for the party and its facts name no + /// vault record: SHOULD refuse (nothing to purge). + /// ``` + #[tool( + name = "books.purge_tin", + description = "Delete a party's full TIN and W-9 file from the TIN vault (after the retention period, at the payee's request, or to replace a wrong one), with the reason. Without confirmed=true it previews what the vault holds and changes nothing. Confirmed, the records are deleted for good and the books journal it: the party's W-9 facts (party.w9_set) keep the last four, drop the vault record, and say when and why it was purged in kept_at. Works without the vault key." + )] + async fn purge_tin( + &self, + params: Parameters, + ) -> Result, String> { + let p = params.0; + let reason = p.reason.trim(); + if reason.is_empty() { + return Err("give the reason for purging".into()); + } + let mut store = self.lock()?; + let id = resolve_party(&store, &p.party)?; + let party = store.ledger().get_party(id).expect("resolved").clone(); + let vault_dir = Self::vault_dir(&store); + let (held_tin, held_w9) = + ledger_vault::records_held(&vault_dir, id).map_err(|e| e.to_string())?; + let recorded = party.w9.as_ref().is_some_and(|f| f.vault_record.is_some()); + if !held_tin && !held_w9 && !recorded { + return Err(format!( + "the TIN vault holds nothing for party {id} ({}) and its W-9 facts name no vault record", + party.name + )); + } + let new_facts = party.w9.clone().map(|f| ledger_core::W9Facts { + vault_record: None, + kept_at: format!( + "not kept: purged from the wanderbooks vault on {}: {reason}", + today() + ), + ..f + }); + if let Some(f) = &new_facts { + store + .ledger() + .clone() + .set_party_w9(id, Some(f.clone())) + .map_err(|e| e.to_string())?; + } + let mut out = PurgeTinOut { + status: "preview".into(), + party_id: id, + name: party.name.clone(), + tin_in_vault: held_tin, + w9_file_in_vault: held_w9, + tin_last4: party.w9.as_ref().map(|f| f.tin_last4.clone()), + journaled: false, + note: format!( + "Confirming deletes {} for good{}. Make sure the CPA has what they need first.", + match (held_tin, held_w9) { + (true, true) => "the TIN and the W-9 file", + (true, false) => "the TIN", + (false, true) => "the W-9 file", + (false, false) => "nothing in the vault (the books' record of it is cleared)", + }, + if new_facts.is_some() { + " and journals the purge in the W-9 facts (party.w9_set; the last four stay)" + } else { + "" + } + ), + }; + if !p.confirmed.unwrap_or(false) { + return Ok(Json(out)); + } + ledger_vault::purge_records(&vault_dir, id).map_err(|e| e.to_string())?; + if let Some(f) = new_facts { + store.set_party_w9(id, Some(f)).map_err(|e| e.to_string())?; + out.journaled = true; + } + out.status = "purged".into(); + out.note = format!( + "Purged from the vault{}.", + if out.journaled { + "; the W-9 facts record when and why" + } else { + "" + } + ); + Ok(Json(out)) + } + #[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." @@ -4482,9 +5891,31 @@ impl BooksServer { .as_deref() .map(|r| resolve_party(&store, r)) .transpose()?; - store - .set_entry_party(p.entry_id, party) - .map_err(|e| e.to_string())?; + let reason = p + .no_party_reason + .as_deref() + .map(str::trim) + .filter(|r| !r.is_empty()); + match (party, reason) { + (Some(_), Some(_)) => { + return Err("pass a party or a no_party_reason, not both".into()); + } + (None, Some(r)) => { + if store.ledger().entry_party(p.entry_id).is_some() { + store + .set_entry_party(p.entry_id, None) + .map_err(|e| e.to_string())?; + } + store + .set_entry_no_party(p.entry_id, r) + .map_err(|e| e.to_string())?; + } + (party, None) => { + store + .set_entry_party(p.entry_id, party) + .map_err(|e| e.to_string())?; + } + } } self.get_entry(Parameters(GetEntryParams { entry_id: p.entry_id, diff --git a/crates/ledger-mcp/src/settings.rs b/crates/ledger-mcp/src/settings.rs index ff85339..31ce459 100644 --- a/crates/ledger-mcp/src/settings.rs +++ b/crates/ledger-mcp/src/settings.rs @@ -50,9 +50,23 @@ pub struct Settings { /// The prefix of generated asset tags (`[books] asset_tag_prefix`, or /// the company's initials). pub asset_tag_prefix: String, + /// `[vault] key_file`: the TIN vault's key file (unset: the vault's + /// default, `ledger_vault::default_key_file`). + pub vault_key_file: Option, + /// `[books] parties`: suggest, require, or never mention each entry's + /// counterparty. + pub parties: ledger_config::PartyPolicy, } impl Settings { + /// The vault key file in force: `[vault] key_file`, else the default + /// outside the books (`~/.config/wanderbooks/vault.key`). + pub fn key_file(&self) -> PathBuf { + self.vault_key_file + .clone() + .unwrap_or_else(ledger_vault::default_key_file) + } + /// The prefix for generated asset tags ("FA" when none is set). pub fn tag_prefix(&self) -> &str { if self.asset_tag_prefix.is_empty() { @@ -96,6 +110,8 @@ impl Source { let policy = ledger_config::effective_receipt_policy(&self.config_json, &books)?; Ok(Settings { asset_tag_prefix: books.asset_tag_prefix(), + vault_key_file: books.vault_key_file.clone(), + parties: books.parties, policy, roles: CashRoles { cash: books.cash_accounts, diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index ade59b1..96388f4 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -356,6 +356,16 @@ pub struct Vendor1099Params { /// Override the reporting threshold, decimal string (default: $600 /// through 2025, $2,000 from 2026). pub threshold: Option, + /// True: each line carries its payee's full TIN, read from the TIN + /// vault (or a note saying why not). The output then holds full TINs: + /// for the CPA only, when the human asks for it. Default false (last + /// four digits only). + #[serde(default)] + pub include_tins: bool, + /// Also write the worksheet as CSV to this local path (TINs included + /// only with include_tins). Never inside the books dir; an existing + /// file is refused. + pub save_csv_to: Option, } /// One vendor's year. @@ -373,6 +383,18 @@ pub struct Vendor1099LineOut { pub paid: String, /// Marked for a 1099 and paid at least the threshold. pub reportable: bool, + /// The vendor's party. + pub party_id: Option, + /// The TIN's last four digits, from the party's W-9 facts. + pub tin_last4: Option, + /// The full TIN, formatted (123-45-6789, 12-3456789): only with + /// include_tins, and only when the vault holds it. + pub tin: Option, + /// The TIN's type (ssn, ein, itin): only with include_tins. + pub tin_type: Option, + /// With include_tins, why `tin` is missing (not in the vault, the vault + /// locked, a record that doesn't match the books). + pub tin_note: Option, } /// Result of `books.vendor_1099`. @@ -388,6 +410,11 @@ pub struct Vendor1099Out { pub missing_w9: Vec, /// Caveats for the human and CPA. pub notes: Vec, + /// With include_tins: this output holds full TINs. For the CPA only; + /// never paste it anywhere else. + pub warning: Option, + /// The CSV written with save_csv_to, if any. + pub saved_csv: Option, } /// Parameters for `books.verify`. @@ -436,6 +463,9 @@ pub struct ShowParams { pub asset: Option, /// A party, by id or name (as `books.party`). pub party: Option, + /// A reconciliation (id from `books.reconciliations`): its account's + /// page, with the reconciliation and its statement. + pub reconciliation: Option, /// Search the transactions list (description, account, vendor, amount). pub search: Option, /// A page: overview, cash, profit, transactions, reports, accounts, @@ -548,6 +578,8 @@ pub struct SettingsOut { pub basis: String, /// The receipt rule in force. pub receipt_rule: String, + /// `[books] parties`: "off", "suggest", or "require". + pub parties: String, /// `[[company.members]]`. pub members: Vec, /// `[books] cash_accounts`. @@ -823,6 +855,11 @@ pub struct ImportBankCsvParams { /// "month_first" (mm/dd/yyyy, US banks) or "day_first" (dd/mm/yyyy). #[serde(default)] pub date_order: Option, + /// The CSV as filed (`books.file_document` category import_source, or + /// any stored attachment): linked to the bank account imported into, + /// so the import traces to its source. Never linked to the drafts: a + /// bank export is not a receipt. + pub source_attachment_id: Option, } /// One imported bank transaction (as a draft entry). @@ -847,6 +884,11 @@ pub struct ImportBankCsvOut { pub imported: Vec, /// Number of transactions imported. pub count: usize, + /// The source attachment, if one was given. + pub source_attachment_id: Option, + /// True when this call linked it to the account (false when it already + /// was, or none was given). + pub source_linked: bool, } /// Parameters for `books.import_chart_csv`. @@ -946,6 +988,10 @@ pub struct DraftEntryParams { /// party with a vendor record also tags the entry's vendor; given with /// `vendor`, both must name the same vendor. pub party: Option, + /// Why the entry has no counterparty (a transfer between the company's + /// own accounts, an accrual), when `[books] parties = "require"` and + /// none applies. Not with `party` or `vendor`. + pub no_party_reason: Option, } /// Result of `books.draft_entry`. @@ -1061,6 +1107,34 @@ pub struct PostEntryOut { /// receipt threshold (same date, description, and debit accounts). /// Empty when no split is detected. pub split_detected: Vec, + /// `[books] parties`: "off", "suggest", or "require". + pub party_policy: String, + /// The entry's counterparty, when tagged. + pub party_id: Option, + /// Its name. + pub party: Option, + /// Why it has none, when marked so. + pub no_party_reason: Option, + /// True when the entry names no party and isn't marked as having none + /// (always false when the policy is "off"). Under "require", posting + /// with confirmed=true fails in this state. + pub party_missing: bool, + /// Likely counterparties, best first, when `party_missing`: tag one + /// with `books.set_entry_party`, or add the party with + /// `books.add_party` first. + pub party_suggestions: Vec, +} + +/// A likely counterparty for an entry. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PartySuggestionOut { + /// The party's id. + pub party_id: u64, + /// Its name. + pub name: String, + /// Why: "named in the description", "named in a receipt", or "tagged + /// on entry N with the same description". + pub why: String, } /// Parameters for `books.reverse_entry`. @@ -1107,6 +1181,11 @@ pub struct AttachReceiptParams { pub content_base64: String, /// Optional description. pub description: Option, + /// More subjects to link it to in the same call (each journaled as its + /// own `attachment.linked`). Every subject is checked before anything + /// is filed: one bad subject files nothing. + #[serde(default)] + pub link_to: Vec, } /// Result of `books.attach_receipt`. @@ -1118,12 +1197,15 @@ pub struct AttachReceiptOut { pub sha256: String, /// True when identical bytes were already stored (deduplicated). pub deduplicated: bool, + /// Everything it is linked to now (the entry it is filed on first). + pub links: Vec, } /// Parameters for `books.file_document`. #[derive(Debug, Deserialize, JsonSchema)] pub struct FileDocumentParams { - /// One of: formation, legal, tax, accounting, other. + /// One of: formation, legal, tax, accounting, statement, contract, + /// photo, warranty, import_source, other. pub category: String, /// Original file name. pub file_name: String, @@ -1133,6 +1215,11 @@ pub struct FileDocumentParams { pub content_base64: String, /// Optional description. pub description: Option, + /// Subjects to link it to in the same call (each journaled as its own + /// `attachment.linked`). Every subject is checked before anything is + /// filed: one bad subject files nothing. + #[serde(default)] + pub link_to: Vec, } /// Parameters for `books.link_receipt`. @@ -1221,6 +1308,8 @@ pub struct ReceiptOut { pub withdrawn: bool, /// Why it was withdrawn, if it was. pub withdrawn_reason: Option, + /// Everything it is linked to (the entry it is filed on first). + pub links: Vec, } /// Result of `books.list_receipts`. @@ -1255,6 +1344,10 @@ pub struct GetReceiptOut { pub mime_type: String, /// Size in bytes. pub size_bytes: u64, + /// Description, if any. + pub description: Option, + /// Everything it is linked to (the entry it is filed on first). + pub links: Vec, /// File bytes, base64-encoded. pub content_base64: String, } @@ -1421,6 +1514,25 @@ pub struct DigestOut { /// didn't load, or a setting that disagrees with the chart. Fix each /// with the human, then check books.settings. pub settings_problems: Vec, + /// Cash and card accounts (`[books] cash_accounts`, and liabilities + /// with a recorded statement) with activity not reconciled through the + /// end of last month: record each statement and reconcile it. + pub unreconciled_accounts: Vec, +} + +/// An account due for reconciliation (`books.digest`). +#[derive(Debug, Serialize, JsonSchema)] +pub struct UnreconciledAccountOut { + /// Account code. + pub code: String, + /// Account name. + pub name: String, + /// The last statement date it is reconciled through, if any. + pub reconciled_through: Option, + /// The date it should be reconciled through: the end of last month. + pub due_through: String, + /// The calls that complete it. + pub next_step: String, } /// A torn journal tail set aside on open. @@ -1976,20 +2088,32 @@ pub struct MarkClearedOut { pub ok: bool, } -/// Parameters for `books.reconcile`. +/// Parameters for `books.reconcile`. Either a recorded statement +/// (`statement_attachment`), or `account`, `statement_date`, and +/// `statement_balance` for a check against any balance. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ReconcileParams { - /// Account id or code (e.g. "1000") to reconcile. - pub account: String, - /// Statement date, YYYY-MM-DD. - pub statement_date: String, - /// Statement balance, decimal string. - pub statement_balance: String, + /// Account id or code (e.g. "1000") to reconcile. With + /// statement_attachment, optional (it must be the statement's). + pub account: Option, + /// Statement date, YYYY-MM-DD. With statement_attachment, optional + /// (it must be the statement's period end). + pub statement_date: Option, + /// Statement balance, decimal string. With statement_attachment, + /// optional (it must be the statement's closing balance). + pub statement_balance: Option, + /// The attachment holding the statement, recorded with + /// `books.record_statement`: reconcile to what it says. + pub statement_attachment: Option, + /// True: record the reconciliation (needs statement_attachment and a + /// difference of exactly zero). Without it, a preview that records + /// nothing. + pub confirmed: Option, } -/// One uncleared line in the reconciliation report. +/// One line in the reconciliation report. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] -pub struct UnclearedLineOut { +pub struct ReconcileLineOut { /// Entry id. pub entry_id: u64, /// Line index within the entry. @@ -2007,8 +2131,14 @@ pub struct UnclearedLineOut { /// Output of `books.reconcile`. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] pub struct ReconcileOut { + /// "preview" (nothing recorded) or "reconciled" (recorded). + pub status: String, /// Account code. pub account_code: String, + /// The statement date reconciled through, YYYY-MM-DD. + pub statement_date: String, + /// The statement's attachment, when reconciling to a recorded one. + pub statement_attachment: Option, /// Book balance (all posted lines), decimal string. pub book_balance: String, /// Cleared balance (cleared lines on/before statement date), decimal string. @@ -2018,7 +2148,14 @@ pub struct ReconcileOut { /// cleared_balance - statement_balance. Zero means reconciled. pub difference: String, /// Uncleared lines on/before the statement date. - pub uncleared_lines: Vec, + pub uncleared_lines: Vec, + /// Cleared lines on/before the statement date: what a confirmed + /// reconciliation records. + pub cleared_lines: Vec, + /// Why confirmed=true would be refused now (empty: it would record). + pub problems: Vec, + /// The recorded reconciliation (status "reconciled" only). + pub reconciliation: Option, } // --------------------------------------------------------------------------- @@ -2437,6 +2574,116 @@ pub struct SetW9Params { /// True: clear the W-9 facts (give no other fact). #[serde(default)] pub clear: bool, + /// The full TIN (nine digits, hyphens allowed), stored only in the + /// encrypted TIN vault, never in the books; the books keep its last + /// four. Needs tin_type. Never echoed back. + pub tin: Option, + /// The TIN's type: ssn, ein, or itin. Required with tin. + pub tin_type: Option, + /// A local PDF, PNG, or JPEG of the signed W-9: stored only in the + /// vault (it shows the full TIN), never with the receipts and + /// documents. Read it back with `books.get_w9`. + pub w9_file_path: Option, +} + +/// A full TIN as a tool takes it: text (or a JSON number) whose value is +/// never printed: its Debug, and any error deserializing it, say nothing +/// of the digits. +#[derive(Clone, JsonSchema)] +#[schemars(transparent)] +pub struct SecretTin(pub String); + +impl std::fmt::Debug for SecretTin { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("SecretTin(..)") + } +} + +impl<'de> Deserialize<'de> for SecretTin { + fn deserialize>(d: D) -> Result { + struct V; + impl serde::de::Visitor<'_> for V { + type Value = SecretTin; + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("a TIN: nine digits as a string") + } + fn visit_str(self, v: &str) -> Result { + Ok(SecretTin(v.to_string())) + } + fn visit_u64(self, v: u64) -> Result { + Ok(SecretTin(v.to_string())) + } + fn visit_i64(self, _: i64) -> Result { + Err(E::custom("tin must be nine digits")) + } + fn visit_f64(self, _: f64) -> Result { + Err(E::custom("tin must be nine digits")) + } + } + d.deserialize_any(V) + } +} + +/// Parameters for `books.get_w9`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct GetW9Params { + /// The party: id or name. + pub party: String, + /// Where to write the W-9 file: a local path outside the books dir + /// (the file shows the full TIN; it must never be committed with the + /// books). An existing file is refused. + pub save_to: String, +} + +/// Result of `books.get_w9`: where the file went, never its contents. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct GetW9Out { + /// The party. + pub party_id: u64, + /// The path written. + pub path: String, + /// The file's original name. + pub file_name: String, + /// Its size in bytes. + pub size_bytes: u64, + /// What to tell the human. + pub note: String, +} + +/// Parameters for `books.purge_tin`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct PurgeTinParams { + /// The party: id or name. + pub party: String, + /// Why (retention period over, the payee asked, a wrong TIN): + /// journaled with the W-9 facts. + pub reason: String, + /// True: delete the vault records and journal it. Without it, a + /// preview that changes nothing. + pub confirmed: Option, +} + +/// Result of `books.purge_tin`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct PurgeTinOut { + /// "preview" or "purged". + pub status: String, + /// The party. + pub party_id: u64, + /// Its name. + pub name: String, + /// Whether the vault holds (preview) or held (purged) its TIN. + pub tin_in_vault: bool, + /// Whether the vault holds (held) its W-9 file. + pub w9_file_in_vault: bool, + /// The TIN's last four digits, which the books keep. + pub tin_last4: Option, + /// True when the purge is journaled (`party.w9_set` with the vault + /// record cleared and kept_at saying why); false when the party has no + /// W-9 facts to update. + pub journaled: bool, + /// What happens (preview) or happened. + pub note: String, } /// Parameters for `books.set_w4`: what a W-4 said, never the SSN. Either @@ -2490,6 +2737,10 @@ pub struct SetEntryPartyParams { /// in `books.vendor_1099`); one without, or clearing, clears the vendor /// tag too. pub party: Option, + /// With `party` null: mark the entry as having no counterparty, with + /// why (journaled as `entry.no_party`). Satisfies `[books] parties = + /// "require"`. + pub no_party_reason: Option, } /// A party's W-9 facts. @@ -2507,6 +2758,9 @@ pub struct W9Out { pub tin_last4: String, /// Where the form is kept. pub kept_at: String, + /// True when the books record a TIN vault record for it (the full TIN + /// is in the vault, never here). + pub in_vault: bool, } /// A party's W-4 facts. @@ -2713,3 +2967,191 @@ pub struct PartyDetailOut { /// Payroll lines naming it, by run. pub payroll: Vec, } + +// --------------------------------------------------------------------------- +// Attachment links, statements, reconciliations +// --------------------------------------------------------------------------- + +/// A reference: a number or text (a code, tag, or name). +#[derive(Debug, Clone, Deserialize, JsonSchema)] +#[serde(untagged)] +pub enum RefId { + /// A number (an id; for an account, its code or id). + Number(u64), + /// Text: an account code, an asset tag, a party name, or an id. + Text(String), +} + +/// What an attachment is linked to. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct SubjectParam { + /// entry, account, asset, party, invoice, bill, period, payroll_run, + /// schedule, reconciliation, or company. + pub kind: String, + /// Which one: an entry, invoice, bill, period, payroll run, schedule, + /// or reconciliation id; an account's code (or id); an asset's tag (or + /// id); a party's id or name. Omitted for the company. + pub id: Option, +} + +/// A subject in a tool's output. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct SubjectOut { + /// Its kind (as in SubjectParam). + pub kind: String, + /// Its id (absent for the company). For an account, the account id; + /// `label` gives the code. + pub id: Option, + /// Human words, e.g. "account 1000 Bank", "entry #5 January rent". + pub label: String, +} + +/// Parameters for `books.link_attachment`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct LinkAttachmentParams { + /// A stored attachment (receipt or document), not withdrawn. + pub attachment_id: u64, + /// What to link it to. + pub subject: SubjectParam, +} + +/// Parameters for `books.unlink_attachment`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct UnlinkAttachmentParams { + /// The attachment. + pub attachment_id: u64, + /// The subject to take it off. + pub subject: SubjectParam, + /// Why, recorded in the journal. + pub reason: String, +} + +/// Result of `books.link_attachment` / `books.unlink_attachment`. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct AttachmentLinksOut { + /// The attachment. + pub attachment_id: u64, + /// Everything it is linked to now (the entry it is filed on first). + pub links: Vec, +} + +/// Parameters for `books.attachments`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct AttachmentsParams { + /// The subject whose files to list. + pub subject: SubjectParam, +} + +/// Result of `books.attachments`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct AttachmentsOut { + /// The subject. + pub subject: SubjectOut, + /// Its files (for an entry: filed on it or linked to it), not + /// withdrawn, by id, each with all its links. + pub attachments: Vec, +} + +/// Parameters for `books.record_statement`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct RecordStatementParams { + /// The filed statement (`books.file_document` category statement). + pub attachment_id: u64, + /// The bank, cash, or card account it is for: code or id. + pub account: String, + /// First day it covers, YYYY-MM-DD. + pub period_start: String, + /// Last day it covers (the statement date), YYYY-MM-DD. + pub period_end: String, + /// Opening balance as printed, in the account's normal direction + /// (what the bank holds for a bank account; what is owed for a card), + /// decimal string, cents at most. + pub opening_balance: String, + /// Closing balance as printed, same direction, decimal string. + pub closing_balance: String, +} + +/// A recorded statement. +#[derive(Debug, Clone, Serialize, JsonSchema)] +pub struct StatementOut { + /// The attachment holding it. + pub attachment_id: u64, + /// Its file name. + pub file_name: String, + /// Account code. + pub account_code: String, + /// Account name. + pub account_name: String, + /// First day, YYYY-MM-DD. + pub period_start: String, + /// Last day (statement date), YYYY-MM-DD. + pub period_end: String, + /// Opening balance, decimal string. + pub opening_balance: String, + /// Closing balance, decimal string. + pub closing_balance: String, + /// The reconciliation (not voided) made against it, if any. + pub reconciliation_id: Option, +} + +/// Parameters for `books.statements` and `books.reconciliations`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct AccountFilterParams { + /// Only this account's (code or id). Omit for every account. + pub account: Option, +} + +/// Result of `books.statements`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct StatementsOut { + /// Recorded statements, by statement date. + pub statements: Vec, +} + +/// A cleared line a reconciliation recorded. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ClearedRefOut { + /// Entry id. + pub entry_id: u64, + /// Line index within the entry. + pub line_index: usize, +} + +/// A recorded reconciliation. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct ReconciliationOut { + /// Its id. + pub reconciliation_id: u64, + /// Account code. + pub account_code: String, + /// Account name. + pub account_name: String, + /// The statement date it reconciles through, YYYY-MM-DD. + pub statement_date: String, + /// The statement's closing balance, decimal string. + pub statement_balance: String, + /// The statement's attachment. + pub statement_attachment: u64, + /// The lines it found cleared. + pub cleared: Vec, + /// True when voided. + pub voided: bool, + /// Why it was voided, if it was. + pub voided_reason: Option, +} + +/// Parameters for `books.void_reconciliation`. +#[derive(Debug, Clone, Deserialize, JsonSchema)] +pub struct VoidReconciliationParams { + /// The reconciliation's id. + pub reconciliation_id: u64, + /// Why, recorded in the journal. + pub reason: String, +} + +/// Result of `books.reconciliations`. +#[derive(Debug, Serialize, JsonSchema)] +pub struct ReconciliationsOut { + /// Reconciliations (voided ones included, marked), by date. + pub reconciliations: Vec, +} diff --git a/crates/ledger-mcp/tests/mcp_stdio.rs b/crates/ledger-mcp/tests/mcp_stdio.rs index b38fedf..73eb35d 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(), 82, "expected 82 tools, got {}", tools.len()); + assert_eq!(tools.len(), 91, "expected 91 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_attachments.rs b/crates/ledger-mcp/tests/spec_attachments.rs new file mode 100644 index 0000000..26de284 --- /dev/null +++ b/crates/ledger-mcp/tests/spec_attachments.rs @@ -0,0 +1,1299 @@ +//! Spec-blind tests for the attachment-link, statement, and reconciliation +//! 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), `docs/mcp-interface.md` ("Tools — +//! attachments", "Tools — bank reconciliation"), and +//! `docs/parties-and-attachments.md` ("Attachments", "Reconciliations"). +//! The harness is `tests/spec_parties.rs`'s: every success validates +//! against the tool's `outputSchema`, and every refusal leaves +//! `journal.jsonl` byte-identical (server invariant 2). + +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, + /// Every response line the server wrote, with the tool it answered. + transcript: Vec<(String, String)>, +} + +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-attachments-{}-{name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A directory beside the books dir `dir`, outside it: the vault key, +/// files the tests hand the server, files it writes, its stderr. +fn outside(dir: &Path) -> PathBuf { + let d = PathBuf::from(format!("{}-outside", dir.display())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// `wanderbooks.toml` with a company name, the receipt threshold, and +/// extra `[books]` TOML; the vault key file is outside the books dir. +fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf { + let path = dir.join("wanderbooks.toml"); + std::fs::write( + &path, + format!( + "[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n\ + [vault]\nkey_file = \"{}\"\n", + outside(dir).join("vault.key").display() + ), + ) + .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) + // Never the user's real vault key (the default key file is + // under XDG_CONFIG_HOME); the tests name theirs anyway. + .env("XDG_CONFIG_HOME", outside(dir).join("config")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::from( + std::fs::File::create(outside(dir).join("stderr.log")).unwrap(), + )) + .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(), + transcript: Vec::new(), + }; + let init = mcp.rpc( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "spec-attachments", "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}"); + let what = params["name"].as_str().unwrap_or(method).to_string(); + self.transcript.push((what, resp.to_string())); + 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() + } + + /// 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() + } +} + +fn b64(bytes: &[u8]) -> String { + use base64::Engine as _; + base64::engine::general_purpose::STANDARD.encode(bytes) +} + +const CHART: &[(&str, &str, &str)] = &[ + ("1000", "Bank", "asset"), + ("1300", "Prepaid Expenses", "asset"), + ("1500", "Equipment", "asset"), + ("1510", "Accumulated Depreciation", "asset"), + ("2050", "Card", "liability"), + ("2100", "Due to Members", "liability"), + ("3000", "Capital", "equity"), + ("4000", "Revenue", "revenue"), + ("6000", "Contract Labor", "expense"), + ("6500", "Depreciation", "expense"), + ("6999", "General Expenses", "expense"), +]; + +/// Books with the chart and a receipt threshold of `threshold`. +fn books_at(name: &str, threshold: &str, extra: &str) -> Mcp { + let dir = test_dir(name); + let toml = books_toml(&dir, threshold, extra); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m +} + +/// Books where ordinary entries need no receipt. +fn books(name: &str) -> Mcp { + books_at(name, "1000000.00", "") +} + +impl Mcp { + /// File a document; its id. + fn doc(&mut self, category: &str, bytes: &[u8], link_to: Value) -> u64 { + self.ok( + "books.file_document", + json!({"category": category, "file_name": format!("{category}.pdf"), + "mime_type": "application/pdf", "content_base64": b64(bytes), + "link_to": link_to}), + )["attachment_id"] + .as_u64() + .unwrap() + } + + /// Attach a receipt (to `entry`, or unlinked); its id. + fn receipt(&mut self, entry: Option, bytes: &[u8]) -> u64 { + let mut args = json!({"file_name": "r.pdf", "mime_type": "application/pdf", + "content_base64": b64(bytes)}); + if let Some(e) = entry { + args["entry_id"] = json!(e); + } + self.ok("books.attach_receipt", args)["attachment_id"] + .as_u64() + .unwrap() + } + + fn link(&mut self, attachment: u64, subject: Value) -> Value { + self.ok( + "books.link_attachment", + json!({"attachment_id": attachment, "subject": subject}), + ) + } + + /// The files on a subject (read-only). + fn files_on(&mut self, subject: Value) -> Vec { + self.read_only("books.attachments", json!({"subject": subject}))["attachments"] + .as_array() + .unwrap() + .iter() + .map(|a| a["attachment_id"].as_u64().unwrap()) + .collect() + } + + /// Record a statement for `account`. + fn statement(&mut self, account: &str, start: &str, end: &str, close: &str) -> u64 { + let id = self.doc( + "statement", + format!("{account} {end}").as_bytes(), + json!([]), + ); + self.ok( + "books.record_statement", + json!({"attachment_id": id, "account": account, "period_start": start, + "period_end": end, "opening_balance": "0.00", "closing_balance": close}), + ); + id + } + + /// Mark every line of `entry` on any account cleared. + fn clear(&mut self, entry: u64, line: usize) { + self.ok( + "books.mark_cleared", + json!({"entry_id": entry, "line_index": line, "cleared": true}), + ); + } +} + +/// The (kind, label) pairs of an output's `links`. +fn links(v: &Value) -> Vec<(String, String)> { + v["links"] + .as_array() + .unwrap() + .iter() + .map(|l| { + ( + l["kind"].as_str().unwrap().to_string(), + l["label"].as_str().unwrap().to_string(), + ) + }) + .collect() +} + +fn kinds(v: &Value) -> Vec { + links(v).into_iter().map(|(k, _)| k).collect() +} + +// ------------------------------------------------- books.link_attachment + +/// Every subject kind links, each named the way the other tools name it +/// (account by code, asset by tag, party by name), each journaled as its +/// own attachment.linked, and every listing shows the links. +#[test] +fn a_file_links_to_every_kind_of_subject() { + let mut m = books("link-kinds"); + let e = m.post( + "2026-01-05", + json!({}), + &[("6999", "debit", "10.00"), ("1000", "credit", "10.00")], + ); + let asset = m.ok( + "books.register_asset", + json!({"description": "Laptop", "purchase_date": "2026-01-10", "cost": "3000.00", + "salvage_value": "0.00", "useful_life_months": 36, "cost_account": "1500", + "accum_depr_account": "1510", "expense_account": "6500", "tag": "LAP-1", + "tax_basis_rule": "cost"}), + ); + assert!(asset.get("asset_id").is_some(), "{asset}"); + m.add_party("Acme Design", &["contractor"]); + let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"].clone(); + let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"].clone(); + let bill = m.ok( + "books.create_bill", + json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28", + "lines": [{"description": "Rent", "amount": "300.00"}]}), + )["id"] + .clone(); + let invoice = m.ok( + "books.create_invoice", + json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01", + "lines": [{"description": "Work", "amount": "500.00"}]}), + )["id"] + .clone(); + let period = m.ok( + "books.create_period", + json!({"name": "2026-01", "start": "2026-01-01", "end": "2026-01-31"}), + )["period_id"] + .clone(); + let schedule = m.ok( + "books.create_schedule", + json!({"kind": "prepaid_expense", "description": "Insurance", "total": "1200.00", + "start": "2026-01-01", "months": 12, "balance_account": "1300", "pl_account": "6999"}), + )["schedule_id"] + .clone(); + m.add_party("Pat Doe", &["employee"]); + let run = m.ok( + "books.run_payroll", + json!({"date": "2026-02-15", "cash_account": "1000", "lines": [{ + "employee": "Pat Doe", "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"}]}), + )["run_id"] + .clone(); + m.clear(e, 1); + let st = m.statement("1000", "2026-01-01", "2026-01-31", "-10.00"); + let rec = m.ok( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + )["reconciliation"]["reconciliation_id"] + .clone(); + + let f = m.doc("other", b"one file, many subjects", json!([])); + let before = m.count("attachment.linked"); + let subjects = [ + json!({"kind": "entry", "id": e}), + json!({"kind": "account", "id": "1000"}), + json!({"kind": "asset", "id": "LAP-1"}), + json!({"kind": "party", "id": " acme DESIGN "}), + json!({"kind": "invoice", "id": invoice}), + json!({"kind": "bill", "id": bill}), + json!({"kind": "period", "id": period}), + json!({"kind": "payroll_run", "id": run}), + json!({"kind": "schedule", "id": schedule}), + json!({"kind": "reconciliation", "id": rec}), + json!({"kind": "company"}), + ]; + for s in &subjects { + m.link(f, s.clone()); + } + assert_eq!(m.count("attachment.linked"), before + subjects.len()); + let got = m.read_only("books.get_receipt", json!({"attachment_id": f})); + let mut ks = kinds(&got); + ks.sort(); + let mut want: Vec = subjects + .iter() + .map(|s| s["kind"].as_str().unwrap().to_string()) + .collect(); + want.sort(); + assert_eq!(ks, want); + let labels = links(&got); + assert!( + labels + .iter() + .any(|(k, l)| k == "account" && l.contains("1000") && l.contains("Bank")), + "{labels:?}" + ); + assert!( + labels + .iter() + .any(|(k, l)| k == "asset" && l.contains("LAP-1")), + "{labels:?}" + ); + assert!( + labels + .iter() + .any(|(k, l)| k == "party" && l.contains("Acme Design")), + "{labels:?}" + ); + let company = got["links"] + .as_array() + .unwrap() + .iter() + .find(|l| l["kind"] == "company") + .unwrap() + .clone(); + assert!(company["id"].is_null(), "the company has no id: {company}"); + // The listing shows the same links. + let listed = m.read_only("books.list_receipts", json!({}))["receipts"] + .as_array() + .unwrap() + .iter() + .find(|r| r["attachment_id"] == json!(f)) + .unwrap() + .clone(); + assert_eq!(links(&listed), links(&got)); + // Each subject lists the file. + for s in subjects { + assert!(m.files_on(s.clone()).contains(&f), "{s} lists it"); + } + // An account named by id works too (its id is not a code here). + let chart = m.read_only("books.chart", json!({})); + let card_id = chart["accounts"] + .as_array() + .unwrap() + .iter() + .find(|a| a["code"] == "2050") + .unwrap()["id"] + .clone(); + m.link(f, json!({"kind": "account", "id": card_id})); + assert!( + m.files_on(json!({"kind": "account", "id": "2050"})) + .contains(&f) + ); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +#[test] +fn link_refusals_change_nothing() { + let mut m = books("link-refusals"); + let e = m.draft("2026-01-05", json!({}), &[]); + let r = m.receipt(Some(e), b"filed on e"); + let f = m.doc("other", b"a document", json!([])); + let gone = m.doc("other", b"withdrawn", json!([])); + m.ok( + "books.withdraw_attachment", + json!({"attachment_id": gone, "reason": "not ours"}), + ); + m.link(f, json!({"kind": "account", "id": "1000"})); + for (att, subject) in [ + (999, json!({"kind": "company"})), // unknown attachment + (gone, json!({"kind": "company"})), // withdrawn + (f, json!({"kind": "planet", "id": 1})), // unknown kind + (f, json!({"kind": "entry"})), // no id + (f, json!({"kind": "company", "id": 1})), // the company takes none + (f, json!({"kind": "entry", "id": 999})), // no such entry + (f, json!({"kind": "entry", "id": "five"})), // not a number + (f, json!({"kind": "account", "id": "9999"})), // no such account + (f, json!({"kind": "asset", "id": "NOPE"})), // no such asset + (f, json!({"kind": "party", "id": "Nobody"})), // no such party + (f, json!({"kind": "reconciliation", "id": 1})), // none recorded + (f, json!({"kind": "account", "id": "1000"})), // linked already + (r, json!({"kind": "entry", "id": e})), // filed on it already + ] { + m.refuse( + "books.link_attachment", + json!({"attachment_id": att, "subject": subject}), + ); + } + assert_eq!(m.count("attachment.linked"), 1); +} + +// ------------------------------------------------- receipts by link + +/// A file linked to an entry is one of its receipts: the policy counts it +/// when posting, and won't let the last one come off a posted entry. +#[test] +fn a_linked_file_is_a_receipt_the_policy_counts() { + let mut m = books_at("link-receipt", "0.00", ""); + let a = m.draft( + "2026-01-05", + json!({}), + &[("6999", "debit", "10.00"), ("1000", "credit", "10.00")], + ); + let b = m.draft( + "2026-01-05", + json!({}), + &[("6000", "debit", "20.00"), ("1000", "credit", "20.00")], + ); + // One invoice covering both entries: filed on a, linked to b. + let inv = m.receipt(Some(a), b"one invoice, two entries"); + m.refuse( + "books.post_entry", + json!({"entry_id": b, "confirmed": true}), + ); + m.link(inv, json!({"kind": "entry", "id": b})); + m.ok( + "books.post_entry", + json!({"entry_id": a, "confirmed": true}), + ); + let preview = m.ok("books.post_entry", json!({"entry_id": b})); + assert_eq!(preview["receipt_missing"], false, "{preview}"); + m.ok( + "books.post_entry", + json!({"entry_id": b, "confirmed": true}), + ); + assert_eq!(m.files_on(json!({"kind": "entry", "id": b})), [inv]); + let listed = m.read_only("books.list_receipts", json!({"entry_id": b}))["receipts"] + .as_array() + .unwrap() + .len(); + assert_eq!(listed, 1, "list_receipts by entry counts links"); + // Its last receipt can't come off. + let why = m.refuse( + "books.unlink_attachment", + json!({"attachment_id": inv, "subject": {"kind": "entry", "id": b}, "reason": "wrong entry"}), + ); + assert!(why.contains("receipt_required"), "{why}"); + // A filed receipt comes off with unlink_receipt, not here. + let why = m.refuse( + "books.unlink_attachment", + json!({"attachment_id": inv, "subject": {"kind": "entry", "id": a}, "reason": "x"}), + ); + assert!(why.contains("books.unlink_receipt"), "{why}"); + // With a replacement on b, the link comes off. + m.receipt(Some(b), b"b's own receipt"); + let out = m.ok( + "books.unlink_attachment", + json!({"attachment_id": inv, "subject": {"kind": "entry", "id": b}, "reason": "b has its own"}), + ); + assert_eq!(kinds(&out), ["entry"], "still filed on a: {out}"); + assert_eq!(m.count("attachment.unlinked"), 1); +} + +// ------------------------------------------------- books.unlink_attachment + +#[test] +fn unlink_refusals_change_nothing() { + let mut m = books("unlink-refusals"); + let f = m.doc("contract", b"lease", json!([{"kind": "company"}])); + for args in [ + json!({"attachment_id": f, "subject": {"kind": "company"}, "reason": " "}), + json!({"attachment_id": f, "subject": {"kind": "account", "id": "1000"}, "reason": "not there"}), + json!({"attachment_id": 999, "subject": {"kind": "company"}, "reason": "x"}), + json!({"attachment_id": f, "subject": {"kind": "planet"}, "reason": "x"}), + ] { + m.refuse("books.unlink_attachment", args); + } + let out = m.ok( + "books.unlink_attachment", + json!({"attachment_id": f, "subject": {"kind": "company"}, "reason": "a lease, not formation"}), + ); + assert!(kinds(&out).is_empty(), "{out}"); + assert!(m.files_on(json!({"kind": "company"})).is_empty()); + // The file stays filed and listed. + let got = m.read_only("books.get_receipt", json!({"attachment_id": f})); + assert_eq!(got["category"], "contract"); + // The journal keeps both the link and the unlink, with the reason. + let journal = String::from_utf8(m.journal()).unwrap(); + assert!(journal.contains("a lease, not formation")); + assert_eq!(m.count("attachment.linked"), 1); + assert_eq!(m.count("attachment.unlinked"), 1); +} + +// ------------------------------------------------- link_to on filing + +/// `link_to` links in the same call, each link its own event; one bad +/// subject files nothing. +#[test] +fn filing_links_in_the_same_call_all_or_nothing() { + let mut m = books("link-to"); + let e = m.draft("2026-01-05", json!({}), &[]); + let other = m.draft("2026-01-06", json!({}), &[]); + m.add_party("Acme Design", &["contractor"]); + let count = |m: &mut Mcp| { + m.read_only("books.list_receipts", json!({"include_withdrawn": true}))["receipts"] + .as_array() + .unwrap() + .len() + }; + let n = count(&mut m); + for link_to in [ + json!([{"kind": "party", "id": "Acme Design"}, {"kind": "entry", "id": 999}]), + json!([{"kind": "company"}, {"kind": "company"}]), + json!([{"kind": "entry", "id": e}]), + json!([{"kind": "nope"}]), + ] { + m.refuse( + "books.attach_receipt", + json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf", + "content_base64": b64(b"r"), "link_to": link_to}), + ); + } + m.refuse( + "books.file_document", + json!({"category": "contract", "file_name": "c.pdf", "mime_type": "application/pdf", + "content_base64": b64(b"c"), "link_to": [{"kind": "asset", "id": "NOPE"}]}), + ); + // An unknown entry_id is refused before anything is filed. + m.refuse( + "books.attach_receipt", + json!({"entry_id": 999, "file_name": "r.pdf", "mime_type": "application/pdf", + "content_base64": b64(b"r"), "link_to": [{"kind": "company"}]}), + ); + assert_eq!(count(&mut m), n, "nothing filed"); + let out = m.ok( + "books.attach_receipt", + json!({"entry_id": e, "file_name": "r.pdf", "mime_type": "application/pdf", + "content_base64": b64(b"r"), + "link_to": [{"kind": "entry", "id": other}, {"kind": "party", "id": "acme design"}]}), + ); + assert_eq!( + kinds(&out), + ["entry", "entry", "party"], + "filed entry first: {out}" + ); + assert_eq!(out["links"][0]["id"], json!(e)); + assert_eq!(m.count("receipt.attached"), 1); + assert_eq!(m.count("attachment.linked"), 2); + let doc = m.ok( + "books.file_document", + json!({"category": "formation", "file_name": "cert.pdf", "mime_type": "application/pdf", + "content_base64": b64(b"certificate"), "link_to": [{"kind": "company"}]}), + ); + assert_eq!(kinds(&doc), ["company"]); + assert_eq!(m.count("document.filed"), 1); +} + +#[test] +fn documents_file_under_the_attachment_kinds() { + let mut m = books("categories"); + for c in [ + "formation", + "legal", + "tax", + "accounting", + "statement", + "contract", + "photo", + "warranty", + "import_source", + "other", + ] { + let id = m.doc(c, c.as_bytes(), json!([])); + assert_eq!( + m.read_only("books.get_receipt", json!({"attachment_id": id}))["category"], + c + ); + } + m.refuse( + "books.file_document", + json!({"category": "receipt-ish", "file_name": "x", "mime_type": "text/plain", + "content_base64": b64(b"x")}), + ); +} + +// ------------------------------------------------- books.attachments + +/// An entry's files are those filed on it and those linked to it, without +/// the withdrawn; each comes with all its links. +#[test] +fn a_subjects_files_are_filed_and_linked_not_withdrawn() { + let mut m = books("attachments"); + let e = m.draft("2026-01-05", json!({}), &[]); + let filed = m.receipt(Some(e), b"filed"); + let linked = m.doc( + "other", + b"linked", + json!([{"kind": "entry", "id": e}, {"kind": "company"}]), + ); + let gone = m.receipt(None, b"to withdraw"); + m.link(gone, json!({"kind": "company"})); + m.ok( + "books.unlink_attachment", + json!({"attachment_id": gone, "subject": {"kind": "company"}, "reason": "wrong"}), + ); + m.ok( + "books.withdraw_attachment", + json!({"attachment_id": gone, "reason": "a duplicate"}), + ); + assert_eq!( + m.files_on(json!({"kind": "entry", "id": e})), + [filed, linked] + ); + assert_eq!(m.files_on(json!({"kind": "company"})), [linked]); + let out = m.read_only( + "books.attachments", + json!({"subject": {"kind": "entry", "id": e}}), + ); + assert_eq!(out["subject"]["kind"], "entry"); + assert_eq!(out["subject"]["id"], json!(e)); + // Its links: the filing entry first (none here), then by kind. + assert_eq!(kinds(&out["attachments"][1]), ["company", "entry"]); + m.refuse( + "books.attachments", + json!({"subject": {"kind": "entry", "id": 999}}), + ); +} + +// ------------------------------------------------- statements + +#[test] +fn record_statement_links_it_to_its_account() { + let mut m = books("statement"); + let f = m.doc("statement", b"jan", json!([])); + let st = m.ok( + "books.record_statement", + json!({"attachment_id": f, "account": "1000", "period_start": "2026-01-01", + "period_end": "2026-01-31", "opening_balance": "0", "closing_balance": "1250.5"}), + ); + assert_eq!(st["account_code"], "1000"); + assert_eq!(st["account_name"], "Bank"); + assert_eq!(st["period_end"], "2026-01-31"); + assert_eq!(st["opening_balance"], "0.00"); + assert_eq!(st["closing_balance"], "1250.50"); + assert_eq!(st["file_name"], "statement.pdf"); + assert!(st["reconciliation_id"].is_null()); + assert_eq!(m.count("statement.recorded"), 1); + assert!( + m.files_on(json!({"kind": "account", "id": "1000"})) + .contains(&f) + ); + // A card statement too (a liability). + let card = m.statement("2050", "2026-01-01", "2026-01-31", "80.00"); + let all = m.read_only("books.statements", json!({}))["statements"] + .as_array() + .unwrap() + .len(); + assert_eq!(all, 2); + let one = m.read_only("books.statements", json!({"account": "2050"})); + assert_eq!(one["statements"].as_array().unwrap().len(), 1); + assert_eq!(one["statements"][0]["attachment_id"], json!(card)); +} + +#[test] +fn record_statement_refusals_change_nothing() { + let mut m = books("statement-refusals"); + let f = m.doc("statement", b"jan", json!([])); + let gone = m.doc("statement", b"gone", json!([])); + m.ok( + "books.withdraw_attachment", + json!({"attachment_id": gone, "reason": "duplicate"}), + ); + let base = json!({"attachment_id": f, "account": "1000", "period_start": "2026-01-01", + "period_end": "2026-01-31", "opening_balance": "0.00", + "closing_balance": "10.00"}); + let with = |k: &str, v: Value| { + let mut a = base.clone(); + a[k] = v; + a + }; + for args in [ + with("attachment_id", json!(999)), + with("attachment_id", json!(gone)), + with("account", json!("6999")), // an expense account + with("account", json!("4000")), // revenue + with("account", json!("9999")), // unknown + with("period_end", json!("2025-12-31")), // backwards + with("period_end", json!("2026-02-30")), // not a date + with("closing_balance", json!("10.001")), // past cents + with("closing_balance", json!("1e1")), // not a decimal string + with("opening_balance", json!(10.0)), // a JSON number + ] { + m.refuse("books.record_statement", args); + } + m.ok("books.record_statement", base.clone()); + m.refuse("books.record_statement", base); + assert_eq!(m.count("statement.recorded"), 1); +} + +// ------------------------------------------------- books.reconcile + +/// Books with a January: a deposit, a payment, and one more payment after +/// the statement date. Returns (deposit, payment) entry ids. +fn january(m: &mut Mcp) -> (u64, u64) { + let dep = m.post( + "2026-01-02", + json!({}), + &[("1000", "debit", "1000.00"), ("3000", "credit", "1000.00")], + ); + let pay = m.post( + "2026-01-20", + json!({}), + &[("6000", "debit", "200.00"), ("1000", "credit", "200.00")], + ); + m.post( + "2026-02-03", + json!({}), + &[("6999", "debit", "5.00"), ("1000", "credit", "5.00")], + ); + (dep, pay) +} + +#[test] +fn reconcile_previews_without_recording() { + let mut m = books("reconcile-preview"); + let (dep, pay) = january(&mut m); + // A quick check against any balance: a preview, never recorded. + let p = m.read_only( + "books.reconcile", + json!({"account": "1000", "statement_date": "2026-01-31", "statement_balance": "800.00"}), + ); + assert_eq!(p["status"], "preview"); + assert_eq!(p["book_balance"], "795.00"); + assert_eq!(p["cleared_balance"], "0.00"); + assert_eq!(p["difference"], "-800.00"); + assert_eq!(p["uncleared_lines"].as_array().unwrap().len(), 2); + assert!(p["cleared_lines"].as_array().unwrap().is_empty()); + assert!(p["reconciliation"].is_null()); + assert!(!p["problems"].as_array().unwrap().is_empty()); + // Confirming without a recorded statement is refused. + m.refuse( + "books.reconcile", + json!({"account": "1000", "statement_date": "2026-01-31", "statement_balance": "800.00", + "confirmed": true}), + ); + // Neither a statement nor all three facts. + m.refuse("books.reconcile", json!({"account": "1000"})); + // Against a recorded statement, with the lines cleared: no problems. + let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00"); + m.clear(dep, 0); + m.clear(pay, 1); + let p = m.read_only("books.reconcile", json!({"statement_attachment": st})); + assert_eq!(p["status"], "preview"); + assert_eq!(p["statement_attachment"], json!(st)); + assert_eq!(p["statement_date"], "2026-01-31"); + assert_eq!(p["cleared_balance"], "800.00"); + assert_eq!(p["difference"], "0.00"); + assert!(p["problems"].as_array().unwrap().is_empty(), "{p}"); + let cleared: Vec<(u64, u64)> = p["cleared_lines"] + .as_array() + .unwrap() + .iter() + .map(|l| { + ( + l["entry_id"].as_u64().unwrap(), + l["line_index"].as_u64().unwrap(), + ) + }) + .collect(); + assert_eq!(cleared, [(dep, 0), (pay, 1)]); + assert_eq!(m.count("reconciliation.completed"), 0); +} + +#[test] +fn reconcile_confirmed_records_against_the_statement() { + let mut m = books("reconcile-confirm"); + let (dep, pay) = january(&mut m); + let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00"); + m.clear(dep, 0); + // A difference refuses, naming it. + let why = m.refuse( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + ); + assert!(why.contains("200.00"), "{why}"); + m.clear(pay, 1); + // Facts given beside the statement must be the statement's. + for (k, v) in [ + ("account", json!("2050")), + ("statement_date", json!("2026-01-30")), + ("statement_balance", json!("800.01")), + ] { + let mut args = json!({"statement_attachment": st, "confirmed": true}); + args[k] = v; + m.refuse("books.reconcile", args); + } + // A file that records no statement. + let not = m.doc("other", b"not a statement", json!([])); + m.refuse( + "books.reconcile", + json!({"statement_attachment": not, "confirmed": true}), + ); + let out = m.ok( + "books.reconcile", + json!({"statement_attachment": st, "account": "1000", "confirmed": true}), + ); + assert_eq!(out["status"], "reconciled"); + let r = &out["reconciliation"]; + assert_eq!(r["account_code"], "1000"); + assert_eq!(r["statement_date"], "2026-01-31"); + assert_eq!(r["statement_balance"], "800.00"); + assert_eq!(r["statement_attachment"], json!(st)); + assert_eq!(r["voided"], false); + assert_eq!( + r["cleared"], + json!([{"entry_id": dep, "line_index": 0}, {"entry_id": pay, "line_index": 1}]) + ); + assert_eq!(m.count("reconciliation.completed"), 1); + let rid = r["reconciliation_id"].as_u64().unwrap(); + // The statement is linked to the reconciliation and lists it. + assert!( + m.files_on(json!({"kind": "reconciliation", "id": rid})) + .contains(&st) + ); + let sts = m.read_only("books.statements", json!({"account": "1000"})); + assert_eq!(sts["statements"][0]["reconciliation_id"], json!(rid)); + // The same date again is refused; the preview says why. + let p = m.read_only("books.reconcile", json!({"statement_attachment": st})); + assert!( + p["problems"].as_array().unwrap().iter().any(|x| x + .as_str() + .unwrap() + .contains("already reconciled through 2026-01-31")), + "{p}" + ); + m.refuse( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + ); + // The statement can't come off a reconciliation that stands. + m.refuse( + "books.unlink_attachment", + json!({"attachment_id": st, "subject": {"kind": "reconciliation", "id": rid}, "reason": "x"}), + ); + // February, forward: fine. + let feb_pay = m.read_only("books.search_entries", json!({"text": "2026-02-03"})); + let feb = feb_pay["entries"][0]["entry_id"].as_u64().unwrap(); + m.clear(feb, 1); + let st2 = m.statement("1000", "2026-02-01", "2026-02-28", "795.00"); + m.ok( + "books.reconcile", + json!({"statement_attachment": st2, "confirmed": true}), + ); + let all = m.read_only("books.reconciliations", json!({"account": "1000"})); + assert_eq!(all["reconciliations"].as_array().unwrap().len(), 2); + assert!( + m.read_only("books.reconciliations", json!({"account": "2050"}))["reconciliations"] + .as_array() + .unwrap() + .is_empty() + ); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +#[test] +fn void_reconciliation_lets_it_be_redone() { + let mut m = books("void"); + let (dep, pay) = january(&mut m); + m.clear(dep, 0); + m.clear(pay, 1); + let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00"); + let rid = m.ok( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + )["reconciliation"]["reconciliation_id"] + .as_u64() + .unwrap(); + for args in [ + json!({"reconciliation_id": rid, "reason": " "}), + json!({"reconciliation_id": 999, "reason": "no such"}), + ] { + m.refuse("books.void_reconciliation", args); + } + let v = m.ok( + "books.void_reconciliation", + json!({"reconciliation_id": rid, "reason": "cleared the wrong deposit"}), + ); + assert_eq!(v["voided"], true); + assert_eq!(v["voided_reason"], "cleared the wrong deposit"); + m.refuse( + "books.void_reconciliation", + json!({"reconciliation_id": rid, "reason": "again"}), + ); + assert_eq!(m.count("reconciliation.voided"), 1); + // Voided: the statement can come off it, and the month can be redone. + let listed = m.read_only("books.reconciliations", json!({})); + assert_eq!(listed["reconciliations"][0]["voided"], true); + m.ok( + "books.unlink_attachment", + json!({"attachment_id": st, "subject": {"kind": "reconciliation", "id": rid}, + "reason": "voided"}), + ); + let redo = m.ok( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + ); + assert_ne!(redo["reconciliation"]["reconciliation_id"], json!(rid)); +} + +// ------------------------------------------------- import sources + +#[test] +fn a_bank_import_links_its_source_to_the_account_not_the_drafts() { + let mut m = books("import-source"); + let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n2026-03-06,Refund,10.00\n"; + let src = m.doc("import_source", csv.as_bytes(), json!([])); + let gone = m.doc("import_source", b"old", json!([])); + m.ok( + "books.withdraw_attachment", + json!({"attachment_id": gone, "reason": "superseded"}), + ); + let args = |source: u64| { + json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999", + "source_attachment_id": source}) + }; + m.refuse("books.import_bank_csv", args(999)); + m.refuse("books.import_bank_csv", args(gone)); + let out = m.ok("books.import_bank_csv", args(src)); + assert_eq!(out["count"], 2); + assert_eq!(out["source_attachment_id"], json!(src)); + assert_eq!(out["source_linked"], true); + assert!( + m.files_on(json!({"kind": "account", "id": "1000"})) + .contains(&src) + ); + for t in out["imported"].as_array().unwrap() { + let e = t["entry_id"].clone(); + assert!( + m.files_on(json!({"kind": "entry", "id": e})).is_empty(), + "a bank export is not a receipt" + ); + } + // Linked already: imported again, not linked twice. + let again = m.ok("books.import_bank_csv", args(src)); + assert_eq!(again["source_linked"], false); + assert_eq!(m.count("attachment.linked"), 1); + // Without a source, as before. + let plain = m.ok( + "books.import_bank_csv", + json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999"}), + ); + assert!(plain["source_attachment_id"].is_null()); + assert_eq!(plain["source_linked"], false); +} + +// ------------------------------------------------- the viewer + +#[test] +fn show_opens_a_reconciliation() { + let mut m = books("show"); + let (dep, pay) = january(&mut m); + m.clear(dep, 0); + m.clear(pay, 1); + let st = m.statement("1000", "2026-01-01", "2026-01-31", "800.00"); + let rid = m.ok( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + )["reconciliation"]["reconciliation_id"] + .as_u64() + .unwrap(); + let out = m.read_only("books.show", json!({"reconciliation": rid})); + assert!(out["shown"].as_str().unwrap().contains("1000"), "{out}"); + let run = m.data.parent().unwrap().join("run/wanderbooks"); + let cmd: Value = std::fs::read_dir(&run) + .unwrap() + .filter_map(|e| std::fs::read(e.unwrap().path()).ok()) + .find_map(|b| serde_json::from_slice(&b).ok()) + .expect("a viewer request"); + assert_eq!(cmd["target"], json!({"show": "reconciliation", "id": rid})); + m.refuse("books.show", json!({"reconciliation": 999})); + m.refuse( + "books.show", + json!({"reconciliation": rid, "entry_id": dep}), + ); +} + +// ------------------------------------------------- books.digest + +/// The end of last month, from today. +fn last_month_end() -> chrono::NaiveDate { + use chrono::Datelike; + let today = chrono::Local::now().date_naive(); + today.with_day(1).unwrap().pred_opt().unwrap() +} + +fn unreconciled(m: &mut Mcp) -> Vec { + m.read_only("books.digest", json!({"since": "2020-01-01"}))["unreconciled_accounts"] + .as_array() + .unwrap() + .iter() + .map(|a| a["code"].as_str().unwrap().to_string()) + .collect() +} + +/// Cash accounts (`[books] cash_accounts`) and card accounts with +/// statements are listed until reconciled through the end of last month; +/// accounts without activity by then aren't. +#[test] +fn the_digest_lists_accounts_not_reconciled_through_last_month() { + let mut m = books("digest"); + assert!(unreconciled(&mut m).is_empty(), "no activity, nothing due"); + let due = last_month_end(); + let day = due.to_string(); + let start = due.with_day0(0).unwrap().to_string(); + let dep = m.post( + &day, + json!({}), + &[("1000", "debit", "100.00"), ("3000", "credit", "100.00")], + ); + let card = m.post( + &day, + json!({}), + &[("6999", "debit", "40.00"), ("2050", "credit", "40.00")], + ); + assert_eq!( + unreconciled(&mut m), + ["1000"], + "the card has no statement yet" + ); + let d = m.read_only("books.digest", json!({"since": "2020-01-01"})); + let row = &d["unreconciled_accounts"][0]; + assert_eq!(row["due_through"], json!(day)); + assert!(row["reconciled_through"].is_null()); + assert!( + row["next_step"] + .as_str() + .unwrap() + .contains("books.record_statement") + ); + // The card's statement makes it a card account to reconcile. + let card_st = m.statement("2050", &start, &day, "40.00"); + assert_eq!(unreconciled(&mut m), ["1000", "2050"]); + m.clear(card, 1); + m.ok( + "books.reconcile", + json!({"statement_attachment": card_st, "confirmed": true}), + ); + assert_eq!(unreconciled(&mut m), ["1000"]); + m.clear(dep, 0); + let st = m.statement("1000", &start, &day, "100.00"); + m.ok( + "books.reconcile", + json!({"statement_attachment": st, "confirmed": true}), + ); + assert!(unreconciled(&mut m).is_empty()); +} + +use chrono::Datelike as _; diff --git a/crates/ledger-mcp/tests/spec_mcp.rs b/crates/ledger-mcp/tests/spec_mcp.rs index 4dfa327..28cf1ea 100644 --- a/crates/ledger-mcp/tests/spec_mcp.rs +++ b/crates/ledger-mcp/tests/spec_mcp.rs @@ -175,6 +175,14 @@ fn test_dir(name: &str) -> PathBuf { dir } +/// A directory beside the books dir `dir`, outside it (vault keys, files +/// with TINs). +fn outside(dir: &Path) -> PathBuf { + let d = PathBuf::from(format!("{}-outside", dir.display())); + std::fs::create_dir_all(&d).unwrap(); + d +} + /// Write a `wanderbooks.toml` with the given receipt threshold and extra /// TOML (appended under `[books]` unless it opens its own table). fn books_toml(dir: &Path, threshold: &str, extra: &str) -> PathBuf { @@ -201,6 +209,9 @@ fn spawn_child(dir: &Path, toml: &Path) -> Child { // books.show writes a viewer request here; keep it out of the // user's real runtime dir. .env("XDG_RUNTIME_DIR", &run) + // The TIN vault's default key file is under XDG_CONFIG_HOME: never + // the user's real one (the happy path names its own anyway). + .env("XDG_CONFIG_HOME", outside(dir).join("config")) .stdin(Stdio::piped()) .stdout(Stdio::piped()) .stderr(Stdio::null()) @@ -572,9 +583,13 @@ fn happy_path(name: &str) -> Mcp { let toml = books_toml( &dir, "1000000.00", - "financing_accounts = [\"2100\"]\n\ + &format!( + "financing_accounts = [\"2100\"]\n\ [[company.members]]\nname = \"A\"\nshare = \"50%\"\ncapital_account = \"3000\"\n\ - [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n", + [[company.members]]\nname = \"B\"\nshare = \"50%\"\ncapital_account = \"3001\"\n\ + [vault]\nkey_file = \"{}\"\n", + outside(&dir).join("vault.key").display() + ), ); let mut m = Mcp::start(&dir, &toml); @@ -675,6 +690,18 @@ fn happy_path(name: &str) -> Mcp { json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)", "tin_last4": "4321", "kept_at": "CPA portal"}), ); + let w9_pdf = outside(&dir).join("landlord-w9.pdf"); + std::fs::write(&w9_pdf, b"%PDF-1.4 W-9 EIN 12-3454321").unwrap(); + m.ok( + "books.set_w9", + json!({"party": "Landlord", "received_on": "2026-01-03", "entity_type": "LLC (partnership)", + "tin": "12-3454321", "tin_type": "ein", "w9_file_path": w9_pdf.to_str().unwrap()}), + ); + m.ok( + "books.get_w9", + json!({"party": "Landlord", + "save_to": outside(&dir).join("landlord-w9-copy.pdf").to_str().unwrap()}), + ); m.ok("books.parties", json!({"role": "landlord", "year": 2026})); // Owner contribution: draft, lines, receipt, preview, post. @@ -748,6 +775,25 @@ fn happy_path(name: &str) -> Mcp { )["attachment_id"] .as_u64() .unwrap(); + let filed = m.ok( + "books.file_document", + json!({"category": "contract", "file_name": "lease.txt", "mime_type": "text/plain", + "content_base64": b64(b"lease"), "link_to": [{"kind": "company"}]}), + )["attachment_id"] + .as_u64() + .unwrap(); + m.ok( + "books.link_attachment", + json!({"attachment_id": filed, "subject": {"kind": "party", "id": "Landlord"}}), + ); + m.ok( + "books.attachments", + json!({"subject": {"kind": "party", "id": "Landlord"}}), + ); + m.ok( + "books.unlink_attachment", + json!({"attachment_id": filed, "subject": {"kind": "company"}, "reason": "a lease, not a formation document"}), + ); let local = dir.join("local-receipt.txt"); std::fs::write(&local, b"a local receipt").unwrap(); m.ok( @@ -899,15 +945,54 @@ fn happy_path(name: &str) -> Mcp { ); // Bank import and reconciliation. + let csv = "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n"; + let source = m.ok( + "books.file_document", + json!({"category": "import_source", "file_name": "bank.csv", "mime_type": "text/csv", + "content_base64": b64(csv.as_bytes())}), + )["attachment_id"] + .as_u64() + .unwrap(); m.ok( "books.import_bank_csv", - json!({"csv_text": "Date,Description,Amount\n2026-03-05,Coffee,-4.50\n", - "bank_account_code": "1000", "default_offset_code": "6999"}), + json!({"csv_text": csv, "bank_account_code": "1000", "default_offset_code": "6999", + "source_attachment_id": source}), ); m.ok( "books.reconcile", json!({"account": "1000", "statement_date": "2026-12-31", "statement_balance": "100.00"}), ); + // January's statement: the contribution and the rent cleared. + m.ok( + "books.mark_cleared", + json!({"entry_id": e1, "line_index": 0, "cleared": true}), + ); + let stmt = m.ok( + "books.file_document", + json!({"category": "statement", "file_name": "2026-01.pdf", "mime_type": "application/pdf", + "content_base64": b64(b"%PDF statement")}), + )["attachment_id"] + .as_u64() + .unwrap(); + m.ok( + "books.record_statement", + json!({"attachment_id": stmt, "account": "1000", "period_start": "2026-01-01", + "period_end": "2026-01-31", "opening_balance": "0.00", "closing_balance": "9900.00"}), + ); + m.ok("books.statements", json!({})); + m.ok("books.reconcile", json!({"statement_attachment": stmt})); + let rec = m.ok( + "books.reconcile", + json!({"statement_attachment": stmt, "confirmed": true}), + )["reconciliation"]["reconciliation_id"] + .as_u64() + .unwrap(); + m.ok("books.reconciliations", json!({"account": "1000"})); + m.ok("books.show", json!({"reconciliation": rec})); + m.ok( + "books.void_reconciliation", + json!({"reconciliation_id": rec, "reason": "redo after the March import"}), + ); // Periods. let p = m.ok( @@ -943,6 +1028,19 @@ fn happy_path(name: &str) -> Mcp { m.ok("books.digest", json!({"since": "2026-01-01"})); m.ok("books.search_entries", json!({"text": "rent", "limit": 10})); m.ok("books.vendor_1099", json!({"year": 2026})); + m.ok( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true, + "save_csv_to": outside(&dir).join("1099.csv").to_str().unwrap()}), + ); + m.ok( + "books.purge_tin", + json!({"party": "Landlord", "reason": "test purge"}), + ); + m.ok( + "books.purge_tin", + json!({"party": "Landlord", "reason": "test purge", "confirmed": true}), + ); 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"})); diff --git a/crates/ledger-mcp/tests/spec_parties.rs b/crates/ledger-mcp/tests/spec_parties.rs index 5a16eb9..6145bad 100644 --- a/crates/ledger-mcp/tests/spec_parties.rs +++ b/crates/ledger-mcp/tests/spec_parties.rs @@ -1147,3 +1147,169 @@ fn no_party_output_holds_a_full_tin() { assert!(digits.iter().all(|&n| n < 9), "{text}"); } } + +// ---------------------------------------------------------------- [books] parties + +/// Books with the chart and extra `[books]` TOML. +fn books_with(name: &str, extra: &str) -> Mcp { + let dir = test_dir(name); + let toml = books_toml(&dir, "Spec Co", extra); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m +} + +/// Oracle: PostEntryOut party fields and the "suggest" default +/// (docs/parties-and-attachments.md "Counterparties"): an untagged preview +/// names likely parties (description, then the same description on an +/// earlier tagged entry); nothing is refused. +#[test] +fn suggest_names_likely_parties_and_refuses_nothing() { + let mut m = books("suggest"); + assert_eq!( + m.read_only("books.settings", json!({}))["parties"], + "suggest" + ); + let apple = m.add_party("Apple Inc.", &[]); + let id = m.ok( + "books.draft_entry", + json!({"date": "2026-03-01", "description": "APPLE.COM/BILL iCloud"}), + )["entry_id"] + .as_u64() + .unwrap(); + m.ok( + "books.add_line", + json!({"entry_id": id, "account": "6999", "side": "debit", "amount": "2.99"}), + ); + m.ok( + "books.add_line", + json!({"entry_id": id, "account": "2050", "side": "credit", "amount": "2.99"}), + ); + let pv = m.ok("books.post_entry", json!({"entry_id": id})); + assert_eq!(pv["party_policy"], "suggest"); + assert_eq!(pv["party_missing"], true); + assert_eq!(pv["party_suggestions"][0]["party_id"], apple); + assert_eq!( + pv["party_suggestions"][0]["why"], + "named in the description" + ); + m.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + // Tagged: the next entry with the same description is suggested from it. + m.ok( + "books.set_entry_party", + json!({"entry_id": id, "party": apple.to_string()}), + ); + let other = m.add_party("Zed", &[]); + let _ = other; + let id2 = m.ok( + "books.draft_entry", + json!({"date": "2026-04-01", "description": "APPLE.COM/BILL iCloud"}), + )["entry_id"] + .as_u64() + .unwrap(); + m.ok( + "books.add_line", + json!({"entry_id": id2, "account": "6999", "side": "debit", "amount": "2.99"}), + ); + m.ok( + "books.add_line", + json!({"entry_id": id2, "account": "2050", "side": "credit", "amount": "2.99"}), + ); + let pv = m.ok("books.post_entry", json!({"entry_id": id2})); + let s = pv["party_suggestions"].as_array().unwrap(); + assert_eq!(s.len(), 1, "one party, listed once: {s:?}"); + assert_eq!(s[0]["party_id"], apple); +} + +/// Oracle: "require": posting is refused (party_required) and journals +/// nothing until the entry names a party or is marked as having none; +/// no_party_reason is refused alongside a party. +#[test] +fn require_refuses_until_tagged_or_marked_none() { + let mut m = books_with("require", "parties = \"require\"\n"); + assert_eq!( + m.read_only("books.settings", json!({}))["parties"], + "require" + ); + let bank = m.add_party("Bluevine", &["financial_institution"]); + let id = m.draft( + "2026-03-01", + json!({}), + &[("6999", "debit", "5.00"), ("1000", "credit", "5.00")], + ); + let before = m.journal(); + let e = m.refuse( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + assert!(e.contains("party_required"), "{e}"); + assert_eq!(m.journal(), before, "refusal journals nothing"); + m.ok( + "books.set_entry_party", + json!({"entry_id": id, "party": bank.to_string()}), + ); + let pv = m.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); + assert_eq!(pv["status"], "posted"); + assert_eq!(pv["party_id"], bank); + // Marked none at draft time: posts, and the reason is journaled. + let t = m.draft( + "2026-03-02", + json!({"no_party_reason": "transfer between own accounts"}), + &[("1300", "debit", "1.00"), ("1000", "credit", "1.00")], + ); + let pv = m.ok( + "books.post_entry", + json!({"entry_id": t, "confirmed": true}), + ); + assert_eq!(pv["no_party_reason"], "transfer between own accounts"); + assert_eq!(pv["party_missing"], false); + assert_eq!(m.count("entry.no_party"), 1); + // Both at once is refused. + let e = m.refuse( + "books.draft_entry", + json!({"date": "2026-03-03", "description": "x", "party": "Bluevine", "no_party_reason": "none"}), + ); + assert!(e.contains("no_party_reason"), "{e}"); + let e = m.refuse( + "books.set_entry_party", + json!({"entry_id": t, "party": "Bluevine", "no_party_reason": "none"}), + ); + assert!(e.contains("not both"), "{e}"); + // Tagging later clears the mark; marking again needs no party. + m.ok( + "books.set_entry_party", + json!({"entry_id": t, "party": "Bluevine"}), + ); + m.ok( + "books.set_entry_party", + json!({"entry_id": t, "no_party_reason": "own-account transfer"}), + ); + let r = m.read_only("books.verify", json!({})); + assert_eq!(r["status"], "ok", "{r}"); +} + +/// Oracle: "off": the preview never raises a party; a bad value is a +/// settings error. +#[test] +fn off_never_raises_it() { + let mut m = books_with("off", "parties = \"off\"\n"); + m.add_party("Apple", &[]); + let id = m.draft( + "2026-03-01", + json!({}), + &[("6999", "debit", "5.00"), ("1000", "credit", "5.00")], + ); + let pv = m.ok("books.post_entry", json!({"entry_id": id})); + assert_eq!(pv["party_missing"], false); + assert!(pv["party_suggestions"].as_array().unwrap().is_empty()); + m.ok( + "books.post_entry", + json!({"entry_id": id, "confirmed": true}), + ); +} diff --git a/crates/ledger-mcp/tests/spec_vault.rs b/crates/ledger-mcp/tests/spec_vault.rs new file mode 100644 index 0000000..600f3db --- /dev/null +++ b/crates/ledger-mcp/tests/spec_vault.rs @@ -0,0 +1,1096 @@ +//! Spec-blind tests for the TIN vault 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`, +//! `docs/mcp-interface.md` ("Tools — the TIN vault"), and +//! `docs/parties-and-attachments.md` ("The TIN vault"). The rule above all +//! (DECISION, Stephen, 2026-10-15): a full TIN is never in the journal, the +//! SQLite projection, the viewer, or any tool output, except the 1099 +//! worksheet for the CPA when asked (`books.vendor_1099` with +//! `include_tins`). The harness is `tests/spec_parties.rs`'s, with a +//! transcript of every response and the server's stderr kept, and the vault +//! key in a temp file outside the books dir (never `~/.config`). + +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, + /// Every response line the server wrote, with the tool it answered. + transcript: Vec<(String, String)>, +} + +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-vault-{}-{name}-{n}", + std::process::id() + )); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + dir +} + +/// A directory beside the books dir `dir`, outside it: the vault key, +/// files the tests hand the server, files it writes, its stderr. +fn outside(dir: &Path) -> PathBuf { + let d = PathBuf::from(format!("{}-outside", dir.display())); + std::fs::create_dir_all(&d).unwrap(); + d +} + +/// `wanderbooks.toml` with a company name, the receipt threshold, and +/// extra `[books]` TOML; the vault key file is outside the books dir. +fn books_toml(dir: &Path, threshold: &str, books_extra: &str) -> PathBuf { + let path = dir.join("wanderbooks.toml"); + std::fs::write( + &path, + format!( + "[company]\nname = \"Spec Co\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"{threshold}\"\ncash_accounts = [\"1000\"]\n{books_extra}\n\ + [vault]\nkey_file = \"{}\"\n", + outside(dir).join("vault.key").display() + ), + ) + .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) + // Never the user's real vault key (the default key file is + // under XDG_CONFIG_HOME); the tests name theirs anyway. + .env("XDG_CONFIG_HOME", outside(dir).join("config")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::from( + std::fs::File::create(outside(dir).join("stderr.log")).unwrap(), + )) + .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(), + transcript: Vec::new(), + }; + let init = mcp.rpc( + "initialize", + json!({ + "protocolVersion": "2025-06-18", + "capabilities": {}, + "clientInfo": {"name": "spec-vault", "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}"); + let what = params["name"].as_str().unwrap_or(method).to_string(); + self.transcript.push((what, resp.to_string())); + 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"), + ("2050", "Card", "liability"), + ("3000", "Capital", "equity"), + ("6000", "Contract Labor", "expense"), + ("6999", "General Expenses", "expense"), +]; + +/// Books with the chart (no receipts needed), a contractor "Acme Design" +/// with a vendor record marked NEC, and a landlord "Rent Co". +fn books(name: &str) -> Mcp { + let dir = test_dir(name); + let toml = books_toml(&dir, "1000000.00", ""); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m.add_party("Acme Design", &["contractor"]); + m.ok("books.add_vendor", json!({"name": "Acme Design"})); + m.ok( + "books.set_vendor_1099", + json!({"vendor": "Acme Design", "form_1099": "nec"}), + ); + m.add_party("Rent Co", &["landlord"]); + m +} + +/// An SSN and an EIN no other number in these tests resembles. +const SSN: &str = "571-93-2846"; +const SSN_DIGITS: &str = "571932846"; +const EIN: &str = "47-3829105"; +const EIN_DIGITS: &str = "473829105"; + +impl Mcp { + fn books_dir(&self) -> PathBuf { + self.data.parent().unwrap().to_path_buf() + } + fn outside(&self) -> PathBuf { + outside(&self.books_dir()) + } + fn vault(&self) -> PathBuf { + self.books_dir().join("vault") + } + fn key(&self) -> PathBuf { + self.outside().join("vault.key") + } + fn party_id(&mut self, name: &str) -> u64 { + self.party(name, 2026)["party"]["party_id"] + .as_u64() + .unwrap() + } + /// set_w9 for `party` with these extra arguments. + fn w9(&mut self, party: &str, extra: Value) -> Out { + let mut args = json!({"party": party, "received_on": "2026-01-05", + "entity_type": "individual/sole proprietor"}); + for (k, v) in extra.as_object().unwrap() { + args[k] = v.clone(); + } + self.tool("books.set_w9", args) + } + /// A W-9 file outside the books; it shows the TIN, like the real one. + fn w9_file(&self, name: &str, tin: &str) -> PathBuf { + let p = self.outside().join(name); + std::fs::write(&p, format!("%PDF-1.4 Form W-9 TIN {tin}")).unwrap(); + p + } + /// Pay `vendor` `amount` from the bank in 2026, tagged. + fn pay(&mut self, vendor: &str, amount: &str) { + self.post( + "2026-03-01", + json!({"vendor": vendor}), + &[("6000", "debit", amount), ("1000", "credit", amount)], + ); + } + /// The W-9 facts the journal holds for a party, last first. + fn journaled_w9(&self, party: u64) -> Value { + String::from_utf8(self.journal()) + .unwrap() + .lines() + .filter_map(|l| serde_json::from_str::(l).ok()) + .filter(|v| v["type"] == "party.w9_set" || v["line_type"] == "party.w9_set") + .rfind(|v| v["payload"]["id"] == json!(party)) + .map(|v| v["payload"]["facts"].clone()) + .expect("a party.w9_set") + } +} + +/// Whether `hay` holds the nine `digits` in any formatting: separators +/// (space, hyphen, dot, slash, underscore) between them are ignored. +fn holds_tin(hay: &[u8], digits: &str) -> bool { + let squeezed: Vec = hay + .iter() + .filter(|b| !matches!(b, b' ' | b'-' | b'.' | b'/' | b'_')) + .copied() + .collect(); + squeezed + .windows(digits.len()) + .any(|w| w == digits.as_bytes()) +} + +/// Every file under `dir`, recursively. +fn files_under(dir: &Path) -> Vec { + let mut out = Vec::new(); + let Ok(rd) = std::fs::read_dir(dir) else { + return out; + }; + for e in rd.flatten() { + let p = e.path(); + if p.is_dir() { + out.extend(files_under(&p)); + } else { + out.push(p); + } + } + out +} + +// ------------------------------------------------- books.set_w9 with a TIN + +/// The TIN goes to the vault (the key created on first use, owner-only), +/// the books keep its last four and the record's hash, and the output +/// never shows it. +#[test] +fn a_tin_goes_to_the_vault_and_the_books_keep_the_last_four() { + let mut m = books("store"); + assert!(!m.key().exists()); + let out = m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})); + let Out::Ok(p) = out else { panic!("{out:?}") }; + assert_eq!(p["w9"]["tin_last4"], "2846", "from the TIN"); + assert_eq!(p["w9"]["kept_at"], "wanderbooks vault"); + assert_eq!(p["w9"]["in_vault"], true); + assert!(!holds_tin(p.to_string().as_bytes(), SSN_DIGITS)); + let id = p["party_id"].as_u64().unwrap(); + assert!(m.vault().join(format!("tin-{id}.bin")).exists()); + assert!(m.key().exists(), "the key is created on first use"); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(m.key()).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600, "the key is the owner's only"); + } + let facts = m.journaled_w9(id); + assert_eq!(facts["tin_last4"], "2846"); + assert_eq!(facts["vault_record"].as_str().unwrap().len(), 64); + // The vendor record's W-9 flag follows. + let v = m.read_only("books.vendor_1099", json!({"year": 2026})); + assert_eq!(v["vendors"][0]["tin_on_file"], true); + assert_eq!(v["vendors"][0]["tin_last4"], "2846"); + assert!(v["vendors"][0]["tin"].is_null()); + assert!(v["warning"].is_null()); + // A given tin_last4 that agrees is fine; an EIN for the landlord. + m.w9( + "Rent Co", + json!({"tin": EIN, "tin_type": "ein", "tin_last4": "9105", + "kept_at": "binder"}), + ); + let rent = m.party("Rent Co", 2026); + assert_eq!(rent["party"]["w9"]["kept_at"], "binder"); + assert_eq!(rent["party"]["w9"]["in_vault"], true); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +#[test] +fn set_w9_refusals_write_nothing_anywhere() { + let mut m = books("refusals"); + let acme = m.party_id("Acme Design"); + m.add_party("IRS", &["tax_agency"]); + let missing = m.outside().join("no-such-w9.pdf"); + let txt = m.outside().join("w9.txt"); + std::fs::write(&txt, "W-9").unwrap(); + for (party, extra) in [ + ("Acme Design", json!({"tin": "57193284", "tin_type": "ssn"})), // eight digits + ( + "Acme Design", + json!({"tin": "971-93-2846", "tin_type": "ssn"}), + ), // an SSN starting 9 + ( + "Acme Design", + json!({"tin": "571932846", "tin_type": "itin"}), + ), // not an ITIN + ("Acme Design", json!({"tin": SSN})), // no type + ( + "Acme Design", + json!({"tin_type": "ssn", "tin_last4": "2846", "kept_at": "x"}), + ), + ("Acme Design", json!({"tin": SSN, "tin_type": "passport"})), + ( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "tin_last4": "1111"}), + ), + ( + "Acme Design", + json!({"tin": 571932846.0, "tin_type": "ssn"}), + ), // a float + ( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": missing.to_str().unwrap()}), + ), + ( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": txt.to_str().unwrap()}), + ), + ( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "clear": true}), + ), + ( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "kept_at": "bad\u{7}"}), + ), + ("IRS", json!({"tin": SSN, "tin_type": "ssn"})), // no 1099 role + ("Nobody", json!({"tin": SSN, "tin_type": "ssn"})), + ] { + let out = m.w9(party, extra.clone()); + assert!(out.refused(), "{party} {extra} should be refused: {out:?}"); + assert!( + !holds_tin(out.err_text().as_bytes(), SSN_DIGITS), + "a refusal never echoes the TIN: {}", + out.err_text() + ); + } + assert!( + !m.vault().join(format!("tin-{acme}.bin")).exists(), + "no record written" + ); + assert!(!m.key().exists(), "no key made for a refusal"); + assert_eq!(m.count("party.w9_set"), 0); +} + +/// Facts that change without a new TIN keep the vault record, unless they +/// disagree with it; a new TIN replaces it. +#[test] +fn the_facts_never_disagree_with_the_vault() { + let mut m = books("agree"); + let acme = m.party_id("Acme Design"); + m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})); + let first = m.journaled_w9(acme)["vault_record"].clone(); + // New facts, same last four: the record stays. + let Out::Ok(p) = m.w9( + "Acme Design", + json!({"tin_last4": "2846", "kept_at": "wanderbooks vault", + "exempt_payee_code": "5"}), + ) else { + panic!("same last four") + }; + assert_eq!(p["w9"]["in_vault"], true); + assert_eq!(m.journaled_w9(acme)["vault_record"], first); + // Another last four without the TIN: refused. + let why = m + .w9( + "Acme Design", + json!({"tin_last4": "1111", "kept_at": "binder"}), + ) + .err_text(); + assert!( + why.contains("books.purge_tin") && why.contains("2846"), + "{why}" + ); + // A new TIN replaces the old. + m.w9( + "Acme Design", + json!({"tin": "571-93-1111", "tin_type": "ssn"}), + ); + let facts = m.journaled_w9(acme); + assert_eq!(facts["tin_last4"], "1111"); + assert_ne!(facts["vault_record"], first); + m.pay("Acme Design", "700.00"); + let v = m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true}), + ); + assert_eq!(v["vendors"][0]["tin"], "571-93-1111"); +} + +// ------------------------------------------------- the W-9 file + +#[test] +fn the_w9_file_goes_to_the_vault_never_the_documents() { + let mut m = books("w9-file"); + let acme = m.party_id("Acme Design"); + let src = m.w9_file("acme-w9.pdf", SSN); + let Out::Ok(p) = m.w9( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}), + ) else { + panic!("set_w9 with a file") + }; + assert_eq!(p["w9"]["in_vault"], true); + assert!(m.vault().join(format!("w9-{acme}.bin")).exists()); + let docs = m.read_only("books.list_receipts", json!({"include_withdrawn": true})); + assert!( + docs["receipts"].as_array().unwrap().is_empty(), + "not a document: {docs}" + ); + // Read back to a path outside the books: the bytes, owner-only. + let to = m.outside().join("copy.pdf"); + let out = m.ok( + "books.get_w9", + json!({"party": "Acme Design", "save_to": to.to_str().unwrap()}), + ); + assert_eq!(out["path"], json!(to.display().to_string())); + assert_eq!(out["file_name"], "acme-w9.pdf"); + assert_eq!(std::fs::read(&to).unwrap(), std::fs::read(&src).unwrap()); + assert!(!holds_tin(out.to_string().as_bytes(), SSN_DIGITS)); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mode = std::fs::metadata(&to).unwrap().permissions().mode(); + assert_eq!(mode & 0o777, 0o600); + } + let books = m.books_dir(); + for save_to in [ + to.clone(), // exists + books.join("w9.pdf"), // in the books dir + m.data.join("w9.pdf"), // in the data dir + books.join("vault/../w9.pdf"), // in it, by a detour + m.outside().join("no/such/dir/w9.pdf"), // no folder + ] { + m.refuse( + "books.get_w9", + json!({"party": "Acme Design", "save_to": save_to.to_str().unwrap()}), + ); + } + // A party with no W-9 file in the vault. + m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"})); + m.refuse( + "books.get_w9", + json!({"party": "Rent Co", "save_to": m.outside().join("rent.pdf").to_str().unwrap()}), + ); + // A W-9 file alone (the TIN typed in later) records the vault too. + m.add_party("Solo", &["contractor"]); + let solo = m.w9_file("solo.png", "000"); + let Out::Ok(p) = m.w9( + "Solo", + json!({"tin_last4": "0000", "w9_file_path": solo.to_str().unwrap()}), + ) else { + panic!("a W-9 file alone") + }; + assert_eq!(p["w9"]["in_vault"], true); + assert_eq!(p["w9"]["kept_at"], "wanderbooks vault"); +} + +#[test] +fn a_locked_vault_says_so_and_never_guesses() { + let mut m = books("locked"); + let src = m.w9_file("w9.pdf", SSN); + m.w9( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}), + ); + m.pay("Acme Design", "700.00"); + let key = std::fs::read(m.key()).unwrap(); + std::fs::remove_file(m.key()).unwrap(); + let why = m.refuse( + "books.get_w9", + json!({"party": "Acme Design", "save_to": m.outside().join("x.pdf").to_str().unwrap()}), + ); + assert!(why.contains("locked"), "{why}"); + let v = m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true}), + ); + assert!(v["vendors"][0]["tin"].is_null()); + assert!( + v["vendors"][0]["tin_note"] + .as_str() + .unwrap() + .contains("locked"), + "{v}" + ); + assert!(!m.key().exists(), "reading never makes a key"); + // Another key can't read it: refused, not guessed. + std::fs::write(m.key(), "11".repeat(32)).unwrap(); + let v = m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true}), + ); + assert!(v["vendors"][0]["tin"].is_null()); + assert!( + v["vendors"][0]["tin_note"] + .as_str() + .unwrap() + .contains("can't be read"), + "{v}" + ); + std::fs::write(m.key(), key).unwrap(); + let v = m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true}), + ); + assert_eq!(v["vendors"][0]["tin"], SSN); +} + +#[test] +fn a_key_file_inside_the_books_is_refused() { + let dir = test_dir("key-inside"); + let toml = dir.join("wanderbooks.toml"); + std::fs::write( + &toml, + format!( + "[company]\nname = \"Spec Co\"\n[books]\nreceipt_threshold = \"1000000.00\"\n\ + [vault]\nkey_file = \"{}\"\n", + dir.join("vault.key").display() + ), + ) + .unwrap(); + let mut m = Mcp::start(&dir, &toml); + m.accounts(CHART); + m.add_party("Acme Design", &["contractor"]); + let why = m + .w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})) + .err_text(); + assert!(why.contains("inside the books dir"), "{why}"); + assert!(!dir.join("vault.key").exists()); + assert!(!dir.join("vault").exists()); +} + +// ------------------------------------------------- books.vendor_1099 + +#[test] +fn the_1099_worksheet_carries_full_tins_only_when_asked() { + let mut m = books("worksheet"); + m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})); + m.pay("Acme Design", "2500.00"); + m.ok("books.add_vendor", json!({"name": "Rent Co"})); + m.ok( + "books.set_vendor_1099", + json!({"vendor": "Rent Co", "form_1099": "misc"}), + ); + m.w9( + "Rent Co", + json!({"tin_last4": "9105", "kept_at": "CPA portal"}), + ); + m.pay("Rent Co", "3000.00"); + let plain = m.read_only("books.vendor_1099", json!({"year": 2026})); + assert!(!holds_tin(plain.to_string().as_bytes(), SSN_DIGITS)); + assert!(plain["warning"].is_null()); + let full = m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true}), + ); + assert!(full["warning"].as_str().unwrap().contains("CPA"), "{full}"); + let line = |name: &str| { + full["vendors"] + .as_array() + .unwrap() + .iter() + .find(|v| v["name"] == name) + .unwrap() + .clone() + }; + let acme = line("Acme Design"); + assert_eq!(acme["tin"], SSN); + assert_eq!(acme["tin_type"], "ssn"); + assert!(acme["tin_note"].is_null()); + let rent = line("Rent Co"); + assert!(rent["tin"].is_null()); + assert!( + rent["tin_note"] + .as_str() + .unwrap() + .contains("not in the vault"), + "{rent}" + ); + assert_eq!(rent["tin_last4"], "9105"); + // The CSV goes to a new file outside the books. + let csv = m.outside().join("1099-2026.csv"); + let out = m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}), + ); + assert_eq!(out["saved_csv"], json!(csv.display().to_string())); + let text = std::fs::read_to_string(&csv).unwrap(); + assert!(text.contains(SSN), "{text}"); + assert!(text.starts_with("vendor_id,name,form_1099,paid,reportable,w9_on_file,tin_last4,tin")); + let without = m.outside().join("1099-plain.csv"); + m.read_only( + "books.vendor_1099", + json!({"year": 2026, "save_csv_to": without.to_str().unwrap()}), + ); + assert!(!holds_tin(&std::fs::read(&without).unwrap(), SSN_DIGITS)); + for to in [ + csv.clone(), + m.books_dir().join("1099.csv"), + m.data.join("1099.csv"), + ] { + m.refuse( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true, "save_csv_to": to.to_str().unwrap()}), + ); + } + assert!(!m.books_dir().join("1099.csv").exists()); +} + +// ------------------------------------------------- books.purge_tin + +#[test] +fn purge_deletes_the_vault_records_and_journals_why() { + let mut m = books("purge"); + let acme = m.party_id("Acme Design"); + let src = m.w9_file("w9.pdf", SSN); + m.w9( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}), + ); + // Clearing while the vault holds it: refused. + let why = m + .tool( + "books.set_w9", + json!({"party": "Acme Design", "clear": true}), + ) + .err_text(); + assert!(why.contains("books.purge_tin"), "{why}"); + m.refuse( + "books.purge_tin", + json!({"party": "Acme Design", "reason": " ", "confirmed": true}), + ); + m.refuse( + "books.purge_tin", + json!({"party": "Nobody", "reason": "x", "confirmed": true}), + ); + // A preview changes nothing. + let journal = m.journal(); + let p = m.ok( + "books.purge_tin", + json!({"party": "Acme Design", "reason": "retention over"}), + ); + assert_eq!(p["status"], "preview"); + assert_eq!(p["tin_in_vault"], true); + assert_eq!(p["w9_file_in_vault"], true); + assert_eq!(p["tin_last4"], "2846"); + assert!(m.journal() == journal); + assert!(m.vault().join(format!("tin-{acme}.bin")).exists()); + // Without the key, confirmed: gone, and journaled. + std::fs::remove_file(m.key()).unwrap(); + let p = m.ok( + "books.purge_tin", + json!({"party": "Acme Design", "reason": "retention over", "confirmed": true}), + ); + assert_eq!(p["status"], "purged"); + assert_eq!(p["journaled"], true); + assert!(!m.vault().join(format!("tin-{acme}.bin")).exists()); + assert!(!m.vault().join(format!("w9-{acme}.bin")).exists()); + let facts = m.journaled_w9(acme); + assert!(facts["vault_record"].is_null(), "{facts}"); + assert_eq!(facts["tin_last4"], "2846", "the last four stay"); + assert!( + facts["kept_at"] + .as_str() + .unwrap() + .contains("retention over"), + "{facts}" + ); + assert_eq!( + m.party("Acme Design", 2026)["party"]["w9"]["in_vault"], + false + ); + // Nothing left: refused. Clearing works now. + m.refuse( + "books.purge_tin", + json!({"party": "Acme Design", "reason": "again", "confirmed": true}), + ); + m.ok( + "books.set_w9", + json!({"party": "Acme Design", "clear": true}), + ); + assert_eq!(m.ok("books.verify", json!({}))["status"], "ok"); +} + +// ------------------------------------------------- the rule above all + +/// A session that stores TINs (an SSN and an EIN, and a W-9 file showing +/// one) and then uses the books every way it can: no tool output, error, +/// or log, and no byte of the journal, the database, the stored files, or +/// the viewer's requests holds either TIN in any formatting, except +/// books.vendor_1099 with include_tins. The vault holds them only +/// encrypted. +#[test] +fn no_full_tin_leaves_the_vault_except_the_worksheet_when_asked() { + let mut m = books("no-leak"); + let src = m.w9_file("acme-w9.pdf", SSN); + m.w9( + "Acme Design", + json!({"tin": SSN, "tin_type": "ssn", "w9_file_path": src.to_str().unwrap()}), + ); + m.w9("Rent Co", json!({"tin": EIN, "tin_type": "ein"})); + m.ok("books.add_vendor", json!({"name": "Rent Co"})); + m.ok( + "books.set_vendor_1099", + json!({"vendor": "Rent Co", "form_1099": "misc"}), + ); + m.pay("Acme Design", "2500.00"); + m.pay("Rent Co", "3000.00"); + let e = m.post( + "2026-03-02", + json!({"party": "Acme Design"}), + &[("6999", "debit", "1.00"), ("1000", "credit", "1.00")], + ); + // Refusals, which must not echo what they were given. + for args in [ + json!({"party": "Acme Design", "received_on": "2026-01-05", "entity_type": "x", + "tin": SSN, "tin_type": "ein", "tin_last4": "0000"}), + json!({"party": "Acme Design", "received_on": "2026-01-05", "entity_type": "x", + "tin": EIN, "tin_type": "ssn", "tin_last4": "2846"}), + json!({"party": "Acme Design", "received_on": "2026-01-05", "entity_type": "x", + "tin": SSN_DIGITS.parse::().unwrap() as f64, "tin_type": "ssn"}), + json!({"party": "Acme Design", "clear": true}), + ] { + m.refuse("books.set_w9", args); + } + // A TIN typed into another field is refused, and not echoed. + m.refuse( + "books.update_party", + json!({"party": "Acme Design", "notes": format!("ssn {SSN}")}), + ); + m.refuse("books.add_party", json!({"name": format!("Acme {EIN}")})); + // Everything else, read and write. + for (tool, args) in [ + ("books.parties", json!({})), + ("books.parties", json!({"role": "contractor", "year": 2026})), + ("books.party", json!({"party": "Acme Design"})), + ("books.party", json!({"party": "Rent Co", "year": 2026})), + ("books.list_vendors", json!({})), + ("books.vendor_1099", json!({"year": 2026})), + ("books.get_entry", json!({"entry_id": e})), + ("books.search_entries", json!({"text": "Acme"})), + ("books.digest", json!({"since": "2026-01-01"})), + ("books.trial_balance", json!({})), + ("books.list_receipts", json!({"include_withdrawn": true})), + ( + "books.attachments", + json!({"subject": {"kind": "party", "id": "Acme Design"}}), + ), + ("books.settings", json!({})), + ("books.verify", json!({})), + ("books.show", json!({"party": "Acme Design"})), + ( + "books.export", + json!({"report": "journal", "format": "csv"}), + ), + ( + "books.export", + json!({"report": "journal", "format": "json"}), + ), + ( + "books.export_cpa_package", + json!({"as_of": "2026-12-31", "cash_accounts": ["1000"]}), + ), + ( + "books.purge_tin", + json!({"party": "Rent Co", "reason": "preview only"}), + ), + ( + "books.get_w9", + json!({"party": "Acme Design", + "save_to": m.outside().join("for-cpa.pdf").to_str().unwrap()}), + ), + ( + "books.update_party", + json!({"party": "Acme Design", "notes": "W-9 in the vault"}), + ), + ] { + m.ok(tool, args); + } + let full = m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true}), + ); + assert!( + holds_tin(full.to_string().as_bytes(), SSN_DIGITS), + "the worksheet, asked" + ); + assert!(holds_tin(full.to_string().as_bytes(), EIN_DIGITS)); + let csv = m.outside().join("w.csv"); + m.read_only( + "books.vendor_1099", + json!({"year": 2026, "include_tins": true, "save_csv_to": csv.to_str().unwrap()}), + ); + // Every response but the worksheet's with include_tins. + let leaks: Vec<&(String, String)> = m + .transcript + .iter() + .filter(|(tool, resp)| { + let worksheet = + tool == "books.vendor_1099" && resp.contains("\"warning\":\"This worksheet"); + !worksheet + && (holds_tin(resp.as_bytes(), SSN_DIGITS) + || holds_tin(resp.as_bytes(), EIN_DIGITS)) + }) + .collect(); + assert!(leaks.is_empty(), "full TINs in tool output: {leaks:?}"); + assert!(m.transcript.len() > 40, "the session used the books"); + // The server's log. + let log = std::fs::read(m.outside().join("stderr.log")).unwrap(); + assert!(!holds_tin(&log, SSN_DIGITS) && !holds_tin(&log, EIN_DIGITS)); + // Every byte in the books dir outside the vault: the journal, the + // database (and its WAL), the stored files, the settings. + let books = m.books_dir(); + let mut seen = Vec::new(); + for f in files_under(&books) { + if f.starts_with(m.vault()) { + continue; + } + let bytes = std::fs::read(&f).unwrap(); + assert!( + !holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS), + "{} holds a full TIN", + f.display() + ); + seen.push(f.file_name().unwrap().to_string_lossy().into_owned()); + } + for must in ["journal.jsonl", "wanderbooks.db"] { + assert!(seen.iter().any(|s| s == must), "{must} scanned: {seen:?}"); + } + // The vault holds them encrypted only. + let vault_files = files_under(&m.vault()); + assert!(vault_files.len() >= 3, "{vault_files:?}"); + for f in vault_files { + let bytes = std::fs::read(&f).unwrap(); + assert!( + !holds_tin(&bytes, SSN_DIGITS) && !holds_tin(&bytes, EIN_DIGITS), + "{} holds a TIN in the clear", + f.display() + ); + } +} + +/// Books kept in git must ignore the vault before a TIN is stored. +#[test] +fn books_in_git_must_ignore_the_vault() { + let mut m = books("git"); + let books = m.books_dir(); + std::fs::create_dir_all(books.join(".git")).unwrap(); + std::fs::write(books.join(".gitignore"), "books-data/wanderbooks.db\n").unwrap(); + let why = m + .w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})) + .err_text(); + assert!( + why.contains(".gitignore") && why.contains("vault/"), + "{why}" + ); + assert!(!m.vault().exists() && !m.key().exists()); + std::fs::write( + books.join(".gitignore"), + "books-data/wanderbooks.db\nvault/\n", + ) + .unwrap(); + assert!( + !m.w9("Acme Design", json!({"tin": SSN, "tin_type": "ssn"})) + .refused() + ); +} diff --git a/crates/ledger-store/src/snapshot.rs b/crates/ledger-store/src/snapshot.rs index def0f31..38317df 100644 --- a/crates/ledger-store/src/snapshot.rs +++ b/crates/ledger-store/src/snapshot.rs @@ -18,9 +18,12 @@ use crate::blob::BlobStore; use crate::error::StoreError; use crate::journal::{GENESIS_PREV, JournalLine, sha256_hex}; use crate::store::{ - Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Store, apply_line_to_sqlite, compare_projections, - projection_rows, query_attachments, read_attachment_bytes, replay_line_into_ledger, + Attachment, BLOBS_DIR, DB_FILE, JOURNAL_FILE, Reconciliation, Statement, Store, Subject, + apply_line_to_sqlite, compare_projections, projection_rows, query_attachment_links, + query_attachments, query_reconciliations, query_statements, read_attachment_bytes, + replay_line_into_ledger, }; +use ledger_core::AccountId; use ledger_core::{Currency, EntryId, Ledger}; use rusqlite::Connection; use std::io::{Read, Seek, SeekFrom}; @@ -216,6 +219,28 @@ impl Snapshot { query_attachments(&self.conn, entry_id) } + /// Every attachment link beyond a receipt's filing entry, as + /// (attachment id, subject), by attachment then subject: what + /// [`Store::attachment_links`] reads, for every attachment at once. + pub fn attachment_links(&self) -> Result, StoreError> { + query_attachment_links(&self.conn) + } + + /// Recorded bank and card statements, optionally one account's, by + /// period end (as [`Store::statements`]). + pub fn statements(&self, account: Option) -> Result, StoreError> { + query_statements(&self.conn, account) + } + + /// Reconciliations, voided ones included, optionally one account's, + /// by date (as [`Store::reconciliations`]). + pub fn reconciliations( + &self, + account: Option, + ) -> Result, StoreError> { + query_reconciliations(&self.conn, account) + } + /// Compare the writer's SQLite database (opened read-only) with this /// snapshot's own projection of the journal, every table, row for row. /// diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 8d7e824..680db43 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -821,6 +821,20 @@ impl Store { Ok(()) } + /// Mark an entry as having no counterparty, with why + /// (`Ledger::set_entry_no_party`). Journaled as `entry.no_party + /// {entry_id, reason}`. + pub fn set_entry_no_party(&mut self, entry: EntryId, reason: &str) -> Result<(), StoreError> { + self.ledger.check_set_entry_no_party(entry, reason)?; + let line = self.append_journal( + "entry.no_party", + json!({"entry_id": entry, "reason": reason.trim()}), + )?; + self.ledger.set_entry_no_party(entry, reason)?; + 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 @@ -2546,8 +2560,9 @@ impl Store { // Attachment links, statements, reconciliations // ------------------------------------------------------------------ - /// Whether `subject` exists in the books. - fn subject_exists(&self, subject: Subject) -> Result { + /// Whether `subject` exists in the books (what [`Store::link_attachment`] + /// checks, so callers can validate several subjects before linking any). + pub fn subject_exists(&self, subject: Subject) -> Result { let l = &self.ledger; Ok(match subject { Subject::Entry(i) => l.get_entry(i).is_some(), @@ -2776,39 +2791,7 @@ impl Store { /// 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) + query_statements(&self.conn, account) } /// Reconcile an account to a recorded statement: the books' cleared @@ -2936,47 +2919,7 @@ impl Store { &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) + query_reconciliations(&self.conn, account) } /// `(linked entry, withdrawn?)` for an attachment. @@ -4138,6 +4081,9 @@ pub(crate) fn replay_line_into_ledger( }; ledger.set_entry_party(get_u64(p, "entry_id")?, party)?; } + "entry.no_party" => { + ledger.set_entry_no_party(get_u64(p, "entry_id")?, get_str(p, "reason")?)?; + } "asset.corrected" => { ledger.correct_asset( get_u64(p, "asset_id")?, @@ -4710,7 +4656,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr )?; } "role.assigned" | "party.added" | "party.updated" | "party.role_set" | "party.w9_set" - | "party.w4_set" | "entry.party_set" => { + | "party.w4_set" | "entry.party_set" | "entry.no_party" => { // Control roles and the party directory live in the ledger // (rebuilt by replay); the events mirror below keeps the // history. @@ -5328,6 +5274,120 @@ pub struct Reconciliation { pub voided: Option, } +/// Recorded statements in the projection, optionally one account's, by +/// period end (shared by [`Store::statements`] and the read-only +/// snapshot). +pub(crate) fn query_statements( + conn: &Connection, + account: Option, +) -> Result, StoreError> { + let mut stmt = 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) +} + +/// Reconciliations in the projection, optionally one account's, by date +/// (shared by [`Store::reconciliations`] and the read-only snapshot). +pub(crate) fn query_reconciliations( + conn: &Connection, + account: Option, +) -> Result, StoreError> { + let mut stmt = 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)> = 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) +} + +/// Every row of `attachment_links`: (attachment, subject), by attachment +/// then subject. A receipt's filing entry isn't a row here (it is the +/// attachment's `entry_id`). +pub(crate) fn query_attachment_links(conn: &Connection) -> Result, StoreError> { + let mut stmt = conn.prepare( + "SELECT attachment_id, subject_kind, subject_id FROM attachment_links ORDER BY attachment_id, subject_kind, subject_id", + )?; + let rows = stmt.query_map([], |r| { + Ok(( + r.get::<_, i64>(0)?, + r.get::<_, String>(1)?, + r.get::<_, i64>(2)?, + )) + })?; + let mut out = Vec::new(); + for row in rows { + let (att, kind, id) = row?; + if let Some(sub) = Subject::from_parts(&kind, id as u64) { + out.push((att as u64, sub)); + } + } + Ok(out) +} + /// Attachments in the projection, optionally only one entry's, by id. pub(crate) fn query_attachments( conn: &Connection, diff --git a/crates/ledger-store/src/viewer.rs b/crates/ledger-store/src/viewer.rs index 22b9035..83dd70c 100644 --- a/crates/ledger-store/src/viewer.rs +++ b/crates/ledger-store/src/viewer.rs @@ -68,6 +68,12 @@ pub enum ViewerTarget { /// Party id. party_id: u64, }, + /// One reconciliation: its account, with the account's statements and + /// reconciliations. + Reconciliation { + /// Reconciliation id. + id: u64, + }, /// The transactions list, searched. Search { /// Search text: description, account, vendor, amount. diff --git a/crates/ledger-vault/src/lib.rs b/crates/ledger-vault/src/lib.rs index 26e9041..cd03066 100644 --- a/crates/ledger-vault/src/lib.rs +++ b/crates/ledger-vault/src/lib.rs @@ -260,7 +260,7 @@ impl Vault { } fn path(&self, party: u64, kind: Kind) -> PathBuf { - self.dir.join(format!("{}-{party}.bin", kind.name())) + record_path(&self.dir, party, kind) } fn aad(party: u64, kind: Kind) -> Vec { @@ -361,18 +361,41 @@ impl Vault { /// 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) + purge_records(&self.dir, party) } } +fn record_path(dir: &Path, party: u64, kind: Kind) -> PathBuf { + dir.join(format!("{}-{party}.bin", kind.name())) +} + +/// What the vault in `dir` holds for a party, `(TIN, W-9 file)`, without +/// the key: whether each record's file exists (nothing is decrypted). +pub fn records_held(dir: &Path, party: u64) -> Result<(bool, bool), Error> { + let exists = |kind| match std::fs::metadata(record_path(dir, party, kind)) { + Ok(_) => Ok(true), + Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false), + Err(e) => Err(Error::Io(e)), + }; + Ok((exists(Kind::Tin)?, exists(Kind::W9)?)) +} + +/// Delete everything the vault in `dir` holds for a party (INVARIANT 6), +/// without the key: purging must work even where the vault can't be read +/// (the key lost, or books restored on another machine). Returns whether +/// anything was there. +pub fn purge_records(dir: &Path, party: u64) -> Result { + let mut any = false; + for kind in [Kind::Tin, Kind::W9] { + match std::fs::remove_file(record_path(dir, 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> { diff --git a/crates/ledger-vault/tests/vault.rs b/crates/ledger-vault/tests/vault.rs index 9e95066..d26e7f5 100644 --- a/crates/ledger-vault/tests/vault.rs +++ b/crates/ledger-vault/tests/vault.rs @@ -180,3 +180,44 @@ fn purge_removes_a_party() { assert!(v.tin(2).unwrap().is_some()); assert!(!v.purge(1).unwrap(), "nothing left to purge"); } + +/// INVARIANT 6, without the key: `records_held` says what a party has on +/// disk and `purge_records` deletes it, so a vault whose key is lost can +/// still be purged. +#[test] +fn purge_and_holdings_need_no_key() { + let d = dir("keyless"); + let v = vault(&d); + let tin = Tin::new("123456789", TinType::Ssn).unwrap(); + v.put_tin(1, &tin).unwrap(); + v.put_w9( + 1, + &W9File { + file_name: "w9.pdf".into(), + mime_type: "application/pdf".into(), + bytes: b"%PDF".to_vec(), + }, + ) + .unwrap(); + v.put_tin(2, &tin).unwrap(); + std::fs::remove_file(d.join("keys/vault.key")).unwrap(); + let vdir = d.join("vault"); + assert_eq!(ledger_vault::records_held(&vdir, 1).unwrap(), (true, true)); + assert_eq!(ledger_vault::records_held(&vdir, 2).unwrap(), (true, false)); + assert_eq!( + ledger_vault::records_held(&vdir, 3).unwrap(), + (false, false) + ); + assert!(ledger_vault::purge_records(&vdir, 1).unwrap()); + assert_eq!( + ledger_vault::records_held(&vdir, 1).unwrap(), + (false, false) + ); + assert_eq!(ledger_vault::records_held(&vdir, 2).unwrap(), (true, false)); + assert!(!ledger_vault::purge_records(&vdir, 1).unwrap()); + // A vault directory that doesn't exist holds nothing. + assert_eq!( + ledger_vault::records_held(&d.join("nowhere"), 1).unwrap(), + (false, false) + ); +} diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 33386fd..3d89882 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -150,7 +150,12 @@ below. amounts wanderbooks may no longer know as of today (`{ name, known_through, catalog, source, why }`): research the current value or ask the human, then update it upstream (pull request or issue at - github.com/swaits/wanderbooks). + github.com/swaits/wanderbooks). `unreconciled_accounts` lists the cash + and card accounts due for reconciliation (`{ code, name, + reconciled_through?, due_through, next_step }`): every `[books] + cash_accounts` account and every liability with a recorded statement + that has posted activity through the end of last month but no + reconciliation (not voided) through it. ## Tools — fixed assets @@ -226,12 +231,15 @@ through the policy. ## Tools — the viewer - `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 + party | reconciliation | search | page, start?, end? }` → `{ shown, + note }`. Asks the human's viewer (`ledger-gui`) to open an entry, an + account register or a report (for start/end dates if given), a stored + file, an asset, a party (by id or name, resolved as in "Tools — + parties"), a reconciliation (by id: its account's page, with its + statements and reconciliations; viewer target `{show: "reconciliation", + id}`), a transactions search, or a page (`parties` among them). Exactly + one target; entries, accounts, attachments, assets, parties, and + reconciliations are checked to exist. Never touches the books: the request is a small JSON file in the user's runtime directory (`$XDG_RUNTIME_DIR/wanderbooks/viewer-.json`), written atomically, watched by the viewer. A viewer started within two minutes of a request opens it; older requests @@ -269,8 +277,9 @@ entries tagged with the vendor. Card payments move no asset (the card processor's 1099-K covers them), so they drop out; reversals of tagged entries carry the tag and net out. Thresholds: $600 for payments through 2025, $2,000 from 2026 (indexed for inflation after 2026); always -overridable, always for the CPA to confirm. TINs are never stored, only -whether a W-9 is on file. +overridable, always for the CPA to confirm. The books keep whether a W-9 +is on file and the TIN's last four; full TINs live only in the TIN vault +("Tools — the TIN vault"). - `books.list_vendors {}` → `{ vendors: [{ id, name, form_1099?, tin_on_file, party_id }] }`. @@ -282,11 +291,21 @@ whether a W-9 is on file. 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. +- `books.vendor_1099 { year, threshold?, include_tins?, save_csv_to? }` → + `{ year, threshold, vendors: [{ vendor_id, name, form_1099?, tin_on_file, + paid, reportable, party_id?, tin_last4?, tin?, tin_type?, tin_note? }], + missing_w9, notes, warning?, saved_csv? }`. For a year whose threshold + 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. `tin_last4` comes from the party's W-9 facts. With + `include_tins: true` (default false) each line carries `tin`, the full + TIN formatted as the IRS writes it (123-45-6789; 12-3456789 for an + EIN), and `tin_type`, read from the vault, or `tin_note` saying why not + (not in the vault, the vault locked, a record whose last four or hash + disagree with the books); `warning` then says the output holds full + TINs, for the CPA only. This is the only tool output that ever holds a + full TIN. `save_csv_to` also writes the worksheet as CSV (TINs only with + `include_tins`) to a new file outside the books dir, mode 0600. ## Tools — parties @@ -330,10 +349,14 @@ texts are at most 500 characters, no control characters. `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. + fatca_code?, tin_last4, kept_at, tin?, tin_type?, w9_file_path? }` 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. `tin`, `tin_type`, and `w9_file_path` put the full + TIN and the signed form in the TIN vault (below); with them `tin_last4` + comes from the TIN (a given one must agree) and `kept_at` defaults to + `"wanderbooks vault"`. - `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 @@ -354,7 +377,7 @@ texts are at most 500 characters, no control characters. 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, +exempt_payee_code?, fatca_code?, tin_last4, kept_at, in_vault }, w4?: { received_on, state, kept_at } }`. `label` is the display name, else the name; `roles` include vendor and customer when it has those records. @@ -383,11 +406,86 @@ include vendor and customer when it has those records. payments drop out); `form_1099` is the record's mark. - `gross_pay` / `net_pay`: payroll lines naming it, in the year. +## Tools — the TIN vault + +Full TINs (SSN, EIN, ITIN) and signed W-9 forms, kept so the CPA's 1099 +worksheet is complete, never in the books (`ledger-vault`; design in +`docs/parties-and-attachments.md` "The TIN vault"; B-26). + +- **Where:** `vault/` in the books dir (the parent of the data dir, beside + `wanderbooks.toml`; git-ignored), one file per record, each encrypted + (XChaCha20-Poly1305) and bound to its party. **Key:** the file named by + `[vault] key_file` in `wanderbooks.toml`, else + `~/.config/wanderbooks/vault.key` (`$XDG_CONFIG_HOME`), mode 0600, + created the first time a TIN or form is stored, never when reading. A + key file inside the books dir is refused, and so is storing into books + kept in git (a `.git` beside them) whose `.gitignore` doesn't list + `vault/`. Without the key the vault is + locked: the books open and verify; the worksheet says the TINs are + missing. +- **Never out:** no tool output, error, or log holds more than a TIN's + last four, except `books.vendor_1099 { include_tins: true }`. The tool + arguments that take a TIN never echo it, even when refused. +- `books.set_w9 { ..., tin, tin_type, w9_file_path? }` (above): validates + the TIN against its type (nine digits, hyphens allowed; an ITIN starts + with 9 and has an IRS group in digits 4-5; an SSN or EIN doesn't start + with 9), checks the facts on a copy of the ledger, then writes the vault + and journals the facts with `vault_record`, the SHA-256 of the TIN + record (of the form's record when only a form is given). The form (PDF, + PNG, JPEG; 25 MB at most) goes to the vault only, never the attachment + store. Facts given later without a TIN keep the vault record when their + last four agree with the vault's TIN, and are refused when they don't + (pass the new TIN, or purge). Clearing the facts (`clear: true`) is + refused while the vault holds the party's TIN or form: purge first. +- `books.get_w9 { party, save_to }` → `{ party_id, path, file_name, + size_bytes, note }`. Writes the party's form from the vault to `save_to`: + a new file (an existing one is refused), in a folder that exists, + outside the books dir, mode 0600. Returns the path, never the contents. + Journals nothing. +- `books.purge_tin { party, reason, confirmed? }` → `{ status: "preview" | + "purged", party_id, name, tin_in_vault, w9_file_in_vault, tin_last4?, + journaled, note }`. Without `confirmed`, a preview that changes nothing. + Confirmed, deletes the party's vault records (needs no key) and, when the + party has W-9 facts, journals `party.w9_set` with `vault_record` cleared, + the last four kept, and `kept_at` saying when and why it was purged. + Refused when the reason is blank or there is nothing to purge. + +## Tools — attachments + +A stored file (a receipt or a document) links to any number of subjects; +a subject is `{ kind, id? }`: `entry` (id), `account` (code or id), +`asset` (tag or id), `party` (id or name), `invoice`, `bill`, `period`, +`payroll_run`, `schedule`, `reconciliation` (ids), or `company` (no id). +Every subject is checked to exist. In outputs a subject is `{ kind, id?, +label }` (`id` absent for the company; for an account, its id, with the +code in `label`). A file linked to an entry is one of the entry's receipts: +the receipt policy counts it. + +- `books.link_attachment { attachment_id, subject }` → `{ attachment_id, + links }`. Journaled as `attachment.linked`. Refused for an unknown or + withdrawn attachment, a subject that doesn't exist, or one it is linked + to already (or filed on). +- `books.unlink_attachment { attachment_id, subject, reason }` → `{ + attachment_id, links }`. Journaled as `attachment.unlinked` with the + reason; the file stays filed. Refused when the reason is blank, it isn't + linked there, it is filed on that entry (`books.unlink_receipt`), it is + a posted entry's last receipt under the receipt policy + (`receipt_required`), or it is the statement of a reconciliation not + voided. +- `books.attachments { subject }` → `{ subject, attachments: [...] }`: the + files on a subject (an entry's: filed on it and linked to it), not + withdrawn, each as in `books.list_receipts`. +- `link_to: [subject]` on `books.attach_receipt` and `books.file_document` + links the new file in the same call, each link its own + `attachment.linked`. All or nothing: every subject is checked (exists, + named once, not the entry it is filed on) before anything is filed. + ## Tools — receipts & documents -- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64 }` - → `{ receipt_id, sha256, deduplicated, size_bytes }`. `entry_id` is - optional: a receipt can be filed before its entry exists. +- `books.attach_receipt { entry_id?, file_name, mime_type, content_base64, + description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links + }`. `entry_id` is optional: a receipt can be filed before its entry + exists. `link_to` as in "Tools — attachments". - `books.link_receipt { receipt_id, entry_id }` → link a previously filed (unlinked, not withdrawn) receipt to its entry. A receipt linked elsewhere must be unlinked first. @@ -403,13 +501,19 @@ include vendor and customer when it has those records. blob store. Nothing in the evidence trail is ever erased — receipts change by new events, the same way entries change by reversals. - `books.list_receipts { entry_id?, include_withdrawn? }` → metadata list - (no bytes), each with `withdrawn` / `withdrawn_reason`. Withdrawn - attachments are omitted unless `include_withdrawn` is true. -- `books.get_receipt { receipt_id }` → `{ metadata, content_base64 }`. + (no bytes), each with `withdrawn` / `withdrawn_reason` and `links` (every + subject it is linked to, its filing entry first). Withdrawn attachments + are omitted unless `include_withdrawn` is true. `entry_id` lists the + files filed on the entry and linked to it. +- `books.get_receipt { attachment_id }` → `{ metadata, description?, + links, content_base64 }`. - `books.file_document { category: "formation" | "legal" | "tax" | - "accounting" | "other", file_name, mime_type, content_base64, - description? }` → `{ document_id, sha256, deduplicated }`. The Drive-folder - replacement (today's 01 Formation / 02 Legal / 03 Tax / 04 Accounting). + "accounting" | "statement" | "contract" | "photo" | "warranty" | + "import_source" | "other", file_name, mime_type, content_base64, + description?, link_to? }` → `{ attachment_id, sha256, deduplicated, links + }`. The Drive-folder replacement (today's 01 Formation / 02 Legal / 03 + Tax / 04 Accounting). Never a W-9: it shows the full TIN (`books.set_w9 + w9_file_path`). ## Receipt enforcement @@ -512,8 +616,12 @@ posting. ## Tools — import - `books.import_bank_csv { csv_text, bank_account_code, default_offset_code, - rules? }` → creates one draft per transaction (QBO upload layout). - `rules` maps description substrings to account codes. + rules?, date_order?, source_attachment_id? }` → creates one draft per + transaction (QBO upload layout). `rules` maps description substrings to + account codes. `source_attachment_id` (the export as filed, category + `import_source`) is checked before any draft and linked to the bank + account (`source_linked`; false when it was already), never to the + drafts: a bank export is not a receipt. - `books.import_chart_csv { csv_text }` → creates accounts from `code,name,type,parent_code` CSV. All-or-nothing. - `books.import_local_file { path, category: "receipt" | "document", ... }` @@ -590,11 +698,38 @@ R-FUTURE-3 and rides the engine roadmap, not the MCP layer. - `books.mark_cleared { entry_id, line_index, cleared }` → `{ ok }`. Only posted lines can be marked; the flag is workflow metadata, not accounting data (posted entries stay immutable). -- `books.reconcile { account, statement_date, statement_balance }` → - `{ account_code, book_balance, cleared_balance, statement_balance, - difference, uncleared_lines: [{ entry_id, line_index, date, description, - side, amount }] }`. `difference` is cleared − statement; zero means - reconciled. +- `books.record_statement { attachment_id, account, period_start, + period_end, opening_balance, closing_balance }` → the statement `{ + attachment_id, file_name, account_code, account_name, period_start, + period_end, opening_balance, closing_balance, reconciliation_id? }`. + Records what a filed statement says (balances as printed, in the + account's normal direction, cents at most) and links it to the account. + Journaled as `statement.recorded` then `attachment.linked`. Refused for + an unknown or withdrawn attachment, one already recording a statement, + an account that isn't an asset or liability, or a period that runs + backwards. +- `books.statements { account? }` → `{ statements }`, by statement date. +- `books.reconcile { statement_attachment? | account, statement_date, + statement_balance, confirmed? }` → `{ status: "preview" | "reconciled", + account_code, statement_date, statement_attachment?, book_balance, + cleared_balance, statement_balance, difference, uncleared_lines, + cleared_lines, problems, reconciliation? }`, each line `{ entry_id, + line_index, date, description, side, amount }`. `difference` is cleared + − statement; zero means reconciled. With `statement_attachment` the + account, date, and balance are the recorded statement's (any given must + agree). Without `confirmed: true` it is a preview that records nothing; + `problems` lists why a confirmation would be refused. Confirmed, it + needs a recorded statement, a difference of exactly zero, and a date + after the account's last reconciliation not voided; it then journals + `reconciliation.completed` (the cleared lines, the statement) and links + the statement to it, returning `reconciliation`. +- `books.reconciliations { account? }` → `{ reconciliations: [{ + reconciliation_id, account_code, account_name, statement_date, + statement_balance, statement_attachment, cleared: [{ entry_id, + line_index }], voided, voided_reason? }] }`, voided ones included. +- `books.void_reconciliation { reconciliation_id, reason }` → the + reconciliation, voided. Journaled as `reconciliation.voided`; the + account can be reconciled through that date again. ## Tools — A/R and A/P diff --git a/docs/parties-and-attachments.md b/docs/parties-and-attachments.md index f6feb4a..c98486a 100644 --- a/docs/parties-and-attachments.md +++ b/docs/parties-and-attachments.md @@ -106,6 +106,13 @@ bill, payroll run, documents, and history. Members link to their capital accounts. Global search finds parties. Needs-attention: a contractor paid without W-9 facts; a 1099 party paid over the threshold. +Money with a party (Stephen, 2026-10-15: "how much have I spent on Apple +versus how much did I get back"): paid out, received in, and net, for a +chosen period (this year, last year, any year, all time), from +`ledger_core::parties::activity`; what it was for (the non-money lines by +account); a monthly out-vs-in chart; the 1099 amount for the calendar +year. The directory shows the same columns, sortable. + ## The TIN vault Decision (Stephen, 2026-10-15): wanderbooks holds full TINs, so the 1099 @@ -138,6 +145,21 @@ worksheet it hands the CPA is complete, but never in the journal. contains a nine-digit run. - **Books without a vault** (another machine, a restored backup) still open and verify; the 1099 worksheet then says which TINs are missing. +- **Tools** (built; `docs/mcp-interface.md` "Tools — the TIN vault"): + `books.set_w9` takes `tin` + `tin_type` and `w9_file_path` (the form + goes to the vault, never the attachment store) and journals the last + four with `vault_record`; facts that disagree with the vault's TIN are + refused, and clearing facts with a vault record needs a purge first. + `books.get_w9 {party, save_to}` writes the form to a new file outside the + books dir and returns only the path. `books.purge_tin {party, reason, + confirmed?}` previews, then deletes the records (no key needed) and + journals the purge as `party.w9_set` with `vault_record` cleared and + `kept_at` saying when and why. `books.vendor_1099 {include_tins: true}` + is the one output with full TINs (with a warning; `save_csv_to` writes + the CSV outside the books dir). A key file inside the books dir is + refused. `tests/spec_vault.rs` scans every tool response, the server's + log, and every file in the books dir outside `vault/` for the TINs in + any formatting. ## Attachments @@ -158,6 +180,14 @@ that entry). A receipt is an attachment linked to an entry; the receipt policy counts links to the entry, as now. A file stored once can be linked to many subjects (no more refiling). +Tools (built; `docs/mcp-interface.md` "Tools — attachments"): +`books.link_attachment`, `books.unlink_attachment` (refused for a posted +entry's last receipt under the receipt policy, and for a standing +reconciliation's statement), `books.attachments {subject}`, `link_to` on +`books.attach_receipt` and `books.file_document` (all or nothing), and +`links` on every listed attachment. `books.file_document` takes the kinds +below as its category (`receipt` is `books.attach_receipt`'s). + ### Kinds `receipt`, `statement`, `contract`, `formation`, `legal`, `tax`, @@ -177,11 +207,23 @@ is never edited; a later one supersedes it; `reconciliation.voided history with its statement; Needs-attention: an account with activity not reconciled through last month's end. +Tools (built; `docs/mcp-interface.md` "Tools — bank reconciliation"): +`books.record_statement`, `books.statements`, `books.reconcile` (preview +by default; `statement_attachment` + `confirmed: true` records it), +`books.reconciliations`, `books.void_reconciliation`; `books.show +reconciliation`; `books.digest` `unreconciled_accounts` (the `[books] +cash_accounts` and liabilities with statements, not reconciled through +last month's end). The statement of a reconciliation not voided can't be +unlinked from it. + ### Import sources -`books.import_bank_csv` files the CSV (kind `import_source`), links it to -every entry it created, and records the row each came from, so every -imported line traces to its source row. +Built: file the export (`books.file_document` category `import_source`) +and pass `source_attachment_id` to `books.import_bank_csv`; it is linked +to the bank account imported into. Not to the drafts it creates: a file +linked to an entry counts as its receipt, and a bank export is not one +(DECISION, 2026-10-15, building the MCP tools). Recording the row each +draft came from is not built yet. ### Projection @@ -210,6 +252,28 @@ the digest, the viewer's Needs attention, and the session-start hook, and enforcement where a rule is a rule (tax basis at registration). The standard: do it now, completely. +### Counterparties + +Decided (Stephen, 2026-10-15): the books should be easy to make complete +without forcing one style. `[books] parties = "off" | "suggest" | +"require"`, default `"suggest"`: + +- `off`: never mention counterparties. +- `suggest`: a draft or preview without a party names the likely one + (existing parties matched against the description, the receipt's text, + the vendor), or offers to create one; `next_steps` carries the exact + call. Nothing is refused. +- `require`: posting is refused until the entry names a party or is + marked as having none (transfers, depreciation, amortization, closes + are exempt by kind). + +Also: memorized matches (journaled rules like description contains +"OPENROUTER" → OpenRouter, applied by bank-CSV import and new drafts, +reviewable and removable); a backfill punch list grouping untagged +entries by likely party for one confirmation per group; 1099 nudges (a +party given contractor or landlord, or a payment that looks like +services to an individual, asks for the role and the W-9). + ## After that: updates (check and seamless upgrade) Decided (Stephen, 2026-10-15), after the completeness engine. New versions diff --git a/docs/spec/rules.md b/docs/spec/rules.md index be96ec8..faab054 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -521,8 +521,26 @@ and the full TIN lives only in the encrypted vault, outside the journal need the employee role; payroll lines that name their employee name a party with that role. +The full TIN, when the books need it for the 1099 worksheet, lives in the +TIN vault (`vault/` in the books dir, git-ignored; encrypted under a key +file outside the books dir): `books.set_w9` `tin` writes it there and +journals only the last four and the vault record's hash; the signed W-9 +goes there too, never to the attachment store. No tool output, error, or +log holds a full TIN except `books.vendor_1099` with `include_tins` +(DECISION, Stephen, 2026-10-15: for the CPA, when asked); files holding +one (`books.get_w9`, `save_csv_to`) are written only outside the books +dir. `books.purge_tin` deletes a party's vault records and journals that +it did. + **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`. +`set_party_w9`, `set_party_w4`, `set_entry_party`, `validate_payroll`; +`ledger-vault` (Tin::new, the encrypted records, records_held, +purge_records); `ledger-mcp` `books.set_w9`, `books.get_w9`, +`books.purge_tin`, `books.vendor_1099` (open_vault refuses a key file in +the books dir; outside_books for files with a TIN). **Checked:** `ledger-core` `tests/spec_parties.rs`; `ledger-store` -`tests/parties.rs`; `ledger-mcp` `tests/spec_parties.rs`. +`tests/parties.rs`; `ledger-vault` `tests/vault.rs`; `ledger-mcp` +`tests/spec_parties.rs`, `tests/spec_vault.rs` (every response, the log, +and every file in the books dir outside `vault/` scanned for the TINs in +any formatting).