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
@@ -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
|
||||
//! 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<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()
|
||||
),
|
||||
}
|
||||
}
|
||||
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 <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> {
|
||||
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 {
|
||||
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<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)]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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]
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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<Vec<JournalLine>, StoreError>;
|
||||
|
||||
/// Every attachment (receipts and documents), withdrawn ones
|
||||
/// included, in id order.
|
||||
fn all_attachments(&self) -> Result<Vec<Attachment>, StoreError>;
|
||||
@@ -36,6 +54,20 @@ pub trait PackageBooks {
|
||||
|
||||
/// An attachment's bytes, verified against its hash.
|
||||
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 {
|
||||
@@ -47,6 +79,12 @@ impl PackageBooks for Store {
|
||||
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> {
|
||||
Store::list_attachments(self, None)
|
||||
}
|
||||
@@ -58,6 +96,14 @@ impl PackageBooks for Store {
|
||||
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||
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 {
|
||||
@@ -69,6 +115,10 @@ impl PackageBooks for Snapshot {
|
||||
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> {
|
||||
Snapshot::attachments(self, None)
|
||||
}
|
||||
@@ -84,4 +134,16 @@ impl PackageBooks for Snapshot {
|
||||
fn attachment_bytes(&self, attachment_id: u64) -> Result<Vec<u8>, StoreError> {
|
||||
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.
|
||||
//! ```
|
||||
|
||||
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;
|
||||
|
||||
@@ -1264,6 +1264,9 @@ impl PackageBooks for AtSeq<'_> {
|
||||
fn journal_seq(&self) -> u64 {
|
||||
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> {
|
||||
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> {
|
||||
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
|
||||
|
||||
Reference in new issue
Block a user