From 0cf2f8f5c5db3c7f455abc023eec7be2b138fc2b Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Mon, 26 Oct 2026 20:26:12 -0600 Subject: [PATCH] feat(package): one attention engine over PackageBooks (U0b) Needs attention was computed by the viewer alone, while books.start hand-picked a handful of next steps. The human's list and the agent's could disagree, and a new item had to be written twice (or, as books.start showed, not at all). Move the engine to ledger-package over PackageBooks: one function, find/find_with(books, today, settings, integrity), reads the ledger, the journal's event types (an entry's origin), the attachments and their links, the statements, the reconciliations, and the database check. The viewer's attention module now only maps Target to Route; books.start turns the same items into its next steps (why = the item's title, top 12), so the list the human sees and the steps the agent gets cannot drift. PackageBooks gains settings, journal, statements, reconciliations and disk_check, implemented for Store and Snapshot, so both views answer alike. The W-9, reconciliation and 1099 predicates move beside the items to keep one copy. ledger-package gains ledger-config and serde_json (the receipt policy and the tool arguments the items carry). Tests: view_model.rs and spec_kept.rs pass unchanged; the new attention_parity.rs compares the viewer and books.start item for item on the demo books and on a book written entry by entry. just check green. --- Cargo.lock | 2 + crates/ledger-cli/tests/attention_parity.rs | 162 ++ crates/ledger-gui/src/attention.rs | 970 ++--------- crates/ledger-gui/src/books.rs | 9 +- crates/ledger-gui/src/pages/parties.rs | 25 +- crates/ledger-gui/src/pages/reconciliation.rs | 11 +- crates/ledger-mcp/src/tools/kept.rs | 37 + crates/ledger-package/Cargo.toml | 2 + crates/ledger-package/src/attention.rs | 1515 +++++++++++++++++ crates/ledger-package/src/books.rs | 70 +- crates/ledger-package/src/lib.rs | 5 + crates/ledger-package/tests/package.rs | 11 + docs/architecture.md | 14 + docs/mcp-interface.md | 7 +- docs/parity.md | 10 + docs/testing.md | 47 + plan/served.md | 24 +- 17 files changed, 2026 insertions(+), 895 deletions(-) create mode 100644 crates/ledger-cli/tests/attention_parity.rs create mode 100644 crates/ledger-package/src/attention.rs diff --git a/Cargo.lock b/Cargo.lock index 854e76d..2b0cad1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4042,12 +4042,14 @@ name = "ledger-package" version = "0.1.0" dependencies = [ "chrono", + "ledger-config", "ledger-core", "ledger-import", "ledger-store", "ledger-vault", "proptest", "rust_decimal", + "serde_json", "tempfile", "toml 1.1.8+spec-1.1.0", "zip 9.0.2", diff --git a/crates/ledger-cli/tests/attention_parity.rs b/crates/ledger-cli/tests/attention_parity.rs new file mode 100644 index 0000000..9ae3c8d --- /dev/null +++ b/crates/ledger-cli/tests/attention_parity.rs @@ -0,0 +1,162 @@ +//! The viewer's "Needs attention" and `books.start`'s next steps are one +//! list: both come from `ledger_package::attention`, the viewer over its +//! `Snapshot`, the server over its `Store`. This drives both on the same +//! books (the demo company, and a book written entry by entry) and compares +//! them item for item. +#![cfg(feature = "gui")] + +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_gui::{Source, data}; +use rust_decimal::Decimal; +use serde_json::json; + +mod common; + +const SETTINGS: &str = "[company]\nname = \"Attention Parity LLC\"\nfiscal_year_start = \"01-01\"\n\ + [books]\nreceipt_threshold = \"0.00\"\ncash_accounts = [\"1000\"]\nparties = \"require\"\n"; + +fn usd(cents: i64) -> Money { + Money::new(Decimal::new(cents, 2), Currency::USD) +} + +/// The items the viewer draws and the next steps the server lists: the +/// server's attention steps are the ones whose `why` is one of the viewer's +/// titles, and they must be the same titles, in the same order. The viewer +/// has at most 12 (books.start lists the top 12). +fn compare(place: &common::Place, wire: &mut common::Wire, today: NaiveDate) { + let answer = common::answer_of(&wire.call("books.start", json!({"focus": "daily"}))) + .unwrap_or_else(|e| panic!("books.start: {e}")); + let books = data::load_once(&Source { + data_dir: place.books_data(), + today: Some(today), + }) + .unwrap_or_else(|e| panic!("the viewer can't read the books: {e}")); + let viewer: Vec = books.attention.iter().map(|a| a.title.clone()).collect(); + assert!( + viewer.len() <= 12, + "the fixture is meant to fit books.start's 12 attention steps: {viewer:?}" + ); + let steps = answer["next_steps"] + .as_array() + .unwrap_or_else(|| panic!("no next_steps: {answer}")); + let server: Vec = steps + .iter() + .filter_map(|s| s["why"].as_str()) + .filter(|why| viewer.iter().any(|t| t == why)) + .map(str::to_string) + .collect(); + assert_eq!(server, viewer, "books.start: {answer}"); + // Item for item: severity, title, and detail are the viewer's own; the + // prompt names the books and says what to do. + for (a, title) in books.attention.iter().zip(&viewer) { + assert_eq!(&a.title, title); + assert!(a.prompt.contains("wanderbooks"), "{}", a.prompt); + } +} + +/// A book written entry by entry, pseudo-randomly but deterministically (a +/// fixed seed, so a failure reproduces): a chart, three drafts, posted +/// entries without receipts, and one that names no counterparty. +fn write_random(dir: &std::path::Path, today: NaiveDate) { + let _ = std::fs::remove_dir_all(dir); + let mut st = ledger_store::Store::create(dir, Currency::USD).unwrap(); + let account = |code: &str, name: &str, ty, st: &mut ledger_store::Store| { + st.add_account(code, name, ty, None, false).unwrap() + }; + let checking = account("1000", "Checking", AccountType::Asset, &mut st); + let owners = account("2100", "Due to owners", AccountType::Liability, &mut st); + let capital = account("3000", "Capital", AccountType::Equity, &mut st); + let revenue = account("4000", "Design", AccountType::Revenue, &mut st); + let supplies = account("6100", "Supplies", AccountType::Expense, &mut st); + let contractors = account("6200", "Contractors", AccountType::Expense, &mut st); + // A member paid a cost personally: money owed to an owner. + let post = |st: &mut ledger_store::Store, day: NaiveDate, dr, cr, cents: i64| { + let e = st.create_entry(day, "Entry").unwrap(); + st.add_line(e, dr, Side::Debit, usd(cents)).unwrap(); + st.add_line(e, cr, Side::Credit, usd(cents)).unwrap(); + st.post_entry(e).unwrap(); + e + }; + post( + &mut st, + today - chrono::Days::new(40), + supplies, + checking, + 12_345, + ); + // Three drafts, alternating accounts. + for i in 0..3i64 { + let e = st + .create_entry(today - chrono::Days::new(30 - i as u64), "A draft") + .unwrap(); + st.add_line( + e, + if i % 2 == 0 { supplies } else { contractors }, + Side::Debit, + usd(5_000 + i), + ) + .unwrap(); + st.add_line(e, checking, Side::Credit, usd(5_000 + i)) + .unwrap(); + } + // Posted without receipts, one naming no counterparty. + for i in 0..4i64 { + let day = today - chrono::Days::new(20 - i as u64); + let e = st.create_entry(day, "No receipt").unwrap(); + st.add_line( + e, + if i % 2 == 0 { supplies } else { contractors }, + Side::Debit, + usd(7_000 + i), + ) + .unwrap(); + st.add_line(e, checking, Side::Credit, usd(7_000 + i)) + .unwrap(); + st.post_entry(e).unwrap(); + } + // A reverse of the first entry (its original carries the evidence). + let first = st.ledger().posted_entries().next().map(|e| e.id()).unwrap(); + st.reverse_entry(first, today - chrono::Days::new(1), "Wrong account") + .unwrap(); + // One entry with a receipt, so not every posted entry is flagged. + let e = st + .create_entry(today - chrono::Days::new(3), "With receipt") + .unwrap(); + st.add_line(e, revenue, Side::Credit, usd(50_000)).unwrap(); + st.add_line(e, checking, Side::Debit, usd(50_000)).unwrap(); + st.attach_receipt(Some(e), "r.pdf", "application/pdf", b"%PDF r", None) + .unwrap(); + st.post_entry(e).unwrap(); + let _ = (owners, capital); + drop(st); +} + +#[test] +fn the_viewer_and_books_start_see_the_same_attention() { + let today = ledger_core::settings::today_in(None); + // The demo company (ledger_gui::demo): the books the viewer ships with, + // with a few loose ends of every kind. Built in a folder of its own and + // adopted into the data root, as old books come in. + { + let place = common::Place::new("attention-parity-demo"); + let source = tempfile::Builder::new() + .prefix("wb-attention-demo-") + .tempdir() + .unwrap(); + ledger_gui::demo::build(source.path(), today).unwrap(); + std::fs::create_dir_all(place.books_data().parent().unwrap()).unwrap(); + std::fs::rename(source.path().join("books-data"), place.books_data()).unwrap(); + let mut wire = place.start(None); + wire.initialize("attention-parity"); + compare(&place, &mut wire, today); + } + // A book written entry by entry. + { + let place = common::Place::new("attention-parity-random"); + write_random(&place.books_data(), today); + let mut wire = place.start(Some(SETTINGS)); + wire.initialize("attention-parity"); + compare(&place, &mut wire, today); + } +} diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index afc44a9..e7a7980 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -1,871 +1,129 @@ -//! What the books need: drafts left unposted, missing receipts, schedules -//! and recurring entries due, open past periods, uncategorized tax lines, -//! 1099 vendors without a W-9 or without a 1099 decision, payments whose -//! method the 1099 worksheet needs, 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, 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). +//! "Needs attention", mapped for the viewer: the items come from +//! `ledger_package::attention` (the same function `books.start` reads), and +//! this module only turns each `Target` into the viewer's [`Route`] and each +//! severity into the viewer's own. +//! +//! ```text +//! GOAL: the viewer never computes an attention item itself; it draws the +//! ones the shared engine found, so the list can't drift from the +//! server's next steps. +//! SOURCE: plan/served.md U0b (D-53: Needs attention lives in +//! ledger-package over PackageBooks); ledger_package::attention +//! (the conditions, the prompts, the INVARIANTS). +//! INVARIANTS: +//! 1. Pure: reads `b`, writes nothing. +//! 2. One item out per item the shared engine found, in the same order, +//! with the same severity, title, detail, and prompt. +//! 3. `target` is the item's `Target` mapped to this viewer's `Route`. +//! ``` use crate::books::{Attention, Books, Severity}; -use crate::fmt; -use crate::nav::Route; -use chrono::Datelike; -use ledger_core::{AccountType, EntryStatus, PeriodStatus}; -use rust_decimal::Decimal; - -/// A figure the books can't compute (rules.md B-28: books an earlier -/// version wrote may hold dates past chrono's calendar or amounts past -/// exact arithmetic): a Problem saying so and why, never a guess. -fn cant_compute(what: String, why: &ledger_core::LedgerError, target: Option) -> Attention { - Attention { - severity: Severity::Problem, - title: format!("{what} can't be computed"), - detail: format!("{why}."), - target, - prompt: format!( - "In wanderbooks, {} can't be computed ({why}). Find the record behind it and tell me how to correct it.", - what.to_lowercase() - ), - } -} +use crate::nav::{ReportKind, Route}; +use ledger_store::{ + Attachment, DiskCheck, JournalLine, Reconciliation, Statement, StoreError, Subject, +}; /// Everything the books need, as items to show. -/// -/// ```text -/// GOAL: tell the humans what's unfinished or wrong, and hand them the -/// prompt that gets the agent to fix it; never change the books. -/// SOURCE: DECISION (owner: the viewer is read-only; its "Needs -/// attention" list offers prompts, AGENTS.md); the conditions -/// below; docs/spec/sources/irs-1099-thresholds.md (W-9s); -/// DECISION D-40 (owner, 2026-10-20: ask only about vendors paid -/// in a way that could need a 1099). -/// INVARIANTS: -/// 1. Pure: reads `b`, writes nothing. -/// 2. One item per: draft entry (Todo); posted entry missing a receipt -/// (Problem; four or more are one combined item); recurring entry or -/// schedule due through today (Todo); period ended and still open -/// (Todo); the revenue and expense accounts (not headers) with no tax -/// category, with activity or not (ledger_core::tax:: -/// uncategorized_accounts: one item; Info, Todo under -/// policy.require_tax_category = refuse); legal rule known only -/// through a past year (Todo); for -/// this calendar year, and last year in January, from -/// Books::vendor_1099 (the server's books.vendor_1099): a vendor a -/// 1099 is due for (reportable) without a W-9 (Problem); a vendor -/// paid that year in a way that could count toward a 1099 while its -/// 1099 decision was undetermined (Vendor1099Line::decision_needed: -/// Problem when its status is undetermined, else Todo, its prompt -/// naming books.set_vendor_1099); the year's payments whose method -/// decides whether they count (Vendor1099Report:: -/// undetermined_payments, none for a vendor decided not reportable), -/// one Todo counting reversal chains, its prompt naming -/// books.instruments, books.add_instrument and -/// books.set_entry_instrument; a year whose worksheet can't be made -/// for want of named cash accounts while vendors were paid in it -/// (Todo); overdue bills, and overdue -/// invoices (Problem, one each, with the total); money owed to -/// owners (Info, per account with a balance); the database differing -/// from the journal or unreadable, and damaged or missing stored -/// files (Problem); depreciation due through the last finished month -/// and not posted (Books::depreciation_due amount > 0: Todo, one -/// item); the asset register and the books' asset cost accounts -/// disagreeing today (unregistered_cost != 0: Problem); an active -/// asset in service with no tag (Todo, one per asset); an active -/// asset in service with no tax basis (Asset::tax_basis is None: -/// Todo, one per asset, its prompt naming books.set_asset_tax_basis); -/// a party not archived whose W-9 is Missing (pages::parties:: -/// w9_status: it files a 1099, has no W-9 facts, and its vendor -/// record doesn't say one is on file) and that was paid this year -/// (PartyInfo::paid_ytd > 0), -/// unless its vendor is already a 1099 vendor without a W-9 above -/// (Todo, one per party, its prompt from -/// pages::parties::collect_w9_prompt, naming books.set_w9); 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); each warning of the fiscal year's -/// statement of cash flows (rules.md B-14 rule 12: equipment sold for -/// an IOU, Todo; bought on an operating payable, Info; one per -/// entry, its detail the warning's fix). -/// 3. Nothing else: books with none of those yield no items, except -/// that a figure behind an item that can't be computed (an -/// amortization plan past chrono's last day, an aging past exact -/// arithmetic: books an earlier version wrote, rules.md B-28) is one -/// Problem item saying so, never a panic or a guess. -/// 4. Every item's prompt starts "In wanderbooks," or names -/// wanderbooks, and its amounts are the books' exact amounts. -/// ``` pub fn find(b: &Books) -> Vec { - let mut out = Vec::new(); - let today = b.today; - - for e in b.entries.iter().filter(|e| e.status == EntryStatus::Draft) { - out.push(Attention { - severity: Severity::Todo, - title: format!("Draft not posted: {}", e.description), - detail: format!( - "#{} · {} · {}. Drafts appear in no report until posted.", - e.id, - fmt::date(e.date), - fmt::dollars_or(e.amount) - ), - target: Some(Route::Entry(e.id)), - prompt: format!( - "In wanderbooks, review draft entry {} (\"{}\", {}): attach its receipt if it needs one, preview it, and post it if it's right.", - e.id, - e.description, - fmt::dollars_or(e.amount) - ), - }); - } - - let missing: Vec<_> = b - .entries - .iter() - .filter(|e| e.status == EntryStatus::Posted && e.missing_receipt) - .collect(); - if missing.len() > 3 { - let total = missing - .iter() - .try_fold(Decimal::ZERO, |t, e| t.checked_add(e.amount?)); - let list: Vec = missing - .iter() - .map(|e| format!("{} ({}, {})", e.id, e.description, e.date)) - .collect(); - out.push(Attention { - severity: Severity::Problem, - title: format!("{} without receipts", fmt::count(missing.len(), "entry", "entries")), - detail: format!( - "{} in entries with no receipt attached ({}).", - fmt::dollars_or(total), - b.receipt_rule() - ), - target: Some(Route::Transactions), - prompt: format!( - "In wanderbooks, these entries have no receipt: {}. Help me find each receipt and attach it with books.attach_receipt.", - list.join("; ") - ), - }); - } - for e in missing.iter().filter(|_| missing.len() <= 3) { - out.push(Attention { - severity: Severity::Problem, - title: format!("No receipt: {}", e.description), - detail: format!( - "#{} · {} · {}: {}.", - e.id, - fmt::date(e.date), - fmt::dollars_or(e.amount), - b.receipt_rule() - ), - target: Some(Route::Entry(e.id)), - prompt: format!( - "In wanderbooks, entry {} (\"{}\", {} on {}) has no receipt. Find it and attach it with books.attach_receipt.", - e.id, - e.description, - fmt::dollars_or(e.amount), - e.date - ), - }); - } - - for r in b.ledger.recurring_entries() { - let due = r.due(today); - if !due.is_empty() { - out.push(Attention { - severity: Severity::Todo, - title: format!("Recurring \"{}\" is due", r.description), - detail: format!( - "{} since {}.", - fmt::count(due.len(), "occurrence", "occurrences"), - fmt::date(due[0]) - ), - target: Some(Route::Company), - prompt: format!( - "In wanderbooks, run books.run_recurring through {today}, then attach each draft's receipt and post them." - ), - }); - } - } - - for s in b.ledger.schedules() { - // A schedule an earlier version journaled may not compute (its - // months past chrono's last day): say so, never guess. - let due = match s.due(today) { - Ok(due) => due, - Err(e) => { - out.push(cant_compute( - format!("Amortization schedule \"{}\"", s.description), - &e, - Some(Route::Company), - )); - continue; - } - }; - if !due.is_empty() { - let Some(total) = due - .iter() - .try_fold(Decimal::ZERO, |t, (_, a)| t.checked_add(*a)) - else { - out.push(cant_compute( - format!("Amortization schedule \"{}\"", s.description), - &ledger_core::LedgerError::TooLargeToCompute("the months due".into()), - Some(Route::Company), - )); - continue; - }; - out.push(Attention { - severity: Severity::Todo, - title: format!("Amortization due: {}", s.description), - detail: format!( - "{} totaling {} not yet posted.", - fmt::count(due.len(), "month", "months"), - fmt::dollars(total) - ), - target: Some(Route::Company), - prompt: format!( - "In wanderbooks, preview books.run_schedules through {today}, then post it if it's right." - ), - }); - } - } - - for p in b.ledger.list_periods() { - if p.status == PeriodStatus::Open && p.end < today { - out.push(Attention { - severity: Severity::Todo, - title: format!("Period {} ended but is open", p.name), - detail: format!( - "{} – {}. Closing locks it and moves its earnings to equity.", - fmt::date(p.start), - fmt::date(p.end) - ), - target: Some(Route::Company), - prompt: format!( - "In wanderbooks, review period {} and, if everything for it is recorded and reconciled, preview and close it.", - p.name - ), - }); - } - } - - // Every revenue and expense account (not a header) without a tax - // category, with activity or not (rules.md B-32): the one list the - // digest gives the agent too. - let uncategorized: Vec = ledger_core::tax::uncategorized_accounts(&b.ledger) - .into_iter() - .map(|a| format!("{} {}", a.code, a.name)) - .collect(); - if !uncategorized.is_empty() { - let refuse = b.ledger.settings().require_tax_category() - == ledger_core::settings::TaxCategoryPolicy::Refuse; - out.push(Attention { - severity: if refuse { - Severity::Todo - } else { - Severity::Info - }, - title: format!( - "{} without a tax category", - fmt::count(uncategorized.len(), "account", "accounts") - ), - detail: format!( - "{}. Each revenue and expense account maps to a line of the return (the tax summary and the CPA package list it as uncategorized until then).", - uncategorized.join(", ") - ), - target: Some(Route::Accounts), - prompt: format!( - "In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.", - uncategorized.join(", ") - ), - }); - } - - // Rules set by law that wanderbooks may no longer know (rules.md; - // owner, 2026-10-13): the agent finds the figure from the authority - // or asks, confirms it, and passes it explicitly (the handbook's - // "Rules that change with the law"); the human reports it upstream. - for rule in ledger_core::rules::stale_rules(today) { - out.push(Attention { - severity: Severity::Todo, - title: format!("Check the {}", rule.name), - detail: format!( - "wanderbooks knows it through {}. {}", - rule.known_through, rule.review_why - ), - target: None, - prompt: format!( - "In wanderbooks, the {} is known only through {}. Find the current figure from the official authority, {}, or ask me or our CPA, and confirm it with me; then pass it explicitly wherever a tool asks for it. Tell me what you found and its official source, so I can report it upstream and the rule is updated for everyone.", - rule.name, rule.known_through, rule.authority - ), - }); - } - - let mut years = vec![today.year()]; - if today.month() == 1 { - years.insert(0, today.year() - 1); - } - // Vendors the "needs a W-9" item already names this year. - let mut asked_w9 = std::collections::HashSet::new(); - for year in years { - // A year whose threshold isn't known is flagged by the stale-rule - // item above; never guess it here. - let r = match b.vendor_1099(year) { - Ok(r) => r, - Err(ledger_core::LedgerError::Invalid1099(why)) - if b.ledger.posted_entries().any(|e| { - e.date().year() == year && b.ledger.entry_vendor(e.id()).is_some() - }) => - { - out.push(Attention { - severity: Severity::Todo, - title: format!("The {year} 1099 worksheet can't be made"), - detail: format!("{why}."), - target: Some(Route::Reports(crate::nav::ReportKind::Vendor1099)), - prompt: format!( - "In wanderbooks, the {year} 1099 worksheet can't be made: {why}. Ask me which accounts hold the company's cash, then set them with books.set_setting." - ), - }); - continue; - } - Err(_) => continue, - }; - let in_year = if year == today.year() { - String::new() - } else { - format!(" for {year}") - }; - for v in r.vendors.iter().filter(|v| v.decision_needed) { - let party = b.ledger.party_of_vendor(v.vendor_id); - let unknown = if v.undetermined > Decimal::ZERO { - format!( - " and {} more whose payment method isn't recorded", - fmt::dollars(v.undetermined) - ) - } else { - String::new() - }; - out.push(Attention { - severity: if v.status == ledger_core::Status1099::Undetermined { - Severity::Problem - } else { - Severity::Todo - }, - title: format!("Decide whether {} gets a 1099{in_year}", v.name), - detail: format!( - "Paid {} by check, ACH, transfer or cash{unknown} in {year}, with no 1099 decision.", - fmt::dollars(v.paid) - ), - target: Some(party.map_or(Route::Company, Route::Party)), - prompt: format!( - "In wanderbooks, {} was paid {}{unknown} in {year} and has no 1099 decision. Ask me whether it gets a 1099-NEC, a 1099-MISC, or none and why (a corporation, the United States or a state, a payee selling only merchandise, ...); then record it with books.set_vendor_1099, with who decided and when.", - v.name, - fmt::dollars(v.paid) - ), - }); - } - // Payments whose method decides whether they count, a payment - // and its reversals counted once. - let mut chains: Vec = r - .undetermined_payments - .iter() - .map(|p| b.ledger.reversal_chain(p.entry_id)[0]) - .collect(); - chains.sort_unstable(); - chains.dedup(); - if !chains.is_empty() { - let n = chains.len(); - let ids = chains - .iter() - .map(|e| e.to_string()) - .collect::>() - .join(", "); - let (payments, entries) = if n == 1 { - ("payment has", "entry") - } else { - ("payments have", "entries") - }; - out.push(Attention { - severity: Severity::Todo, - title: format!("{n} {payments} no payment method{in_year}"), - detail: format!( - "Paid for the company through a financing account in {year} ({entries} {ids}): a member's card doesn't count toward a 1099, their cash or check does, so the 1099 worksheet can't tell until each is recorded." - ), - target: Some(Route::Reports(crate::nav::ReportKind::Vendor1099)), - prompt: format!( - "In wanderbooks, {entries} {ids} paid a vendor in {year} through a financing account with no payment method recorded, so the 1099 worksheet can't tell whether they count. Ask me how each was paid (whose card, or cash, check or a transfer); add any method not yet in books.instruments with books.add_instrument, then tag each entry with books.set_entry_instrument." - ), - }); - } - for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) { - if year == today.year() { - asked_w9.insert(v.vendor_id); - } - let party = b.ledger.party_of_vendor(v.vendor_id); - out.push(Attention { - severity: Severity::Problem, - title: format!("{} needs a W-9", v.name), - detail: format!( - "Paid {} in {year}, over the {} 1099 threshold.", - fmt::dollars(v.paid), - fmt::dollars(r.threshold) - ), - target: Some(party.map_or(Route::Company, Route::Party)), - prompt: format!( - "Remind me to request a W-9 from {}; once I have it, record what it says in wanderbooks with books.set_w9 (that marks it on file and keeps the 1099 decision).", - v.name - ), - }); - } - } - - for p in b.ledger.parties().filter(|p| { - !p.archived - && crate::pages::parties::w9_status(b, p) == crate::pages::parties::W9Status::Missing - && !p.vendor.is_some_and(|v| asked_w9.contains(&v)) - }) { - let Some(info) = b.party(p.id).filter(|i| i.paid_ytd > Decimal::ZERO) else { - continue; - }; - out.push(Attention { - severity: Severity::Todo, - title: format!("Collect a W-9 from {}", p.label()), - detail: format!( - "Paid {} in {} as {}, with no W-9 facts recorded: collect a W-9 before the next payment.", - fmt::dollars(info.paid_ytd), - today.year(), - if p.roles.contains(&ledger_core::Role::Landlord) - && !p.roles.contains(&ledger_core::Role::Contractor) - { - "a landlord" - } else { - "a contractor" - } - ), - target: Some(Route::Party(p.id)), - prompt: crate::pages::parties::collect_w9_prompt(b, p), - }); - } - - // Overdue: (parties, total), or why it can't be computed (documents - // are restored as journaled, of any size). - let overdue = |rows: Result, ledger_core::LedgerError>| { - let too_large = || ledger_core::LedgerError::TooLargeToCompute("the overdue total".into()); - let late: Vec = rows? - .iter() - .map(|r| { - [r.days_60, r.days_90, r.days_90_plus] - .into_iter() - .try_fold(r.days_30, Decimal::checked_add) - .ok_or_else(too_large) - }) - .collect::, _>>()? - .into_iter() - .filter(|d| !d.is_zero()) - .collect(); - let total = late - .iter() - .try_fold(Decimal::ZERO, |t, d| t.checked_add(*d)) - .ok_or_else(too_large)?; - Ok::<_, ledger_core::LedgerError>((late.len(), total)) - }; - let (n, total) = match overdue(b.ledger.ap_aging(today)) { - Ok(v) => v, - Err(e) => { - out.push(cant_compute( - "The payables aging".into(), - &e, - Some(Route::Reports(crate::nav::ReportKind::ApAging)), - )); - (0, Decimal::ZERO) - } - }; - if n > 0 { - out.push(Attention { - severity: Severity::Problem, - title: format!("{} overdue to pay", fmt::dollars(total)), - detail: format!( - "{} with bills past due.", - fmt::count(n, "vendor", "vendors") - ), - target: Some(Route::Reports(crate::nav::ReportKind::ApAging)), - prompt: "In wanderbooks, show me the A/P aging and which bills are past due.".into(), - }); - } - let (n, total) = match overdue(b.ledger.ar_aging(today)) { - Ok(v) => v, - Err(e) => { - out.push(cant_compute( - "The receivables aging".into(), - &e, - Some(Route::Reports(crate::nav::ReportKind::ArAging)), - )); - (0, Decimal::ZERO) - } - }; - if n > 0 { - out.push(Attention { - severity: Severity::Problem, - title: format!("{} overdue to collect", fmt::dollars(total)), - detail: format!( - "{} with invoices past due.", - fmt::count(n, "customer", "customers") - ), - target: Some(Route::Reports(crate::nav::ReportKind::ArAging)), - prompt: - "In wanderbooks, show me the A/R aging and draft reminders for past-due invoices." - .into(), - }); - } - - for a in b.ledger.chart().all().filter(|a| { - a.account_type == AccountType::Liability && { - let n = a.name.to_lowercase(); - n.contains("due to") || n.contains("owed to") - } - }) { - let bal = b.balance_on(a.id, today); - if bal > Decimal::ZERO { - out.push(Attention { - severity: Severity::Info, - title: format!("{} owed on {}", fmt::dollars(bal), a.name), - detail: "Expenses someone paid personally, not yet reimbursed.".into(), - target: Some(Route::Account(a.id)), - prompt: format!( - "In wanderbooks, list what makes up the {} balance on {} {} and who is owed what.", - fmt::dollars(bal), - a.code, - a.name - ), - }); - } - } - - 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.parties.is_empty() - && 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("; ") - ), - }); - } - } - - // The register computes unless a contributor basis an earlier version - // journaled is past exact arithmetic: then one item says so. - let (due, register) = match (b.depreciation_due(today), b.asset_register(today)) { - (Ok(due), Ok(register)) => (Some(due), Some(register)), - (Err(e), _) | (_, Err(e)) => { - out.push(cant_compute( - "The fixed asset register".into(), - &e, - Some(Route::Reports(crate::nav::ReportKind::FixedAssetRegister)), - )); - (None, None) - } - }; - if let Some(due) = due.filter(|d| d.amount > Decimal::ZERO) { - let months = if due.periods.is_empty() { - "every month not yet posted".to_string() - } else { - due.periods.join(", ") - }; - out.push(Attention { - severity: Severity::Todo, - title: format!("Depreciation not posted through {}", fmt::date(due.through)), - detail: format!( - "{} scheduled and not yet in the books ({}).", - fmt::dollars(due.amount), - months - ), - target: Some(Route::Assets), - prompt: format!( - "In wanderbooks, run books.run_depreciation for {months} (depreciation due through {} and not posted: {}), then show me the fixed asset register.", - due.through, - fmt::dollars(due.amount) - ), - }); - } - // Accumulated depreciation no asset's runs account for: a hand entry, - // or a run reversed after its asset left the books. - let unattributed = register - .as_ref() - .map_or(Decimal::ZERO, |r| r.unattributed_accumulated()); - if !unattributed.is_zero() { - out.push(Attention { - severity: Severity::Problem, - title: format!( - "{} of accumulated depreciation not from any asset's runs", - fmt::dollars(unattributed.abs()) - ), - detail: "The accumulated depreciation accounts hold depreciation (or take it back) that no registered asset's depreciation run accounts for: an entry made by hand, or a run reversed after its asset was disposed.".into(), - target: Some(Route::Assets), - prompt: format!( - "In wanderbooks, the accumulated depreciation accounts differ by {} from what the assets' depreciation runs put there (books.export fixed_asset_register, unattributed_accumulated). Find the entries behind it in those accounts' registers and tell me how to fix them.", - fmt::dollars(unattributed) - ), - }); - } - let unregistered = register.map_or(Decimal::ZERO, |r| r.unregistered_cost()); - if !unregistered.is_zero() { - let (title, why) = if unregistered > Decimal::ZERO { - ( - format!( - "{} of equipment not in the asset register", - fmt::dollars(unregistered) - ), - "The asset cost accounts hold more than the registered assets cost: something bought was never registered.", - ) - } else { - ( - format!( - "{} of registered assets not in the books", - fmt::dollars(-unregistered) - ), - "Registered assets cost more than their cost accounts hold: a purchase entry may be missing.", - ) - }; - out.push(Attention { - severity: Severity::Problem, - title, - detail: why.into(), - target: Some(Route::Assets), - prompt: format!( - "In wanderbooks, the fixed asset register and the asset cost accounts differ by {} today. Find what's behind it (books.list_assets and the cost accounts' registers) and tell me how to fix it.", - fmt::dollars(unregistered) - ), - }); - } - let held = |a: &&ledger_core::Asset| { - a.status == ledger_core::AssetStatus::Active - && b.ledger - .asset_acquired_date(a.id) - .is_some_and(|d| d <= today) - }; - for a in b - .ledger - .registered_assets() - .filter(held) - .filter(|a| a.tag.is_none()) - { - out.push(Attention { - severity: Severity::Todo, - title: format!("No tag: {}", a.description), - detail: format!( - "Asset {}, in service {}. A tag is the label on the thing itself.", - a.id, - fmt::date(a.in_service_date) - ), - target: Some(Route::Asset(a.id)), - prompt: format!( - "In wanderbooks, label asset {} (\"{}\") with books.label_asset: give it the next generated tag unless I have my own, and its serial number if I can find it. Then print its label with books.asset_labels.", - a.id, a.description - ), - }); - } - - for a in b.ledger.registered_assets().filter(held) { - let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id)); - let Some(basis) = &a.basis else { - out.push(Attention { - severity: Severity::Todo, - title: format!("No tax basis: {}", b.asset_label(a.id)), - detail: format!( - "Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.", - fmt::dollars(a.cost.amount), - fmt::date(a.in_service_date) - ), - target: Some(Route::Asset(a.id)), - prompt: format!( - "In wanderbooks, record the tax basis of {name} (\"{}\", book cost {}) with books.set_asset_tax_basis. Ask me how the company got it: bought (purchase), contributed by a member from business use (carryover_723, IRC §723: the member's adjusted basis), or contributed by a member from personal use (personal_use_conversion, Treas. Reg. §1.168(i)-4(b): the member's adjusted basis, depreciated from the lesser of that and its fair market value); for a contribution, its fair market value when contributed, and who says so. Then show me the asset.", - a.description, - fmt::dollars(a.cost.amount) - ), - }); - continue; - }; - if !basis.rule.is_contribution() { - continue; - } - if !a.fmv_is_recorded() { - out.push(Attention { - severity: Severity::Todo, - title: format!("Confirm its value at contribution: {}", b.asset_label(a.id)), - detail: format!( - "Its basis was recorded before the value at contribution was asked, so the books read it as the book cost, {}. The §704(c) built-in gain or loss rests on it.", - fmt::dollars(a.cost.amount) - ), - target: Some(Route::Asset(a.id)), - prompt: format!( - "In wanderbooks, {name} (\"{}\") was contributed with its value at contribution not recorded; the books read it as its book cost, {}. Ask me what it was worth when contributed, then record it with books.set_asset_tax_basis (rule {}, contributor_adjusted_basis {}, fmv_at_contribution, and basis_source: who says so, citing the contribution entry). Then show me the asset.", - a.description, - fmt::dollars(a.cost.amount), - basis.rule.key(), - basis - .contributor_adjusted_basis - .map(|v| v.to_string()) - .unwrap_or_default() - ), - }); - } - if a.built_in_gain_704c().is_some_and(|g| !g.is_zero()) && basis.sec704c_method.is_none() { - out.push(Attention { - severity: Severity::Todo, - title: format!("Choose a §704(c) method with the CPA: {}", b.asset_label(a.id)), - detail: "It was contributed with a built-in gain or loss, and no §704(c) method (traditional, traditional with curative allocations, or remedial) is recorded for it.".into(), - target: Some(Route::Asset(a.id)), - prompt: format!( - "In wanderbooks, {name} (\"{}\") has a §704(c) built-in gain or loss and no §704(c) method. Ask me which method the CPA and I chose (traditional, traditional_with_curative, or remedial: Treas. Reg. §1.704-3(b)-(d)), then record it with books.set_asset_tax_basis, keeping its other facts as they are. Then show me the asset.", - a.description - ), - }); - } - } - - use ledger_store::DiskCheck; - let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string(); - match &b.integrity.disk { - DiskCheck::Differs(why) => out.push(Attention { - severity: Severity::Problem, - title: "The database doesn't match the journal".into(), - detail: format!("{why}. The journal is the record; the server rebuilds the database from it (books.verify, or on its next start)."), - target: Some(Route::Journal), - prompt: verify_prompt.clone(), - }), - DiskCheck::Unreadable(why) => out.push(Attention { - severity: Severity::Problem, - title: "Couldn't check the database".into(), - detail: format!("{why}."), - target: Some(Route::Journal), - prompt: verify_prompt.clone(), - }), - _ => {} - } - if !b.integrity.file_failures.is_empty() { - let ids: Vec = b - .integrity - .file_failures - .iter() - .map(|(id, _)| id.to_string()) - .collect(); - out.push(Attention { - severity: Severity::Problem, - title: format!("{} damaged or missing", fmt::count(ids.len(), "stored file is", "stored files are")), - detail: format!( - "Attachment {}: the bytes on disk don't match their recorded hash. Restore them from a backup (the books dir's git history).", - ids.join(", ") - ), - target: Some(Route::Documents), - prompt: verify_prompt, - }); - } - // The statement of cash flows' warnings this fiscal year (rules.md - // B-14 rule 12, D-35): equipment sold for an IOU, flagged with its fix - // (Todo); equipment bought on an operating payable, following how the - // payable is classified (Info). A statement the settings can't give - // (no cash accounts yet) has none. let settings = ledger_package::Settings { company: &b.settings.company, cash_accounts: &b.settings.cash_accounts, financing_accounts: &b.settings.financing_accounts, investing_accounts: &b.settings.investing_accounts, }; - let (fy_start, _) = b.fiscal_ytd(); - if let Ok(cf) = ledger_package::cash_flow_for( - &b.ledger, - &settings, - Some(&fy_start.to_string()), - Some(&today.to_string()), - &[], - &[], - &[], - today, - ) { - for w in cf.warnings { - let sold = w.kind == ledger_core::CashFlowWarningKind::AssetSoldOnOperatingReceivable; - out.push(Attention { - severity: if sold { Severity::Todo } else { Severity::Info }, - title: if sold { - format!("Equipment sold for an IOU: entry #{}", w.entry) - } else { - format!("Equipment bought on an operating payable: entry #{}", w.entry) - }, - detail: w.message.clone(), - target: Some(Route::Entry(w.entry)), - prompt: format!( - "In wanderbooks, the cash flow statement flags entry {}: {} Show me the entry and what you would change, and change nothing until I agree.", - w.entry, w.message - ), - }); - } + let integrity = ledger_package::Integrity { + disk: b.integrity.disk.clone(), + file_failures: b.integrity.file_failures.clone(), + }; + ledger_package::find_with(&View(b), b.today, &settings, &integrity) + .into_iter() + .map(|a| Attention { + severity: severity(a.severity), + title: a.title, + detail: a.detail, + target: route(a.target), + prompt: a.prompt, + }) + .collect() +} + +fn severity(s: ledger_package::Severity) -> Severity { + match s { + ledger_package::Severity::Problem => Severity::Problem, + ledger_package::Severity::Todo => Severity::Todo, + ledger_package::Severity::Info => Severity::Info, + } +} + +fn route(t: ledger_package::Target) -> Option { + Some(match t { + ledger_package::Target::None => return None, + ledger_package::Target::Entry(e) => Route::Entry(e), + ledger_package::Target::Transactions => Route::Transactions, + ledger_package::Target::Company => Route::Company, + ledger_package::Target::Accounts => Route::Accounts, + ledger_package::Target::Account(a) => Route::Account(a), + ledger_package::Target::Party(p) => Route::Party(p), + ledger_package::Target::Assets => Route::Assets, + ledger_package::Target::Asset(a) => Route::Asset(a), + ledger_package::Target::Documents => Route::Documents, + ledger_package::Target::Journal => Route::Journal, + ledger_package::Target::Report(r) => Route::Reports(match r { + ledger_package::Report::Vendor1099 => ReportKind::Vendor1099, + ledger_package::Report::ApAging => ReportKind::ApAging, + ledger_package::Report::ArAging => ReportKind::ArAging, + ledger_package::Report::FixedAssetRegister => ReportKind::FixedAssetRegister, + }), + }) +} + +/// The view model seen through [`PackageBooks`]: the snapshot's own state, +/// already read, handed to the shared engine without reading the books +/// again. +struct View<'a>(&'a Books); + +impl ledger_package::PackageBooks for View<'_> { + fn ledger(&self) -> &ledger_core::Ledger { + &self.0.ledger + } + + fn journal_seq(&self) -> u64 { + self.0.journal.len() as u64 + } + + fn journal(&self) -> Result, StoreError> { + Ok(self.0.journal.clone()) + } + + fn all_attachments(&self) -> Result, StoreError> { + Ok(self.0.attachments.clone()) + } + + fn links_of(&self, attachment_id: u64) -> Result, StoreError> { + Ok(self.0.links_of(attachment_id).to_vec()) + } + + fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError> { + let a = self + .0 + .attachment(attachment_id) + .ok_or_else(|| StoreError::Io(std::io::Error::other("no such attachment")))?; + self.0 + .attachment_bytes(a) + .map_err(|e| StoreError::Io(std::io::Error::other(e))) + } + + fn statements(&self) -> Result, StoreError> { + Ok(self.0.statements.clone()) + } + + fn reconciliations(&self) -> Result, StoreError> { + Ok(self.0.reconciliations.clone()) + } + + fn disk_check(&self) -> DiskCheck { + self.0.integrity.disk.clone() } - out.sort_by_key(|a| a.severity); - out } #[cfg(test)] diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 047e39b..4fe24d8 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -1057,12 +1057,11 @@ impl Books { /// Whether a party files a 1099: it has a 1099 role (contractor, /// landlord), or its vendor record has been decided for one (from any - /// date: its W-9 matters for those payments). + /// date: its W-9 matters for those payments). The shared rule + /// (ledger_package::attention::files_1099), so the viewer's W-9 column + /// and the attention items say the same. pub fn files_1099(&self, p: &Party) -> bool { - p.roles.iter().any(|r| r.gets_1099()) - || p.vendor - .and_then(|v| self.ledger.get_vendor(v)) - .is_some_and(|v| v.decisions_1099.iter().any(|d| d.decision.form().is_some())) + ledger_package::files_1099(&self.ledger, p) } /// The 1099 report for calendar `year`, the server's diff --git a/crates/ledger-gui/src/pages/parties.rs b/crates/ledger-gui/src/pages/parties.rs index 0c61f7c..4c57359 100644 --- a/crates/ledger-gui/src/pages/parties.rs +++ b/crates/ledger-gui/src/pages/parties.rs @@ -237,29 +237,12 @@ pub fn w9_status(b: &Books, p: &Party) -> W9Status { } } -/// The 1099 role a party files under, in words ("contractor", -/// "landlord"), for prompts. -fn form_role(p: &Party) -> &'static str { - if p.roles.contains(&Role::Landlord) && !p.roles.contains(&Role::Contractor) { - "landlord" - } else { - "contractor" - } -} - /// The agent prompt that collects and records a party's W-9. pub fn collect_w9_prompt(b: &Books, p: &Party) -> String { - let paid = b.party(p.id).map(|i| i.paid_ytd).unwrap_or_default(); - let paid = if paid > Decimal::ZERO { - format!(", paid {} so far in {}", fmt::dollars(paid), b.today.year()) - } else { - String::new() - }; - format!( - "In wanderbooks, {} (party {}) is a {}{paid} with no W-9 facts recorded. Remind me to collect a W-9 before the next payment. Once I have it, record what it says with books.set_w9: the date received, the entity type (line 3), any exempt payee or FATCA codes (line 4), the last four digits of the TIN only (the full TIN goes in the vault, never the books), and where the form is kept. Then show me the party.", - p.label(), - p.id, - form_role(p) + ledger_package::w9_prompt( + p, + b.party(p.id).map(|i| i.paid_ytd).unwrap_or_default(), + b.today.year(), ) } diff --git a/crates/ledger-gui/src/pages/reconciliation.rs b/crates/ledger-gui/src/pages/reconciliation.rs index fa28502..fd6173e 100644 --- a/crates/ledger-gui/src/pages/reconciliation.rs +++ b/crates/ledger-gui/src/pages/reconciliation.rs @@ -33,13 +33,10 @@ use rust_decimal::Decimal; /// 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) + ledger_package::reconcile_prompt( + &b.account_label(account), + b.reconciled_through(account), + through, ) } diff --git a/crates/ledger-mcp/src/tools/kept.rs b/crates/ledger-mcp/src/tools/kept.rs index 7d64fbf..fffd0fd 100644 --- a/crates/ledger-mcp/src/tools/kept.rs +++ b/crates/ledger-mcp/src/tools/kept.rs @@ -761,6 +761,18 @@ impl BooksServer { args, why: why.into(), }; + // An attention title can carry a free-text description; keep the + // step short so books.start stays within START_MAX (INVARIANT 4). + let step_title = |t: &str| -> String { + const MAX: usize = 160; + if t.chars().count() <= MAX { + t.to_string() + } else { + let mut s: String = t.chars().take(MAX - 1).collect(); + s.push('…'); + s + } + }; let mut next_steps = Vec::new(); // Books an older version wrote wait for the owner's go-ahead to // upgrade their format: first, at every start until it is given @@ -811,6 +823,31 @@ impl BooksServer { )); } } + // The attention items, as next steps: the same function the viewer + // draws (ledger_package::attention), so the agent and the human see + // one list. The most urgent first (the list is sorted by severity), + // at most 12: `books.start` stays small (INVARIANT 4). + { + let attention_settings = ledger_package::Settings { + company: &cfg.company, + cash_accounts: &cfg.roles.cash, + financing_accounts: &cfg.roles.financing, + investing_accounts: &cfg.roles.investing, + }; + let attention = ledger_package::find_with( + store, + today, + &attention_settings, + &ledger_package::Integrity::default(), + ); + for a in attention.iter().filter(|a| a.tool.is_some()).take(12) { + let Some((tool, args)) = &a.tool else { + continue; + }; + let args = (!args.is_null()).then(|| args.clone()); + next_steps.push(step(tool, args, &step_title(&a.title))); + } + } match focus { "close" => next_steps.push(step( "books.conventions", diff --git a/crates/ledger-package/Cargo.toml b/crates/ledger-package/Cargo.toml index b01a671..877ef18 100644 --- a/crates/ledger-package/Cargo.toml +++ b/crates/ledger-package/Cargo.toml @@ -9,10 +9,12 @@ description = "wanderbooks' CPA handoff package and the reports and worksheets i [dependencies] ledger-core = { path = "../ledger-core" } ledger-store = { path = "../ledger-store" } +ledger-config = { path = "../ledger-config" } ledger-import = { path = "../ledger-import" } ledger-vault = { path = "../ledger-vault" } chrono = "0.4" rust_decimal = "1" +serde_json = "1" zip = "9.0.1" [lints] diff --git a/crates/ledger-package/src/attention.rs b/crates/ledger-package/src/attention.rs new file mode 100644 index 0000000..2c6fb73 --- /dev/null +++ b/crates/ledger-package/src/attention.rs @@ -0,0 +1,1515 @@ +//! Needs attention: what the books need, read once for the viewer and the +//! server alike. +//! +//! ```text +//! GOAL: tell the humans what's unfinished or wrong, and hand them the +//! prompt that gets the agent to fix it; never change the books. +//! The viewer draws these items and `books.start` lists them as its +//! next steps, from this one function over [`PackageBooks`], so the +//! two can never disagree. +//! SOURCE: DECISION (owner: the viewer is read-only; its "Needs +//! attention" list offers prompts, AGENTS.md); the conditions +//! below; docs/spec/sources/irs-1099-thresholds.md (W-9s); +//! DECISION D-40 (owner, 2026-10-20: ask only about vendors paid +//! in a way that could need a 1099); plan/served.md U0b (D-53: no +//! `ledger-view` crate). +//! INVARIANTS: +//! 1. Pure: reads the books, writes nothing. +//! 2. One item per: draft entry (Todo); posted entry missing a receipt +//! (Problem; four or more are one combined item); recurring entry or +//! schedule due through today (Todo); period ended and still open +//! (Todo); the revenue and expense accounts (not headers) with no tax +//! category, with activity or not (ledger_core::tax:: +//! uncategorized_accounts: one item; Info, Todo under +//! policy.require_tax_category = refuse); legal rule known only +//! through a past year (Todo); for +//! this calendar year, and last year in January, from +//! the year's 1099 report (the server's books.vendor_1099): a vendor a +//! 1099 is due for (reportable) without a W-9 (Problem); a vendor +//! paid that year in a way that could count toward a 1099 while its +//! 1099 decision was undetermined (Vendor1099Line::decision_needed: +//! Problem when its status is undetermined, else Todo, its prompt +//! naming books.set_vendor_1099); the year's payments whose method +//! decides whether they count (Vendor1099Report:: +//! undetermined_payments, none for a vendor decided not reportable), +//! one Todo counting reversal chains, its prompt naming +//! books.instruments, books.add_instrument and +//! books.set_entry_instrument; a year whose worksheet can't be made +//! for want of named cash accounts while vendors were paid in it +//! (Todo); overdue bills, and overdue +//! invoices (Problem, one each, with the total); money owed to +//! owners (Info, per account with a balance); the database differing +//! from the journal or unreadable, and damaged or missing stored +//! files (Problem); depreciation due through the last finished month +//! and not posted (amount > 0: Todo, one item); the asset register +//! and the books' asset cost accounts disagreeing today +//! (unregistered_cost != 0: Problem); an active asset in service with +//! no tag (Todo, one per asset); an active asset in service with no +//! tax basis (Asset::tax_basis is None: Todo, one per asset, its +//! prompt naming books.set_asset_tax_basis); a party not archived +//! whose W-9 is Missing (it files a 1099, has no W-9 facts, and its +//! vendor record doesn't say one is on file) and that was paid this +//! year (paid_ytd > 0), unless its vendor is already a 1099 vendor +//! without a W-9 above (Todo, one per party, its prompt from +//! [`w9_prompt`], naming books.set_w9); a cash or card account whose +//! reconciliation is due (Todo, one per account, titled "Reconcile +//! through ", its prompt from [`reconcile_prompt`], +//! naming books.record_statement and books.reconcile); when +//! `policy.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); each warning of the fiscal year's +//! statement of cash flows (rules.md B-14 rule 12: equipment sold for +//! an IOU, Todo; bought on an operating payable, Info; one per +//! entry, its detail the warning's fix). +//! 3. Nothing else: books with none of those yield no items, except +//! that a figure behind an item that can't be computed (an +//! amortization plan past chrono's last day, an aging past exact +//! arithmetic: books an earlier version wrote, rules.md B-28) is one +//! Problem item saying so, never a panic or a guess. +//! 4. Every item's prompt starts "In wanderbooks," or names +//! wanderbooks, and its amounts are the books' exact amounts. +//! 5. The same books give the same items through a `Store` and through a +//! `Snapshot` (the fields each reads are the same: the ledger, the +//! journal's event types, the attachments and their links, the +//! statements and reconciliations). +//! ``` + +use std::collections::{BTreeSet, HashMap, HashSet}; + +use chrono::{Datelike, NaiveDate}; +use ledger_core::parties::MoneyAccounts; +use ledger_core::{ + AccountId, AccountType, AssetId, EntryId, EntryStatus, Ledger, LedgerError, Party, PartyId, + PartyMember, PeriodStatus, +}; +use ledger_store::{Attachment, DiskCheck, JournalLine, Reconciliation, Statement, Subject}; +use rust_decimal::Decimal; +use serde_json::Value; + +use crate::books::PackageBooks; +use crate::reports::Settings; + +/// How urgent an item is; the list is sorted by this, Problems first. +#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub enum Severity { + /// Something wrong that wants fixing: a missing receipt, an overdue + /// bill, a figure the books can't compute. + Problem, + /// Something unfinished: a draft, a due schedule, an unreconciled + /// account. + Todo, + /// Worth knowing, not wrong: an uncategorized account under a policy + /// that only warns. + Info, +} + +/// What an item is about: the viewer maps it to its page, the server to a +/// next step. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Target { + /// Nothing to open (a fact with no page of its own). + None, + /// One entry's page. + Entry(EntryId), + /// The transactions list. + Transactions, + /// The company page. + Company, + /// The chart of accounts. + Accounts, + /// One account's page. + Account(AccountId), + /// One party's page. + Party(PartyId), + /// The fixed asset register. + Assets, + /// One asset. + Asset(AssetId), + /// The documents and attachments list. + Documents, + /// The journal. + Journal, + /// One of the reports. + Report(Report), +} + +/// A report one of the viewer's pages shows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Report { + /// The 1099 worksheet. + Vendor1099, + /// The payables aging. + ApAging, + /// The receivables aging. + ArAging, + /// The fixed asset register. + FixedAssetRegister, +} + +/// Something the books need, with how to fix it. +#[derive(Clone, Debug)] +pub struct Attention { + /// How urgent it is. + pub severity: Severity, + /// A stable word for the item's kind ("missing_receipt", "draft", ...). + pub kind: &'static str, + /// What's wrong, in one line. + pub title: String, + /// The particulars: the entry, the amounts, the rule. + pub detail: String, + /// Where the viewer opens it. + pub target: Target, + /// The prompt to paste into the agent session that fixes it. + pub prompt: String, + /// The tool that starts the fix, with its arguments (None: none yet). + pub tool: Option<(String, Value)>, +} + +/// What's on disk against what the journal says, as an attention item needs +/// it: the database check, and the stored files that are missing or +/// damaged. +#[derive(Clone, Debug, PartialEq)] +pub struct Integrity { + /// How the writer's database compares with the journal. + pub disk: DiskCheck, + /// Stored files whose bytes are missing or don't match their hash: + /// (attachment id, why). + pub file_failures: Vec<(u64, String)>, +} + +impl Default for Integrity { + fn default() -> Self { + Integrity { + disk: DiskCheck::Matches, + file_failures: Vec::new(), + } + } +} + +impl Integrity { + /// Check the database and every stored file: the attachment's bytes + /// are read (and hash-checked) through the books. + pub fn check(books: &impl PackageBooks) -> Integrity { + let mut file_failures = Vec::new(); + for a in books.all_attachments().unwrap_or_default() { + if let Err(e) = books.attachment_bytes(a.id) { + file_failures.push((a.id, e.to_string())); + } + } + Integrity { + disk: books.disk_check(), + file_failures, + } + } +} + +/// Everything the books need, as items to show, computing the integrity +/// check itself (the viewer passes its own, already checked, to +/// [`find_with`]). +pub fn find(books: &impl PackageBooks, today: NaiveDate, settings: &Settings) -> Vec { + let integrity = Integrity::check(books); + find_with(books, today, settings, &integrity) +} + +/// [`find`], with the integrity check the caller already made. +pub fn find_with( + books: &impl PackageBooks, + today: NaiveDate, + settings: &Settings, + integrity: &Integrity, +) -> Vec { + let ledger = books.ledger(); + let policy = ledger_config::codec::receipt_policy(ledger.settings()); + let threshold = policy.threshold(); + let journal = books.journal().unwrap_or_default(); + let statements = books.statements().unwrap_or_default(); + let reconciliations = books.reconciliations().unwrap_or_default(); + let cash_accounts = ledger_store::reconcilable::cash_accounts(ledger, settings.cash_accounts); + let financing: Vec = settings + .financing_accounts + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect(); + let money_cash: Vec = { + let configured: Vec = settings + .cash_accounts + .iter() + .filter_map(|c| ledger.chart().find_by_code(c).map(|a| a.id)) + .collect(); + if configured.is_empty() { + let mut all: Vec = ledger_core::parties::money_accounts(ledger, &[], &[]) + .cash + .into_iter() + .collect(); + all.sort_unstable(); + all + } else { + configured + } + }; + let money = ledger_core::parties::money_accounts(ledger, &money_cash, &financing); + let members = settings.company.party_members(ledger); + let entries = entries(books, &journal, &policy); + let mut out = Vec::new(); + + for e in entries.iter().filter(|e| e.status == EntryStatus::Draft) { + out.push(Attention { + severity: Severity::Todo, + kind: "draft", + title: format!("Draft not posted: {}", e.description), + detail: format!( + "#{} · {} · {}. Drafts appear in no report until posted.", + e.id, + date(e.date), + dollars_or(e.amount) + ), + target: Target::Entry(e.id), + prompt: format!( + "In wanderbooks, review draft entry {} (\"{}\", {}): attach its receipt if it needs one, preview it, and post it if it's right.", + e.id, + e.description, + dollars_or(e.amount) + ), + tool: Some(("books.get_entry".into(), serde_json::json!({ "entry_id": e.id }))), + }); + } + + let missing: Vec<&Entry> = entries + .iter() + .filter(|e| e.status == EntryStatus::Posted && e.missing_receipt) + .collect(); + if missing.len() > 3 { + let total = missing + .iter() + .try_fold(Decimal::ZERO, |t, e| t.checked_add(e.amount?)); + let list: Vec = missing + .iter() + .map(|e| format!("{} ({}, {})", e.id, e.description, e.date)) + .collect(); + out.push(Attention { + severity: Severity::Problem, + kind: "missing_receipt", + title: format!("{} without receipts", count(missing.len(), "entry", "entries")), + detail: format!( + "{} in entries with no receipt attached ({}).", + dollars_or(total), + receipt_rule(threshold) + ), + target: Target::Transactions, + prompt: format!( + "In wanderbooks, these entries have no receipt: {}. Help me find each receipt and attach it with books.attach_receipt.", + list.join("; ") + ), + tool: Some(("books.attach_receipt".into(), Value::Null)), + }); + } + for e in missing.iter().filter(|_| missing.len() <= 3) { + out.push(Attention { + severity: Severity::Problem, + kind: "missing_receipt", + title: format!("No receipt: {}", e.description), + detail: format!( + "#{} · {} · {}: {}.", + e.id, + date(e.date), + dollars_or(e.amount), + receipt_rule(threshold) + ), + target: Target::Entry(e.id), + prompt: format!( + "In wanderbooks, entry {} (\"{}\", {} on {}) has no receipt. Find it and attach it with books.attach_receipt.", + e.id, + e.description, + dollars_or(e.amount), + e.date + ), + tool: Some(("books.attach_receipt".into(), Value::Null)), + }); + } + + for r in ledger.recurring_entries() { + let due = r.due(today); + if !due.is_empty() { + out.push(Attention { + severity: Severity::Todo, + kind: "recurring_due", + title: format!("Recurring \"{}\" is due", r.description), + detail: format!( + "{} since {}.", + count(due.len(), "occurrence", "occurrences"), + date(due[0]) + ), + target: Target::Company, + prompt: format!( + "In wanderbooks, run books.run_recurring through {today}, then attach each draft's receipt and post them." + ), + tool: Some(("books.run_recurring".into(), Value::Null)), + }); + } + } + + for s in ledger.schedules() { + // A schedule an earlier version journaled may not compute (its + // months past chrono's last day): say so, never guess. + let due = match s.due(today) { + Ok(due) => due, + Err(e) => { + out.push(cant_compute( + format!("Amortization schedule \"{}\"", s.description), + &e, + Target::Company, + )); + continue; + } + }; + if !due.is_empty() { + let Some(total) = due + .iter() + .try_fold(Decimal::ZERO, |t, (_, a)| t.checked_add(*a)) + else { + out.push(cant_compute( + format!("Amortization schedule \"{}\"", s.description), + &LedgerError::TooLargeToCompute("the months due".into()), + Target::Company, + )); + continue; + }; + out.push(Attention { + severity: Severity::Todo, + kind: "schedule_due", + title: format!("Amortization due: {}", s.description), + detail: format!( + "{} totaling {} not yet posted.", + count(due.len(), "month", "months"), + dollars(total) + ), + target: Target::Company, + prompt: format!( + "In wanderbooks, preview books.run_schedules through {today}, then post it if it's right." + ), + tool: Some(("books.run_schedules".into(), Value::Null)), + }); + } + } + + for p in ledger.list_periods() { + if p.status == PeriodStatus::Open && p.end < today { + out.push(Attention { + severity: Severity::Todo, + kind: "period_open", + title: format!("Period {} ended but is open", p.name), + detail: format!( + "{} – {}. Closing locks it and moves its earnings to equity.", + date(p.start), + date(p.end) + ), + target: Target::Company, + prompt: format!( + "In wanderbooks, review period {} and, if everything for it is recorded and reconciled, preview and close it.", + p.name + ), + tool: Some(("books.list_periods".into(), Value::Null)), + }); + } + } + + // Every revenue and expense account (not a header) without a tax + // category, with activity or not (rules.md B-32): the one list the + // digest gives the agent too. + let uncategorized: Vec = ledger_core::tax::uncategorized_accounts(ledger) + .into_iter() + .map(|a| format!("{} {}", a.code, a.name)) + .collect(); + if !uncategorized.is_empty() { + let refuse = ledger.settings().require_tax_category() + == ledger_core::settings::TaxCategoryPolicy::Refuse; + out.push(Attention { + severity: if refuse { + Severity::Todo + } else { + Severity::Info + }, + kind: "uncategorized", + title: format!( + "{} without a tax category", + count(uncategorized.len(), "account", "accounts") + ), + detail: format!( + "{}. Each revenue and expense account maps to a line of the return (the tax summary and the CPA package list it as uncategorized until then).", + uncategorized.join(", ") + ), + target: Target::Accounts, + prompt: format!( + "In wanderbooks, propose a tax category for each of these accounts ({}) using books.tax_categories, and set them with books.set_tax_category once I agree.", + uncategorized.join(", ") + ), + tool: Some(("books.tax_categories".into(), Value::Null)), + }); + } + + // Rules set by law that wanderbooks may no longer know (rules.md; + // owner, 2026-10-13): the agent finds the figure from the authority + // or asks, confirms it, and passes it explicitly (the handbook's + // "Rules that change with the law"); the human reports it upstream. + for rule in ledger_core::rules::stale_rules(today) { + out.push(Attention { + severity: Severity::Todo, + kind: "stale_rule", + title: format!("Check the {}", rule.name), + detail: format!( + "wanderbooks knows it through {}. {}", + rule.known_through, rule.review_why + ), + target: Target::None, + prompt: format!( + "In wanderbooks, the {} is known only through {}. Find the current figure from the official authority, {}, or ask me or our CPA, and confirm it with me; then pass it explicitly wherever a tool asks for it. Tell me what you found and its official source, so I can report it upstream and the rule is updated for everyone.", + rule.name, rule.known_through, rule.authority + ), + tool: Some(( + "books.handbook".into(), + serde_json::json!({ "section": "rules-that-change" }), + )), + }); + } + + let mut years = vec![today.year()]; + if today.month() == 1 { + years.insert(0, today.year() - 1); + } + // Vendors the "needs a W-9" item already names this year. + let mut asked_w9 = HashSet::new(); + for year in years { + // A year whose threshold isn't known is flagged by the stale-rule + // item above; never guess it here. + let r = match crate::vendor_1099::vendor_1099_report(ledger, settings, year, None, None) { + Ok(r) => r, + Err(LedgerError::Invalid1099(why)) + if ledger + .posted_entries() + .any(|e| e.date().year() == year && ledger.entry_vendor(e.id()).is_some()) => + { + out.push(Attention { + severity: Severity::Todo, + kind: "worksheet_unmakeable", + title: format!("The {year} 1099 worksheet can't be made"), + detail: format!("{why}."), + target: Target::Report(Report::Vendor1099), + prompt: format!( + "In wanderbooks, the {year} 1099 worksheet can't be made: {why}. Ask me which accounts hold the company's cash, then set them with books.set_setting." + ), + tool: Some(("books.settings".into(), Value::Null)), + }); + continue; + } + Err(_) => continue, + }; + let in_year = if year == today.year() { + String::new() + } else { + format!(" for {year}") + }; + for v in r.vendors.iter().filter(|v| v.decision_needed) { + let party = ledger.party_of_vendor(v.vendor_id); + let unknown = if v.undetermined > Decimal::ZERO { + format!( + " and {} more whose payment method isn't recorded", + dollars(v.undetermined) + ) + } else { + String::new() + }; + out.push(Attention { + severity: if v.status == ledger_core::Status1099::Undetermined { + Severity::Problem + } else { + Severity::Todo + }, + kind: "vendor_1099_decision", + title: format!("Decide whether {} gets a 1099{in_year}", v.name), + detail: format!( + "Paid {} by check, ACH, transfer or cash{unknown} in {year}, with no 1099 decision.", + dollars(v.paid) + ), + target: party.map_or(Target::Company, Target::Party), + prompt: format!( + "In wanderbooks, {} was paid {}{unknown} in {year} and has no 1099 decision. Ask me whether it gets a 1099-NEC, a 1099-MISC, or none and why (a corporation, the United States or a state, a payee selling only merchandise, ...); then record it with books.set_vendor_1099, with who decided and when.", + v.name, + dollars(v.paid) + ), + tool: Some(( + "books.set_vendor_1099".into(), + serde_json::json!({ "vendor": v.vendor_id.to_string() }), + )), + }); + } + // Payments whose method decides whether they count, a payment + // and its reversals counted once. + let mut chains: Vec = r + .undetermined_payments + .iter() + .map(|p| ledger.reversal_chain(p.entry_id)[0]) + .collect(); + chains.sort_unstable(); + chains.dedup(); + if !chains.is_empty() { + let n = chains.len(); + let ids = chains + .iter() + .map(|e| e.to_string()) + .collect::>() + .join(", "); + let (payments, entries) = if n == 1 { + ("payment has", "entry") + } else { + ("payments have", "entries") + }; + out.push(Attention { + severity: Severity::Todo, + kind: "payment_method", + title: format!("{n} {payments} no payment method{in_year}"), + detail: format!( + "Paid for the company through a financing account in {year} ({entries} {ids}): a member's card doesn't count toward a 1099, their cash or check does, so the 1099 worksheet can't tell until each is recorded." + ), + target: Target::Report(Report::Vendor1099), + prompt: format!( + "In wanderbooks, {entries} {ids} paid a vendor in {year} through a financing account with no payment method recorded, so the 1099 worksheet can't tell whether they count. Ask me how each was paid (whose card, or cash, check or a transfer); add any method not yet in books.instruments with books.add_instrument, then tag each entry with books.set_entry_instrument." + ), + tool: Some(("books.instruments".into(), Value::Null)), + }); + } + for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) { + if year == today.year() { + asked_w9.insert(v.vendor_id); + } + let party = ledger.party_of_vendor(v.vendor_id); + out.push(Attention { + severity: Severity::Problem, + kind: "missing_w9", + title: format!("{} needs a W-9", v.name), + detail: format!( + "Paid {} in {year}, over the {} 1099 threshold.", + dollars(v.paid), + dollars(r.threshold) + ), + target: party.map_or(Target::Company, Target::Party), + prompt: format!( + "Remind me to request a W-9 from {}; once I have it, record what it says in wanderbooks with books.set_w9 (that marks it on file and keeps the 1099 decision).", + v.name + ), + tool: Some(( + "books.set_w9".into(), + party.map_or(Value::Null, |p| { + serde_json::json!({ "party": p.to_string() }) + }), + )), + }); + } + } + + for p in ledger.parties().filter(|p| { + !p.archived && w9_missing(ledger, p) && !p.vendor.is_some_and(|v| asked_w9.contains(&v)) + }) { + let paid_ytd = paid_this_year(ledger, &money, &members, p.id, today); + if paid_ytd <= Decimal::ZERO { + continue; + } + out.push(Attention { + severity: Severity::Todo, + kind: "collect_w9", + title: format!("Collect a W-9 from {}", p.label()), + detail: format!( + "Paid {} in {} as {}, with no W-9 facts recorded: collect a W-9 before the next payment.", + dollars(paid_ytd), + today.year(), + role_word(p) + ), + target: Target::Party(p.id), + prompt: w9_prompt(p, paid_ytd, today.year()), + tool: Some(( + "books.set_w9".into(), + serde_json::json!({ "party": p.id.to_string() }), + )), + }); + } + + // Overdue: (parties, total), or why it can't be computed (documents + // are restored as journaled, of any size). + let overdue = |rows: Result, LedgerError>| { + let too_large = || LedgerError::TooLargeToCompute("the overdue total".into()); + let late: Vec = rows? + .iter() + .map(|r| { + [r.days_60, r.days_90, r.days_90_plus] + .into_iter() + .try_fold(r.days_30, Decimal::checked_add) + .ok_or_else(too_large) + }) + .collect::, _>>()? + .into_iter() + .filter(|d| !d.is_zero()) + .collect(); + let total = late + .iter() + .try_fold(Decimal::ZERO, |t, d| t.checked_add(*d)) + .ok_or_else(too_large)?; + Ok::<_, LedgerError>((late.len(), total)) + }; + let (n, total) = match overdue(ledger.ap_aging(today)) { + Ok(v) => v, + Err(e) => { + out.push(cant_compute( + "The payables aging".into(), + &e, + Target::Report(Report::ApAging), + )); + (0, Decimal::ZERO) + } + }; + if n > 0 { + out.push(Attention { + severity: Severity::Problem, + kind: "ap_overdue", + title: format!("{} overdue to pay", dollars(total)), + detail: format!("{} with bills past due.", count(n, "vendor", "vendors")), + target: Target::Report(Report::ApAging), + prompt: "In wanderbooks, show me the A/P aging and which bills are past due.".into(), + tool: Some(("books.ap_aging".into(), Value::Null)), + }); + } + let (n, total) = match overdue(ledger.ar_aging(today)) { + Ok(v) => v, + Err(e) => { + out.push(cant_compute( + "The receivables aging".into(), + &e, + Target::Report(Report::ArAging), + )); + (0, Decimal::ZERO) + } + }; + if n > 0 { + out.push(Attention { + severity: Severity::Problem, + kind: "ar_overdue", + title: format!("{} overdue to collect", dollars(total)), + detail: format!( + "{} with invoices past due.", + count(n, "customer", "customers") + ), + target: Target::Report(Report::ArAging), + prompt: + "In wanderbooks, show me the A/R aging and draft reminders for past-due invoices." + .into(), + tool: Some(("books.ar_aging".into(), Value::Null)), + }); + } + + for a in ledger.chart().all().filter(|a| { + a.account_type == AccountType::Liability && { + let n = a.name.to_lowercase(); + n.contains("due to") || n.contains("owed to") + } + }) { + let bal = balance_on(ledger, a.id, today); + if bal > Decimal::ZERO { + out.push(Attention { + severity: Severity::Info, + kind: "due_to_owners", + title: format!("{} owed on {}", dollars(bal), a.name), + detail: "Expenses someone paid personally, not yet reimbursed.".into(), + target: Target::Account(a.id), + prompt: format!( + "In wanderbooks, list what makes up the {} balance on {} {} and who is owed what.", + dollars(bal), + a.code, + a.name + ), + tool: Some(( + "books.search_entries".into(), + serde_json::json!({ "account_id": a.id }), + )), + }); + } + } + + for a in reconcilable_accounts(ledger, &cash_accounts, &statements) { + let Some(through) = reconciliation_due(ledger, &reconciliations, a, today) else { + continue; + }; + let name = account_name(ledger, a); + let reconciled = reconciled_through(&reconciliations, a); + out.push(Attention { + severity: Severity::Todo, + kind: "reconcile", + title: format!("Reconcile {name} through {}", date(through)), + detail: match reconciled { + Some(d) => format!( + "Reconciled through {}. The monthly close reconciles it to each statement since.", + date(d) + ), + None => "Never reconciled. The monthly close reconciles it to its statement.".into(), + }, + target: Target::Account(a), + prompt: reconcile_prompt(&account_label(ledger, a), reconciled, through), + tool: Some(("books.record_statement".into(), Value::Null)), + }); + } + + let policy = ledger.settings().parties(); + if policy != ledger_core::settings::PartyPolicy::Off { + let year_start = NaiveDate::from_ymd_opt(today.year(), 1, 1).expect("valid"); + let unnamed: Vec<&Entry> = entries + .iter() + .filter(|e| { + e.status == EntryStatus::Posted + && e.date >= year_start + && e.date <= today + && e.parties.is_empty() + && 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_core::settings::PartyPolicy::Require { + Severity::Todo + } else { + Severity::Info + }, + kind: "no_counterparty", + title: format!( + "{} no counterparty", + 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: Target::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("; ") + ), + tool: Some(("books.set_entry_party".into(), Value::Null)), + }); + } + } + + // The register computes unless a contributor basis an earlier version + // journaled is past exact arithmetic: then one item says so. + let (due, register) = match ( + depreciation_due(ledger, today), + fixed_asset_register(ledger, today), + ) { + (Ok(due), Ok(register)) => (Some(due), Some(register)), + (Err(e), _) | (_, Err(e)) => { + out.push(cant_compute( + "The fixed asset register".into(), + &e, + Target::Report(Report::FixedAssetRegister), + )); + (None, None) + } + }; + if let Some(due) = due.filter(|d| d.amount > Decimal::ZERO) { + let months = if due.periods.is_empty() { + "every month not yet posted".to_string() + } else { + due.periods.join(", ") + }; + out.push(Attention { + severity: Severity::Todo, + kind: "depreciation", + title: format!("Depreciation not posted through {}", date(due.through)), + detail: format!( + "{} scheduled and not yet in the books ({}).", + dollars(due.amount), + months + ), + target: Target::Assets, + prompt: format!( + "In wanderbooks, run books.run_depreciation for {months} (depreciation due through {} and not posted: {}), then show me the fixed asset register.", + due.through, + dollars(due.amount) + ), + tool: Some(("books.run_depreciation".into(), Value::Null)), + }); + } + // Accumulated depreciation no asset's runs account for: a hand entry, + // or a run reversed after its asset left the books. + let unattributed = register + .as_ref() + .map_or(Decimal::ZERO, |r| r.unattributed_accumulated()); + if !unattributed.is_zero() { + out.push(Attention { + severity: Severity::Problem, + kind: "unattributed_depreciation", + title: format!( + "{} of accumulated depreciation not from any asset's runs", + dollars(unattributed.abs()) + ), + detail: "The accumulated depreciation accounts hold depreciation (or take it back) that no registered asset's depreciation run accounts for: an entry made by hand, or a run reversed after its asset was disposed.".into(), + target: Target::Assets, + prompt: format!( + "In wanderbooks, the accumulated depreciation accounts differ by {} from what the assets' depreciation runs put there (books.export fixed_asset_register, unattributed_accumulated). Find the entries behind it in those accounts' registers and tell me how to fix them.", + dollars(unattributed) + ), + tool: Some(( + "books.export".into(), + serde_json::json!({ "format": "csv", "report": "fixed_asset_register" }), + )), + }); + } + let unregistered = register.map_or(Decimal::ZERO, |r| r.unregistered_cost()); + if !unregistered.is_zero() { + let (title, why) = if unregistered > Decimal::ZERO { + ( + format!( + "{} of equipment not in the asset register", + dollars(unregistered) + ), + "The asset cost accounts hold more than the registered assets cost: something bought was never registered.", + ) + } else { + ( + format!( + "{} of registered assets not in the books", + dollars(-unregistered) + ), + "Registered assets cost more than their cost accounts hold: a purchase entry may be missing.", + ) + }; + out.push(Attention { + severity: Severity::Problem, + kind: "unregistered_assets", + title, + detail: why.into(), + target: Target::Assets, + prompt: format!( + "In wanderbooks, the fixed asset register and the asset cost accounts differ by {} today. Find what's behind it (books.list_assets and the cost accounts' registers) and tell me how to fix it.", + dollars(unregistered) + ), + tool: Some(("books.list_assets".into(), Value::Null)), + }); + } + let held = |a: &&ledger_core::Asset| { + a.status == ledger_core::AssetStatus::Active + && ledger.asset_acquired_date(a.id).is_some_and(|d| d <= today) + }; + for a in ledger + .registered_assets() + .filter(held) + .filter(|a| a.tag.is_none()) + { + out.push(Attention { + severity: Severity::Todo, + kind: "asset_tag", + title: format!("No tag: {}", a.description), + detail: format!( + "Asset {}, in service {}. A tag is the label on the thing itself.", + a.id, + date(a.in_service_date) + ), + target: Target::Asset(a.id), + prompt: format!( + "In wanderbooks, label asset {} (\"{}\") with books.label_asset: give it the next generated tag unless I have my own, and its serial number if I can find it. Then print its label with books.asset_labels.", + a.id, a.description + ), + tool: Some(( + "books.label_asset".into(), + serde_json::json!({ "asset": a.id.to_string() }), + )), + }); + } + + for a in ledger.registered_assets().filter(held) { + let name = a.tag.clone().unwrap_or_else(|| format!("asset {}", a.id)); + let Some(basis) = &a.basis else { + out.push(Attention { + severity: Severity::Todo, + kind: "asset_tax_basis", + title: format!("No tax basis: {}", asset_label(ledger, a.id)), + detail: format!( + "Book cost {}, in service {}. The CPA needs its federal tax basis beside its book value.", + dollars(a.cost.amount), + date(a.in_service_date) + ), + target: Target::Asset(a.id), + prompt: format!( + "In wanderbooks, record the tax basis of {name} (\"{}\", book cost {}) with books.set_asset_tax_basis. Ask me how the company got it: bought (purchase), contributed by a member from business use (carryover_723, IRC §723: the member's adjusted basis), or contributed by a member from personal use (personal_use_conversion, Treas. Reg. §1.168(i)-4(b): the member's adjusted basis, depreciated from the lesser of that and its fair market value); for a contribution, its fair market value when contributed, and who says so. Then show me the asset.", + a.description, + dollars(a.cost.amount) + ), + tool: Some(( + "books.set_asset_tax_basis".into(), + serde_json::json!({ "asset": a.id.to_string() }), + )), + }); + continue; + }; + if !basis.rule.is_contribution() { + continue; + } + if !a.fmv_is_recorded() { + out.push(Attention { + severity: Severity::Todo, + kind: "asset_fmv", + title: format!("Confirm its value at contribution: {}", asset_label(ledger, a.id)), + detail: format!( + "Its basis was recorded before the value at contribution was asked, so the books read it as the book cost, {}. The §704(c) built-in gain or loss rests on it.", + dollars(a.cost.amount) + ), + target: Target::Asset(a.id), + prompt: format!( + "In wanderbooks, {name} (\"{}\") was contributed with its value at contribution not recorded; the books read it as its book cost, {}. Ask me what it was worth when contributed, then record it with books.set_asset_tax_basis (rule {}, contributor_adjusted_basis {}, fmv_at_contribution, and basis_source: who says so, citing the contribution entry). Then show me the asset.", + a.description, + dollars(a.cost.amount), + basis.rule.key(), + basis + .contributor_adjusted_basis + .map(|v| v.to_string()) + .unwrap_or_default() + ), + tool: Some(( + "books.set_asset_tax_basis".into(), + serde_json::json!({ "asset": a.id.to_string() }), + )), + }); + } + if a.built_in_gain_704c().is_some_and(|g| !g.is_zero()) && basis.sec704c_method.is_none() { + out.push(Attention { + severity: Severity::Todo, + kind: "sec704c", + title: format!("Choose a §704(c) method with the CPA: {}", asset_label(ledger, a.id)), + detail: "It was contributed with a built-in gain or loss, and no §704(c) method (traditional, traditional with curative allocations, or remedial) is recorded for it.".into(), + target: Target::Asset(a.id), + prompt: format!( + "In wanderbooks, {name} (\"{}\") has a §704(c) built-in gain or loss and no §704(c) method. Ask me which method the CPA and I chose (traditional, traditional_with_curative, or remedial: Treas. Reg. §1.704-3(b)-(d)), then record it with books.set_asset_tax_basis, keeping its other facts as they are. Then show me the asset.", + a.description + ), + tool: Some(( + "books.set_asset_tax_basis".into(), + serde_json::json!({ "asset": a.id.to_string() }), + )), + }); + } + } + + let verify_prompt = "In wanderbooks, run books.verify and tell me what it found.".to_string(); + match &integrity.disk { + DiskCheck::Differs(why) => out.push(Attention { + severity: Severity::Problem, + kind: "database", + title: "The database doesn't match the journal".into(), + detail: format!("{why}. The journal is the record; the server rebuilds the database from it (books.verify, or on its next start)."), + target: Target::Journal, + prompt: verify_prompt.clone(), + tool: Some(("books.verify".into(), Value::Null)), + }), + DiskCheck::Unreadable(why) => out.push(Attention { + severity: Severity::Problem, + kind: "database", + title: "Couldn't check the database".into(), + detail: format!("{why}."), + target: Target::Journal, + prompt: verify_prompt.clone(), + tool: Some(("books.verify".into(), Value::Null)), + }), + _ => {} + } + if !integrity.file_failures.is_empty() { + let ids: Vec = integrity + .file_failures + .iter() + .map(|(id, _)| id.to_string()) + .collect(); + out.push(Attention { + severity: Severity::Problem, + kind: "stored_files", + title: format!( + "{} damaged or missing", + count(ids.len(), "stored file is", "stored files are") + ), + detail: format!( + "Attachment {}: the bytes on disk don't match their recorded hash. Restore them from a backup (the books dir's git history).", + ids.join(", ") + ), + target: Target::Documents, + prompt: verify_prompt, + tool: Some(("books.verify".into(), Value::Null)), + }); + } + // The statement of cash flows' warnings this fiscal year (rules.md + // B-14 rule 12, D-35): equipment sold for an IOU, flagged with its fix + // (Todo); equipment bought on an operating payable, following how the + // payable is classified (Info). A statement the settings can't give + // (no cash accounts yet) has none. + let (fy_start, _) = fiscal_ytd(settings.company, today); + if let Ok(cf) = crate::reports::cash_flow_for( + ledger, + settings, + Some(&fy_start.to_string()), + Some(&today.to_string()), + &[], + &[], + &[], + today, + ) { + for w in cf.warnings { + let sold = w.kind == ledger_core::CashFlowWarningKind::AssetSoldOnOperatingReceivable; + out.push(Attention { + severity: if sold { Severity::Todo } else { Severity::Info }, + kind: "cash_flow", + title: if sold { + format!("Equipment sold for an IOU: entry #{}", w.entry) + } else { + format!("Equipment bought on an operating payable: entry #{}", w.entry) + }, + detail: w.message.clone(), + target: Target::Entry(w.entry), + prompt: format!( + "In wanderbooks, the cash flow statement flags entry {}: {} Show me the entry and what you would change, and change nothing until I agree.", + w.entry, w.message + ), + tool: Some(( + "books.get_entry".into(), + serde_json::json!({ "entry_id": w.entry }), + )), + }); + } + } + out.sort_by_key(|a| a.severity); + out +} + +/// One entry, indexed the way the items read it. +struct Entry { + id: EntryId, + date: NaiveDate, + description: String, + status: EntryStatus, + /// Total debits; None when they can't be summed exactly. + amount: Option, + origin: &'static str, + parties: Vec, + /// Covered by the receipt policy and has no receipt. + missing_receipt: bool, +} + +/// Every entry, with the journal-derived facts the items read: its origin +/// and whether the receipt policy covers it without a receipt. +fn entries( + books: &impl PackageBooks, + journal: &[JournalLine], + policy: &ledger_config::ReceiptPolicy, +) -> Vec { + let ledger = books.ledger(); + let mut events: HashMap> = HashMap::new(); + for (i, line) in journal.iter().enumerate() { + for id in entry_refs(line) { + events.entry(id).or_default().push(i); + } + } + // An entry's receipts: filed on it, or linked to it. + let mut receipts: HashMap = HashMap::new(); + let attachments: Vec = books.all_attachments().unwrap_or_default(); + for a in &attachments { + if a.withdrawn_at.is_some() { + continue; + } + let mut subjects: Vec = a.entry_id.map(Subject::Entry).into_iter().collect(); + subjects.extend(books.links_of(a.id).unwrap_or_default()); + let mut seen: HashSet = HashSet::new(); + for s in subjects { + if let Subject::Entry(e) = s + && seen.insert(e) + { + *receipts.entry(e).or_default() += 1; + } + } + } + let closings = ledger.closing_entry_ids(); + let mut out = Vec::new(); + for id in 1..ledger.next_entry_id() { + let Some(e) = ledger.get_entry(id) else { + continue; + }; + let evs = events.remove(&id).unwrap_or_default(); + let origin = origin(&evs, journal, closings.contains(&id)); + let amount = ledger_config::ReceiptPolicy::entry_debits(e).ok(); + let covered = matches!(origin, "Journal entry" | "Recurring") + && e.reversal_of().is_none() + && receipts.get(&id).copied().unwrap_or(0) == 0 + && policy.receipt_required(ledger, e); + out.push(Entry { + id, + date: e.date(), + description: e.description().to_string(), + status: e.status(), + amount, + origin, + parties: ledger.entry_parties(id).iter().map(|p| p.party).collect(), + missing_receipt: covered, + }); + } + out +} + +/// Entry ids a journal line refers to. +fn entry_refs(line: &JournalLine) -> Vec { + let p = &line.payload; + let mut out = Vec::new(); + let mut push = |v: Option<&Value>| { + if let Some(id) = v.and_then(|v| v.as_u64()) + && !out.contains(&id) + { + out.push(id); + } + }; + push(p.get("entry_id")); + push(p.get("closing_entry")); + push(p.get("entry").and_then(|e| e.get("id"))); + if line.line_type.starts_with("entry.") { + push(p.get("id")); + } + out +} + +fn origin(events: &[usize], journal: &[JournalLine], closing: bool) -> &'static str { + if closing { + return "Period close"; + } + let has = |t: &str| events.iter().any(|&i| journal[i].line_type == t); + if has("entry.reversed") { + "Reversal" + } else if has("schedule.posted") { + "Amortization" + } else if has("asset.depreciation_posted") { + "Depreciation" + } else if has("asset.disposed") { + "Asset disposal" + } else if has("bill.created") { + "Bill" + } else if has("bill.paid") { + "Bill payment" + } else if has("invoice.created") { + "Invoice" + } else if has("invoice.payment_received") { + "Customer payment" + } else if has("payroll.run") { + "Payroll" + } else if has("payroll.remitted") || has("payroll.taxes_remitted") { + "Payroll taxes" + } else if has("recurring.generated") { + "Recurring" + } else { + "Journal entry" + } +} + +/// Depreciation due and not yet in the books. +struct Due { + through: NaiveDate, + amount: Decimal, + periods: Vec, +} + +/// Depreciation due through the last finished month on or before `as_of` +/// and not yet posted; refused when the register can't be computed. +fn depreciation_due(ledger: &Ledger, as_of: NaiveDate) -> Result { + // A month's last day, or chrono's last day; else the end of the + // month before (none before chrono's first month: its first day). + let through = if as_of.succ_opt().is_none_or(|d| d.day() == 1) { + as_of + } else { + as_of + .with_day(1) + .and_then(|m| m.pred_opt()) + .unwrap_or(NaiveDate::MIN) + }; + let amount = fixed_asset_register(ledger, through)?.unposted_depreciation(); + let mut periods = BTreeSet::new(); + for a in ledger.registered_assets().filter(|a| { + ledger + .asset_acquired_date(a.id) + .is_some_and(|d| d <= through) + }) { + if matches!(a.status, ledger_core::AssetStatus::Disposed { date, .. } if date <= through) { + continue; + } + let runs: Vec<(EntryId, Decimal)> = ledger + .depreciation_runs(a.id) + .iter() + .map(|r| (r.entry, r.amount)) + .collect(); + let in_effect: HashSet<&str> = ledger + .depreciation_runs(a.id) + .iter() + .filter(|r| ledger.in_effect(r.entry)) + .map(|r| r.period.as_str()) + .collect(); + let effects = ledger_core::depreciation_effects(ledger, &runs); + let due: HashMap<(i32, u32), Decimal> = + ledger_core::run_amounts(a, (through.year(), through.month()), &effects)? + .into_iter() + .map(|(y, m, r)| ((y, m), r.amount)) + .collect(); + for l in ledger_core::depreciation_schedule(a, through)? { + if in_effect.contains(l.period.as_str()) { + continue; + } + if period_month(&l.period) + .and_then(|ym| due.get(&ym)) + .is_some_and(|a| !a.is_zero()) + { + periods.insert(l.period); + } + } + } + Ok(Due { + through, + amount, + periods: periods.into_iter().collect(), + }) +} + +/// A "YYYY-MM" period as (year, month), read leniently (an earlier version +/// posted "10000-01"). +fn period_month(period: &str) -> Option<(i32, u32)> { + let (y, m) = period.split_once('-')?; + let (y, m) = (y.parse().ok()?, m.parse().ok()?); + (1..=12).contains(&m).then_some((y, m)) +} + +fn fixed_asset_register( + ledger: &Ledger, + as_of: NaiveDate, +) -> Result { + ledger_core::fixed_asset_register(ledger, as_of) +} + +fn fiscal_ytd( + company: &ledger_core::settings::Company, + today: NaiveDate, +) -> (NaiveDate, NaiveDate) { + ( + ledger_core::calendar::fiscal_year_start_on(company.fiscal_year_start, today), + today, + ) +} + +fn balance_on(ledger: &Ledger, account: AccountId, date: NaiveDate) -> Decimal { + ledger_core::analysis::balance_series(ledger, &[account], &[date])[0] +} + +fn account_name(ledger: &Ledger, id: AccountId) -> String { + ledger + .chart() + .get(id) + .map(|a| a.name.clone()) + .unwrap_or_else(|| format!("account {id}")) +} + +fn account_label(ledger: &Ledger, id: AccountId) -> String { + ledger + .chart() + .get(id) + .map(|a| format!("{} {}", a.code, a.name)) + .unwrap_or_else(|| format!("account {id}")) +} + +fn asset_label(ledger: &Ledger, id: AssetId) -> String { + match ledger.get_asset(id) { + Some(a) => match &a.tag { + Some(t) => format!("{t} {}", a.description), + None => a.description.clone(), + }, + None => format!("asset {id}"), + } +} + +fn reconcilable_accounts( + ledger: &Ledger, + cash_accounts: &[AccountId], + statements: &[Statement], +) -> Vec { + ledger_store::reconcilable::reconcilable_accounts(ledger, cash_accounts, statements) +} + +fn reconciliation_due( + ledger: &Ledger, + reconciliations: &[Reconciliation], + account: AccountId, + today: NaiveDate, +) -> Option { + ledger_store::reconcilable::reconciliation_due(ledger, reconciliations, account, today) +} + +/// The latest statement date an account is reconciled through: the newest +/// reconciliation of it not voided (None: never reconciled). +fn reconciled_through(reconciliations: &[Reconciliation], account: AccountId) -> Option { + reconciliations + .iter() + .filter(|r| r.account_id == account && r.voided.is_none()) + .map(|r| r.statement_date) + .max() +} + +/// Whether a party files a 1099: it has a 1099 role (contractor, landlord), +/// or its vendor record has been decided for one (from any date: its W-9 +/// matters for those payments). +pub fn files_1099(ledger: &Ledger, p: &Party) -> bool { + p.roles.iter().any(|r| r.gets_1099()) + || p.vendor + .and_then(|v| ledger.get_vendor(v)) + .is_some_and(|v| v.decisions_1099.iter().any(|d| d.decision.form().is_some())) +} + +/// A party files a 1099, has no W-9 facts, and its vendor record doesn't +/// say one is on file: the Missing arm of the viewer's W-9 column. +pub fn w9_missing(ledger: &Ledger, p: &Party) -> bool { + if p.w9.is_some() || !files_1099(ledger, p) { + return false; + } + !p.vendor + .and_then(|v| ledger.get_vendor(v)) + .is_some_and(|v| v.tin_on_file) +} + +/// "as a contractor" or "as a landlord", by the party's 1099 role. +fn role_word(p: &Party) -> &'static str { + if p.roles.contains(&ledger_core::Role::Landlord) + && !p.roles.contains(&ledger_core::Role::Contractor) + { + "a landlord" + } else { + "a contractor" + } +} + +/// The agent prompt that collects and records a party's W-9. +pub fn w9_prompt(p: &Party, paid_ytd: Decimal, year: i32) -> String { + let paid = if paid_ytd > Decimal::ZERO { + format!(", paid {} so far in {year}", dollars(paid_ytd)) + } else { + String::new() + }; + format!( + "In wanderbooks, {} (party {}) is a {}{paid} with no W-9 facts recorded. Remind me to collect a W-9 before the next payment. Once I have it, record what it says with books.set_w9: the date received, the entity type (line 3), any exempt payee or FATCA codes (line 4), the last four digits of the TIN only (the full TIN goes in the vault, never the books), and where the form is kept. Then show me the party.", + p.label(), + p.id, + role_word(p) + ) +} + +/// The agent prompt that has the agent record a statement and reconcile an +/// account through a date. +pub fn reconcile_prompt(label: &str, reconciled: Option, through: NaiveDate) -> String { + let since = match reconciled { + 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 {label} 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." + ) +} + +/// What a party was paid this calendar year (net, cash basis): the +/// viewer's `PartyInfo::paid_ytd`, through the same `activity_with`. +fn paid_this_year( + ledger: &Ledger, + money: &MoneyAccounts, + members: &[PartyMember], + party: PartyId, + today: NaiveDate, +) -> Decimal { + let start = NaiveDate::from_ymd_opt(today.year(), 1, 1).expect("valid"); + let last = ledger + .posted_entries() + .filter(|e| e.date().year() == today.year()) + .map(|e| e.date()) + .max() + .unwrap_or(today) + .max(today); + let end = last.min(NaiveDate::from_ymd_opt(today.year(), 12, 31).expect("valid")); + let a = ledger_core::parties::activity_with(ledger, party, (start, end), money, members); + a.paid - a.received +} + +/// A figure the books can't compute (rules.md B-28: books an earlier +/// version wrote may hold dates past chrono's calendar or amounts past +/// exact arithmetic): a Problem saying so and why, never a guess. +fn cant_compute(what: String, why: &LedgerError, target: Target) -> Attention { + Attention { + severity: Severity::Problem, + kind: "cant_compute", + title: format!("{what} can't be computed"), + detail: format!("{why}."), + target, + prompt: format!( + "In wanderbooks, {} can't be computed ({why}). Find the record behind it and tell me how to correct it.", + what.to_lowercase() + ), + tool: None, + } +} + +/// Why an entry needs a receipt, in words: "every entry needs a receipt" +/// or "over the $25.00 receipt threshold". +fn receipt_rule(threshold: Decimal) -> String { + if threshold.is_zero() { + "every entry needs a receipt".to_string() + } else { + format!("over the {} receipt threshold", dollars(threshold)) + } +} + +// The viewer's own spellings, so an item reads the same wherever it is +// shown (ledger-gui::fmt): thousands grouped, every decimal place the +// amount has (at least two), a true minus sign. +fn dollars(d: Decimal) -> String { + let s = format!("${}", group(d.abs())); + if d.is_sign_negative() && !d.is_zero() { + format!("\u{2212}{s}") + } else { + s + } +} + +/// A non-negative amount, every decimal it has (at least two), grouped. +fn group(d: Decimal) -> String { + let places = d.normalize().scale().max(2) as usize; + let s = format!("{d:.places$}"); + let (int, frac) = s.split_once('.').unwrap_or((&s, "00")); + let mut out = String::new(); + for (i, c) in int.chars().enumerate() { + if i > 0 && (int.len() - i) % 3 == 0 { + out.push(','); + } + out.push(c); + } + format!("{out}.{frac}") +} + +/// [`dollars`], or "too large to compute" for a figure the books can't +/// compute. +fn dollars_or(d: Option) -> String { + d.map_or_else(|| "too large to compute".to_string(), dollars) +} + +fn date(d: NaiveDate) -> String { + d.format("%b %-d, %Y").to_string() +} + +fn count(n: usize, one: &str, many: &str) -> String { + format!("{n} {}", if n == 1 { one } else { many }) +} diff --git a/crates/ledger-package/src/books.rs b/crates/ledger-package/src/books.rs index 483efe0..839971e 100644 --- a/crates/ledger-package/src/books.rs +++ b/crates/ledger-package/src/books.rs @@ -9,22 +9,40 @@ //! INVARIANTS: //! 1. Every method only reads. //! 2. A Store and a Snapshot of the same journal answer every method -//! alike: the same ledger, journal seq, attachments (withdrawn ones -//! included), entry links, and bytes. +//! alike: the same ledger, settings, journal, journal seq, +//! attachments (withdrawn ones included), bytes, statements, and +//! reconciliations, and the same entry links once the filing entry +//! is added (a store lists it in `links_of`; a snapshot only lists +//! the explicit links, so callers take the union with +//! `all_attachments`, as `build_package` and the attention module +//! do). //! ``` use ledger_core::Ledger; -use ledger_store::{Attachment, Snapshot, Store, StoreError, Subject}; +use ledger_store::{ + Attachment, DiskCheck, JournalLine, Reconciliation, Snapshot, Statement, Store, StoreError, + Subject, +}; -/// The books, read-only, as the CPA package reads them. +/// The books, read-only, as the package reads them. pub trait PackageBooks { /// The ledger every figure comes from. fn ledger(&self) -> &Ledger; + /// The settings journaled in the books (the same ones the server and + /// the viewer read). + fn settings(&self) -> &ledger_core::settings::Settings { + self.ledger().settings() + } + /// Sequence number of the last journal line: the journal state the /// package reflects. fn journal_seq(&self) -> u64; + /// Every journal line, in order: what an entry's origin and its events + /// are read from. + fn journal(&self) -> Result, StoreError>; + /// Every attachment (receipts and documents), withdrawn ones /// included, in id order. fn all_attachments(&self) -> Result, StoreError>; @@ -36,6 +54,20 @@ pub trait PackageBooks { /// An attachment's bytes, verified against its hash. fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError>; + + /// Recorded bank and card statements, by period end. + fn statements(&self) -> Result, StoreError>; + + /// Reconciliations, voided ones included, by date then id. + fn reconciliations(&self) -> Result, StoreError>; + + /// How the writer's database compares with the journal. A snapshot + /// compares them; the store is the only writer and applies each line + /// before it returns (a failed apply heals on its next open), so it + /// reports a match. + fn disk_check(&self) -> DiskCheck { + DiskCheck::Matches + } } impl PackageBooks for Store { @@ -47,6 +79,12 @@ impl PackageBooks for Store { Store::journal_seq(self) } + fn journal(&self) -> Result, StoreError> { + ledger_store::journal::read_and_verify_journal( + &Store::data_dir(self).join(ledger_store::JOURNAL_FILE), + ) + } + fn all_attachments(&self) -> Result, StoreError> { Store::list_attachments(self, None) } @@ -58,6 +96,14 @@ impl PackageBooks for Store { fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError> { Store::attachment_bytes(self, attachment_id) } + + fn statements(&self) -> Result, StoreError> { + Store::statements(self, None) + } + + fn reconciliations(&self) -> Result, StoreError> { + Store::reconciliations(self, None) + } } impl PackageBooks for Snapshot { @@ -69,6 +115,10 @@ impl PackageBooks for Snapshot { Snapshot::journal_seq(self) } + fn journal(&self) -> Result, StoreError> { + Ok(Snapshot::journal(self).to_vec()) + } + fn all_attachments(&self) -> Result, StoreError> { Snapshot::attachments(self, None) } @@ -84,4 +134,16 @@ impl PackageBooks for Snapshot { fn attachment_bytes(&self, attachment_id: u64) -> Result, StoreError> { Snapshot::attachment_bytes(self, attachment_id) } + + fn statements(&self) -> Result, StoreError> { + Snapshot::statements(self, None) + } + + fn reconciliations(&self) -> Result, StoreError> { + Snapshot::reconciliations(self, None) + } + + fn disk_check(&self) -> DiskCheck { + Snapshot::check_disk(self) + } } diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs index a6819cf..578f6cd 100644 --- a/crates/ledger-package/src/lib.rs +++ b/crates/ledger-package/src/lib.rs @@ -23,6 +23,7 @@ //! whichever [`PackageBooks`] they are read through. //! ``` +mod attention; mod books; mod iif; mod invoice; @@ -33,6 +34,10 @@ mod text; mod tins; mod vendor_1099; +pub use attention::{ + Attention, Integrity, Report, Severity, Target, files_1099, find, find_with, reconcile_prompt, + w9_missing, w9_prompt, +}; pub use books::PackageBooks; pub use iif::export_iif; pub use invoice::invoice_document; diff --git a/crates/ledger-package/tests/package.rs b/crates/ledger-package/tests/package.rs index 164e2f5..7163246 100644 --- a/crates/ledger-package/tests/package.rs +++ b/crates/ledger-package/tests/package.rs @@ -1264,6 +1264,9 @@ impl PackageBooks for AtSeq<'_> { fn journal_seq(&self) -> u64 { self.1 } + fn journal(&self) -> Result, ledger_store::StoreError> { + PackageBooks::journal(self.0) + } fn all_attachments(&self) -> Result, ledger_store::StoreError> { PackageBooks::all_attachments(self.0) } @@ -1273,6 +1276,14 @@ impl PackageBooks for AtSeq<'_> { fn attachment_bytes(&self, id: u64) -> Result, ledger_store::StoreError> { PackageBooks::attachment_bytes(self.0, id) } + fn statements(&self) -> Result, ledger_store::StoreError> { + PackageBooks::statements(self.0) + } + fn reconciliations( + &self, + ) -> Result, ledger_store::StoreError> { + PackageBooks::reconciliations(self.0) + } } /// [`books`] with equipment accounts and a bank fee dated `as_of` with its diff --git a/docs/architecture.md b/docs/architecture.md index 154acd9..da798ff 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -81,6 +81,20 @@ appended journal lines; the UI renders an immutable view model rebuilt per change. Statements and series come from `ledger-core` (`reports`, `analysis`); the GUI only arranges them. See `plan/gui.md`. +## Shared read-only views (`ledger-package`) + +`ledger-package` is the shared read-only view: packages, the invoice PDF, +and Needs attention, over `Store` or `Snapshot`. Its `PackageBooks` trait +is what those builders read of the books (ledger, settings, journal, +attachments and their links and bytes, statements, reconciliations), so the +server and the viewer make the same package, the same invoice, and the same +attention list from one piece of code; nothing in it writes. The viewer's +`attention` module only maps each item's `Target` to its own route, and +`books.start` turns the items into its next steps, so the human's list and +the agent's cannot drift apart. The crate depends inward (`ledger-core`, +`ledger-store`, `ledger-config`, `ledger-import`, `ledger-vault`), never on +`ledger-mcp` or `ledger-gui`. + ## The journal format gate (`ledger-store`, P0) Every journal line passes a gate after its parse, seq and chain checks diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 01b8f77..ebb9d31 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -124,7 +124,12 @@ settings-and-conventions section says how to use them. onboarding step (`books.handbook {section: "contract"}`, when the chart is empty or `focus` is `onboarding`); `books.open_items` (an item due within 30 days or overdue); `books.backup` (none set up, or one stale, - failing, paused or failing its drill); the focus's step (`close`: + failing, paused or failing its drill); then Needs attention, by + severity, at most 12, each one `{tool, args?, why}` whose `why` is the + item's title (the same list the viewer draws: `ledger_package:: + attention`, so the two can't differ; `books.verify` items are the + server's own damage check and are not repeated); then the focus's step + (`close`: `books.conventions {kind: "close_step"}`; `audit`: `books.verify`); then always `books.handbook {section: "contract"}` and `books.digest`. `focus` is `daily` (the default), `onboarding`, `close` or `audit`; diff --git a/docs/parity.md b/docs/parity.md index ab4f4a5..665b723 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -63,6 +63,16 @@ D-46); no file with a full TIN is filed into the books (B-26); the agent is told the rules in force in a short connect text and a handbook, and no agent text points at the books' files, git or the repo. +## U0b: one attention engine (D-53, 2026-10-26) + +No semantic feature changed, so no status moved and the floor stands (10 +done). Needs attention used to be computed by the viewer alone; it now +lives in `ledger-package::attention`, over `PackageBooks`, and both the +viewer and `books.start` read it (the same items, the same prompts), so the +list the human sees and the next steps the agent gets cannot drift. The +parity test `the_viewer_and_books_start_see_the_same_attention` compares +them item for item on the demo books and on a book written entry by entry. + ## Capability parity (AI-first phase) Semantic parity (above) is about *correctness*: the engine computes the diff --git a/docs/testing.md b/docs/testing.md index 037d7ee..125c099 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -2915,3 +2915,50 @@ example" arm; the adapter's `event_of`, its `depreciation_schedule` month span (acquisition through as-of, 0.00 where the schedule reaches no month) and every `answer_*` figure against the loader's same check (the two must agree on all 56 examples). + +## U0b: one attention engine over `PackageBooks` (2026-10-26) + +Needs attention moved out of the viewer into `ledger-package::attention` +(D-53): one function, `find`/`find_with(books, today, settings, integrity)`, +over the `PackageBooks` trait (`Store` and `Snapshot`). The viewer's +`ledger-gui::attention::find` is now a mapping (`Target` → `Route`, +`Severity` → the viewer's); `books.start` turns the same items into next +steps, `why` equal to the title, at most 12. `PackageBooks` gained +`settings`, `journal`, `statements`, `reconciliations` and `disk_check`, so +the engine can read an entry's origin from the journal's event types (as +the viewer's builder did) and the same statements, reconciliations and +database check through either view. + +- **ledger-package (unit tests in `src/attention.rs`):** none of its own + (the engine's conditions are exercised end to end); the crate's tests + (`cargo test -p ledger-package`) keep the package over `Store` and + `Snapshot` equal. +- **ledger-gui:** `tests/view_model.rs` passes unchanged (the viewer's + list is the engine's); `src/attention.rs`'s unit tests + (`cash_flow_warnings_are_items`, + `a_stale_rule_asks_for_the_authority_and_the_humans_confirmation`) move + with it and pass; `tests/assets.rs`, `tests/attachments.rs`, + `tests/edges.rs`, `tests/old_books.rs`, `tests/parties.rs`, + `tests/spec_gui.rs` exercise the items through the viewer's wrapper. +- **ledger-cli:** `tests/attention_parity.rs` + `the_viewer_and_books_start_see_the_same_attention` (the demo books and a + book written entry by entry: the viewer's titles, in order, are exactly + `books.start`'s attention steps). +- **unchanged:** `crates/ledger-cli/tests/spec_kept.rs`'s `books.start` + tests (the fixed steps keep their order; attention items sit between the + backup step and the focus). + +**For P12 (mutation targets, none run, D-33):** +`ledger-package attention`: `origin`'s arm order (a reversal before its +schedule, a bill before its payment) and `entry_refs`' keys; +`missing_receipt`'s conjunction (the `Journal entry | Recurring` origin, +`reversal_of` none, no receipts, the policy); the `> 3` combine and its +`try_fold`; `w9_missing`'s three clauses (no W-9, files a 1099, the vendor +not marked on file) and `files_1099`'s `any`; `paid_this_year`'s `max` +(today) and `min` (Dec 31) and its `paid - received`; `depreciation_due`'s +`in_effect` filter, its `period_month` (`1..=12`) and its `!is_zero`; +`period_month`'s split; the severity arms (undetermined 1099, refuse +tax category, parties require); `dollars`' thousands grouping and its +negative-zero test; `filter(|a| a.tool.is_some()).take(12)` and +`step_title`'s 160-character cut in `start_out`; the viewer wrapper's +`Target` arms. diff --git a/plan/served.md b/plan/served.md index 425a5c8..c285b80 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1231,7 +1231,29 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns `crates/ledger-package/src/attention.rs`, `crates/ledger-gui/src/attention.rs`; touches `crates/ledger-package/src/books.rs`, `crates/ledger-gui/src/books.rs`, `crates/ledger-mcp/src/tools/kept.rs`, `docs/architecture.md`. 6. **Tests and proofs:** `crates/ledger-gui/tests/view_model.rs` passes unchanged (the viewer's list is the engine's); new `crates/ledger-cli/tests/attention_parity.rs::the_viewer_and_books_start_see_the_same_attention` (on the demo books and on a random book: the viewer's items equal the server's, item for item; pattern `projects_parity.rs`); the existing `spec_kept.rs` tests of `books.start` pass unchanged. 7. **Done when:** (1) `ledger-gui` no longer computes any attention item itself; (2) `books.start` lists the same items the viewer shows, proven by the parity test; (3) `just check` green; (4) no new crate and no new trait beyond `PackageBooks`. -8. **As built:** none yet. +8. **As built (2026-10-26):** + - `PackageBooks` grew `journal`, `statements`, `reconciliations` and + `disk_check` beside `settings` (the Build named only `settings`): the + engine reads an entry's origin from the journal's event types (as the + viewer's builder did), and the viewer's statements, reconciliations + and database check, so a `Store` and a `Snapshot` answer alike. + - `Attention` keeps the viewer's `prompt` beside the plan's `kind` and + `tool`: the viewer draws the shared text and the server turns the same + item into a next step without a second copy of the prompts. + - `find_with` takes the viewer's already-computed `Integrity` (the + loader checks the database and the stored files once per refresh); + `find` computes it for callers with none. + - `books.start` lists at most 12 attention steps (the eventual + `needs_attention` top 12; counts by kind come with P5), each step's + `why` cut to 160 characters (a free-text description could otherwise + push the result past 8 KB), and passes `Integrity::default()`: the + server's own damage check is `books.verify`. + - The W-9 and reconciliation prompts and the 1099 predicate moved to + `ledger-package`; `pages/parties.rs`, `pages/reconciliation.rs` and + `Books::files_1099` delegate to them (two files beyond the Build's + list, to keep one copy of each text). + - `ledger-package` gained `ledger-config` and `serde_json` (the receipt + policy the items use, and the tool arguments they carry). #### U0c: testing notes for S1-B, S2, S3, and stale notes in the tool docs