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.
This commit is contained in:
1 parent
eeee9658da
commit
0cf2f8f5c5
17 files changed
+2026
-895
No files matched your search
Generated
+2
@@ -4042,12 +4042,14 @@ name = "ledger-package"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"chrono",
|
"chrono",
|
||||||
|
"ledger-config",
|
||||||
"ledger-core",
|
"ledger-core",
|
||||||
"ledger-import",
|
"ledger-import",
|
||||||
"ledger-store",
|
"ledger-store",
|
||||||
"ledger-vault",
|
"ledger-vault",
|
||||||
"proptest",
|
"proptest",
|
||||||
"rust_decimal",
|
"rust_decimal",
|
||||||
|
"serde_json",
|
||||||
"tempfile",
|
"tempfile",
|
||||||
"toml 1.1.8+spec-1.1.0",
|
"toml 1.1.8+spec-1.1.0",
|
||||||
"zip 9.0.2",
|
"zip 9.0.2",
|
||||||
|
|||||||
@@ -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<String> = 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<String> = 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
+114
-856
@@ -1,871 +1,129 @@
|
|||||||
//! What the books need: drafts left unposted, missing receipts, schedules
|
//! "Needs attention", mapped for the viewer: the items come from
|
||||||
//! and recurring entries due, open past periods, uncategorized tax lines,
|
//! `ledger_package::attention` (the same function `books.start` reads), and
|
||||||
//! 1099 vendors without a W-9 or without a 1099 decision, payments whose
|
//! this module only turns each `Target` into the viewer's [`Route`] and each
|
||||||
//! method the 1099 worksheet needs, overdue bills and invoices, money owed to
|
//! severity into the viewer's own.
|
||||||
//! owners, fixed asset depreciation not posted, assets without a tag or a
|
//!
|
||||||
//! tax basis, contractors and landlords paid without W-9 facts, bank and
|
//! ```text
|
||||||
//! card accounts not reconciled through last month, and legal
|
//! GOAL: the viewer never computes an attention item itself; it draws the
|
||||||
//! rules (like the 1099 threshold) due for an update. Each item says
|
//! ones the shared engine found, so the list can't drift from the
|
||||||
//! what's wrong, where to look, and the prompt that
|
//! server's next steps.
|
||||||
//! asks the agent to fix it (the GUI never changes the books itself).
|
//! 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::books::{Attention, Books, Severity};
|
||||||
use crate::fmt;
|
use crate::nav::{ReportKind, Route};
|
||||||
use crate::nav::Route;
|
use ledger_store::{
|
||||||
use chrono::Datelike;
|
Attachment, DiskCheck, JournalLine, Reconciliation, Statement, StoreError, Subject,
|
||||||
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<Route>) -> 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()
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Everything the books need, as items to show.
|
/// 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 <account> through <date>", 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<Attention> {
|
pub fn find(b: &Books) -> Vec<Attention> {
|
||||||
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<String> = 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<String> = 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<ledger_core::EntryId> = 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::<Vec<_>>()
|
|
||||||
.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<Vec<ledger_core::AgingRow>, ledger_core::LedgerError>| {
|
|
||||||
let too_large = || ledger_core::LedgerError::TooLargeToCompute("the overdue total".into());
|
|
||||||
let late: Vec<Decimal> = 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::<Result<Vec<_>, _>>()?
|
|
||||||
.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<String> = 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<String> = 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 {
|
let settings = ledger_package::Settings {
|
||||||
company: &b.settings.company,
|
company: &b.settings.company,
|
||||||
cash_accounts: &b.settings.cash_accounts,
|
cash_accounts: &b.settings.cash_accounts,
|
||||||
financing_accounts: &b.settings.financing_accounts,
|
financing_accounts: &b.settings.financing_accounts,
|
||||||
investing_accounts: &b.settings.investing_accounts,
|
investing_accounts: &b.settings.investing_accounts,
|
||||||
};
|
};
|
||||||
let (fy_start, _) = b.fiscal_ytd();
|
let integrity = ledger_package::Integrity {
|
||||||
if let Ok(cf) = ledger_package::cash_flow_for(
|
disk: b.integrity.disk.clone(),
|
||||||
&b.ledger,
|
file_failures: b.integrity.file_failures.clone(),
|
||||||
&settings,
|
};
|
||||||
Some(&fy_start.to_string()),
|
ledger_package::find_with(&View(b), b.today, &settings, &integrity)
|
||||||
Some(&today.to_string()),
|
.into_iter()
|
||||||
&[],
|
.map(|a| Attention {
|
||||||
&[],
|
severity: severity(a.severity),
|
||||||
&[],
|
title: a.title,
|
||||||
today,
|
detail: a.detail,
|
||||||
) {
|
target: route(a.target),
|
||||||
for w in cf.warnings {
|
prompt: a.prompt,
|
||||||
let sold = w.kind == ledger_core::CashFlowWarningKind::AssetSoldOnOperatingReceivable;
|
})
|
||||||
out.push(Attention {
|
.collect()
|
||||||
severity: if sold { Severity::Todo } else { Severity::Info },
|
}
|
||||||
title: if sold {
|
|
||||||
format!("Equipment sold for an IOU: entry #{}", w.entry)
|
fn severity(s: ledger_package::Severity) -> Severity {
|
||||||
} else {
|
match s {
|
||||||
format!("Equipment bought on an operating payable: entry #{}", w.entry)
|
ledger_package::Severity::Problem => Severity::Problem,
|
||||||
},
|
ledger_package::Severity::Todo => Severity::Todo,
|
||||||
detail: w.message.clone(),
|
ledger_package::Severity::Info => Severity::Info,
|
||||||
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
|
fn route(t: ledger_package::Target) -> Option<Route> {
|
||||||
),
|
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<Vec<JournalLine>, StoreError> {
|
||||||
|
Ok(self.0.journal.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError> {
|
||||||
|
Ok(self.0.attachments.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn links_of(&self, attachment_id: u64) -> Result<Vec<Subject>, StoreError> {
|
||||||
|
Ok(self.0.links_of(attachment_id).to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, 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<Vec<Statement>, StoreError> {
|
||||||
|
Ok(self.0.statements.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconciliations(&self) -> Result<Vec<Reconciliation>, 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)]
|
#[cfg(test)]
|
||||||
|
|||||||
@@ -1057,12 +1057,11 @@ impl Books {
|
|||||||
|
|
||||||
/// Whether a party files a 1099: it has a 1099 role (contractor,
|
/// Whether a party files a 1099: it has a 1099 role (contractor,
|
||||||
/// landlord), or its vendor record has been decided for one (from any
|
/// 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 {
|
pub fn files_1099(&self, p: &Party) -> bool {
|
||||||
p.roles.iter().any(|r| r.gets_1099())
|
ledger_package::files_1099(&self.ledger, p)
|
||||||
|| 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()))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The 1099 report for calendar `year`, the server's
|
/// The 1099 report for calendar `year`, the server's
|
||||||
|
|||||||
@@ -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.
|
/// The agent prompt that collects and records a party's W-9.
|
||||||
pub fn collect_w9_prompt(b: &Books, p: &Party) -> String {
|
pub fn collect_w9_prompt(b: &Books, p: &Party) -> String {
|
||||||
let paid = b.party(p.id).map(|i| i.paid_ytd).unwrap_or_default();
|
ledger_package::w9_prompt(
|
||||||
let paid = if paid > Decimal::ZERO {
|
p,
|
||||||
format!(", paid {} so far in {}", fmt::dollars(paid), b.today.year())
|
b.party(p.id).map(|i| i.paid_ytd).unwrap_or_default(),
|
||||||
} else {
|
b.today.year(),
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -33,13 +33,10 @@ use rust_decimal::Decimal;
|
|||||||
/// 2. Says where it last stood: reconciled through a date, or never.
|
/// 2. Says where it last stood: reconciled through a date, or never.
|
||||||
/// ```
|
/// ```
|
||||||
pub fn reconcile_prompt(b: &Books, account: AccountId, through: NaiveDate) -> String {
|
pub fn reconcile_prompt(b: &Books, account: AccountId, through: NaiveDate) -> String {
|
||||||
let since = match b.reconciled_through(account) {
|
ledger_package::reconcile_prompt(
|
||||||
Some(d) => format!("It is reconciled through {d}, so start with the statement after that"),
|
&b.account_label(account),
|
||||||
None => "It has never been reconciled, so start with its first statement".to_string(),
|
b.reconciled_through(account),
|
||||||
};
|
through,
|
||||||
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)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -761,6 +761,18 @@ impl BooksServer {
|
|||||||
args,
|
args,
|
||||||
why: why.into(),
|
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();
|
let mut next_steps = Vec::new();
|
||||||
// Books an older version wrote wait for the owner's go-ahead to
|
// Books an older version wrote wait for the owner's go-ahead to
|
||||||
// upgrade their format: first, at every start until it is given
|
// 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 {
|
match focus {
|
||||||
"close" => next_steps.push(step(
|
"close" => next_steps.push(step(
|
||||||
"books.conventions",
|
"books.conventions",
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ description = "wanderbooks' CPA handoff package and the reports and worksheets i
|
|||||||
[dependencies]
|
[dependencies]
|
||||||
ledger-core = { path = "../ledger-core" }
|
ledger-core = { path = "../ledger-core" }
|
||||||
ledger-store = { path = "../ledger-store" }
|
ledger-store = { path = "../ledger-store" }
|
||||||
|
ledger-config = { path = "../ledger-config" }
|
||||||
ledger-import = { path = "../ledger-import" }
|
ledger-import = { path = "../ledger-import" }
|
||||||
ledger-vault = { path = "../ledger-vault" }
|
ledger-vault = { path = "../ledger-vault" }
|
||||||
chrono = "0.4"
|
chrono = "0.4"
|
||||||
rust_decimal = "1"
|
rust_decimal = "1"
|
||||||
|
serde_json = "1"
|
||||||
zip = "9.0.1"
|
zip = "9.0.1"
|
||||||
|
|
||||||
[lints]
|
[lints]
|
||||||
|
|||||||
File diff suppressed because it is too large.
Load diff
@@ -9,22 +9,40 @@
|
|||||||
//! INVARIANTS:
|
//! INVARIANTS:
|
||||||
//! 1. Every method only reads.
|
//! 1. Every method only reads.
|
||||||
//! 2. A Store and a Snapshot of the same journal answer every method
|
//! 2. A Store and a Snapshot of the same journal answer every method
|
||||||
//! alike: the same ledger, journal seq, attachments (withdrawn ones
|
//! alike: the same ledger, settings, journal, journal seq,
|
||||||
//! included), entry links, and bytes.
|
//! 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_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 {
|
pub trait PackageBooks {
|
||||||
/// The ledger every figure comes from.
|
/// The ledger every figure comes from.
|
||||||
fn ledger(&self) -> &Ledger;
|
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
|
/// Sequence number of the last journal line: the journal state the
|
||||||
/// package reflects.
|
/// package reflects.
|
||||||
fn journal_seq(&self) -> u64;
|
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<Vec<JournalLine>, StoreError>;
|
||||||
|
|
||||||
/// Every attachment (receipts and documents), withdrawn ones
|
/// Every attachment (receipts and documents), withdrawn ones
|
||||||
/// included, in id order.
|
/// included, in id order.
|
||||||
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError>;
|
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError>;
|
||||||
@@ -36,6 +54,20 @@ pub trait PackageBooks {
|
|||||||
|
|
||||||
/// An attachment's bytes, verified against its hash.
|
/// An attachment's bytes, verified against its hash.
|
||||||
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError>;
|
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError>;
|
||||||
|
|
||||||
|
/// Recorded bank and card statements, by period end.
|
||||||
|
fn statements(&self) -> Result<Vec<Statement>, StoreError>;
|
||||||
|
|
||||||
|
/// Reconciliations, voided ones included, by date then id.
|
||||||
|
fn reconciliations(&self) -> Result<Vec<Reconciliation>, 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 {
|
impl PackageBooks for Store {
|
||||||
@@ -47,6 +79,12 @@ impl PackageBooks for Store {
|
|||||||
Store::journal_seq(self)
|
Store::journal_seq(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn journal(&self) -> Result<Vec<JournalLine>, StoreError> {
|
||||||
|
ledger_store::journal::read_and_verify_journal(
|
||||||
|
&Store::data_dir(self).join(ledger_store::JOURNAL_FILE),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError> {
|
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError> {
|
||||||
Store::list_attachments(self, None)
|
Store::list_attachments(self, None)
|
||||||
}
|
}
|
||||||
@@ -58,6 +96,14 @@ impl PackageBooks for Store {
|
|||||||
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||||
Store::attachment_bytes(self, attachment_id)
|
Store::attachment_bytes(self, attachment_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn statements(&self) -> Result<Vec<Statement>, StoreError> {
|
||||||
|
Store::statements(self, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconciliations(&self) -> Result<Vec<Reconciliation>, StoreError> {
|
||||||
|
Store::reconciliations(self, None)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PackageBooks for Snapshot {
|
impl PackageBooks for Snapshot {
|
||||||
@@ -69,6 +115,10 @@ impl PackageBooks for Snapshot {
|
|||||||
Snapshot::journal_seq(self)
|
Snapshot::journal_seq(self)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn journal(&self) -> Result<Vec<JournalLine>, StoreError> {
|
||||||
|
Ok(Snapshot::journal(self).to_vec())
|
||||||
|
}
|
||||||
|
|
||||||
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError> {
|
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError> {
|
||||||
Snapshot::attachments(self, None)
|
Snapshot::attachments(self, None)
|
||||||
}
|
}
|
||||||
@@ -84,4 +134,16 @@ impl PackageBooks for Snapshot {
|
|||||||
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||||
Snapshot::attachment_bytes(self, attachment_id)
|
Snapshot::attachment_bytes(self, attachment_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn statements(&self) -> Result<Vec<Statement>, StoreError> {
|
||||||
|
Snapshot::statements(self, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn reconciliations(&self) -> Result<Vec<Reconciliation>, StoreError> {
|
||||||
|
Snapshot::reconciliations(self, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn disk_check(&self) -> DiskCheck {
|
||||||
|
Snapshot::check_disk(self)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -23,6 +23,7 @@
|
|||||||
//! whichever [`PackageBooks`] they are read through.
|
//! whichever [`PackageBooks`] they are read through.
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
|
mod attention;
|
||||||
mod books;
|
mod books;
|
||||||
mod iif;
|
mod iif;
|
||||||
mod invoice;
|
mod invoice;
|
||||||
@@ -33,6 +34,10 @@ mod text;
|
|||||||
mod tins;
|
mod tins;
|
||||||
mod vendor_1099;
|
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 books::PackageBooks;
|
||||||
pub use iif::export_iif;
|
pub use iif::export_iif;
|
||||||
pub use invoice::invoice_document;
|
pub use invoice::invoice_document;
|
||||||
|
|||||||
@@ -1264,6 +1264,9 @@ impl PackageBooks for AtSeq<'_> {
|
|||||||
fn journal_seq(&self) -> u64 {
|
fn journal_seq(&self) -> u64 {
|
||||||
self.1
|
self.1
|
||||||
}
|
}
|
||||||
|
fn journal(&self) -> Result<Vec<ledger_store::JournalLine>, ledger_store::StoreError> {
|
||||||
|
PackageBooks::journal(self.0)
|
||||||
|
}
|
||||||
fn all_attachments(&self) -> Result<Vec<ledger_store::Attachment>, ledger_store::StoreError> {
|
fn all_attachments(&self) -> Result<Vec<ledger_store::Attachment>, ledger_store::StoreError> {
|
||||||
PackageBooks::all_attachments(self.0)
|
PackageBooks::all_attachments(self.0)
|
||||||
}
|
}
|
||||||
@@ -1273,6 +1276,14 @@ impl PackageBooks for AtSeq<'_> {
|
|||||||
fn attachment_bytes(&self, id: u64) -> Result<Vec<u8>, ledger_store::StoreError> {
|
fn attachment_bytes(&self, id: u64) -> Result<Vec<u8>, ledger_store::StoreError> {
|
||||||
PackageBooks::attachment_bytes(self.0, id)
|
PackageBooks::attachment_bytes(self.0, id)
|
||||||
}
|
}
|
||||||
|
fn statements(&self) -> Result<Vec<ledger_store::Statement>, ledger_store::StoreError> {
|
||||||
|
PackageBooks::statements(self.0)
|
||||||
|
}
|
||||||
|
fn reconciliations(
|
||||||
|
&self,
|
||||||
|
) -> Result<Vec<ledger_store::Reconciliation>, ledger_store::StoreError> {
|
||||||
|
PackageBooks::reconciliations(self.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// [`books`] with equipment accounts and a bank fee dated `as_of` with its
|
/// [`books`] with equipment accounts and a bank fee dated `as_of` with its
|
||||||
|
|||||||
@@ -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`,
|
change. Statements and series come from `ledger-core` (`reports`,
|
||||||
`analysis`); the GUI only arranges them. See `plan/gui.md`.
|
`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)
|
## The journal format gate (`ledger-store`, P0)
|
||||||
|
|
||||||
Every journal line passes a gate after its parse, seq and chain checks
|
Every journal line passes a gate after its parse, seq and chain checks
|
||||||
|
|||||||
@@ -124,7 +124,12 @@ settings-and-conventions section says how to use them.
|
|||||||
onboarding step (`books.handbook {section: "contract"}`, when the chart
|
onboarding step (`books.handbook {section: "contract"}`, when the chart
|
||||||
is empty or `focus` is `onboarding`); `books.open_items` (an item due
|
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,
|
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
|
`books.conventions {kind: "close_step"}`; `audit`: `books.verify`); then
|
||||||
always `books.handbook {section: "contract"}` and `books.digest`.
|
always `books.handbook {section: "contract"}` and `books.digest`.
|
||||||
`focus` is `daily` (the default), `onboarding`, `close` or `audit`;
|
`focus` is `daily` (the default), `onboarding`, `close` or `audit`;
|
||||||
|
|||||||
@@ -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
|
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.
|
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)
|
## Capability parity (AI-first phase)
|
||||||
|
|
||||||
Semantic parity (above) is about *correctness*: the engine computes the
|
Semantic parity (above) is about *correctness*: the engine computes the
|
||||||
|
|||||||
@@ -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)
|
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
|
and every `answer_*` figure against the loader's same check (the two must
|
||||||
agree on all 56 examples).
|
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.
|
||||||
+23
-1
@@ -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`.
|
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.
|
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`.
|
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
|
#### U0c: testing notes for S1-B, S2, S3, and stale notes in the tool docs
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user