feat: ledger-gui — a read-only, live desktop view of the books

egui/wgpu app for owners and their accountant: Overview, Cash, Profit,
Transactions (with rendered receipts and journal history), Reports
(statements with interval columns, comparisons, drill-down), Accounts,
Documents, Company, Journal; Ctrl+K palette; back/forward; light/dark.
Follows the journal live (~30 ms from write to screen). --demo opens a
deterministic demo company, also used for screenshot tests.

Engine: ledger-store Snapshot (read-only, incremental, tolerant of a
half-written tail); ledger-core analysis (intervals, balance series,
P&L by interval, cash flows); ledger-config extracted from ledger-mcp
with the full company profile and cash_accounts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MtYEaR6M6Ybdhm6X98BWNu
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-12 16:20:14 -07:00
1 parent bc3ab779e7
commit c09325ef8c
52 files changed
+14050 -113

No files matched your search

+265
View File
@@ -0,0 +1,265 @@
//! 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, overdue bills and invoices, money owed to
//! owners. Each item says what's wrong, where to look, and the prompt that
//! asks the agent to fix it (the GUI never changes the books itself).
use crate::books::{Attention, Books, Severity};
use crate::fmt;
use crate::nav::Route;
use chrono::Datelike;
use ledger_core::reports::profit_and_loss_for_period;
use ledger_core::{AccountType, EntryStatus, PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
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(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(e.amount)
),
});
}
let missing: Vec<_> = b
.entries
.iter()
.filter(|e| e.status == EntryStatus::Posted && e.missing_receipt)
.collect();
if missing.len() > 3 {
let total: Decimal = missing.iter().map(|e| e.amount).sum();
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(), "expense", "expenses")),
detail: format!(
"{} of spending over the {} threshold has no receipt attached.",
fmt::dollars(total),
fmt::dollars(b.receipt_threshold)
),
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!(
"#{} · {} · {} is over the {} receipt threshold.",
e.id,
fmt::date(e.date),
fmt::dollars(e.amount),
fmt::dollars(b.receipt_threshold)
),
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(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() {
let due = s.due(today);
if !due.is_empty() {
let total: Decimal = due.iter().map(|(_, a)| *a).sum();
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
),
});
}
}
let (fy_start, _) = b.fiscal_ytd();
if let Ok(pl) = profit_and_loss_for_period(&b.ledger, fy_start, today, None) {
let uncategorized: Vec<String> = pl
.revenue
.iter()
.chain(&pl.expenses)
.filter(|l| {
b.ledger
.chart()
.find_by_code(&l.code)
.is_some_and(|a| a.tax_category.is_none())
})
.map(|l| format!("{} {}", l.code, l.name))
.collect();
if !uncategorized.is_empty() {
out.push(Attention {
severity: Severity::Info,
title: format!(
"{} without a tax category",
fmt::count(uncategorized.len(), "account", "accounts")
),
detail: format!(
"{}. The tax summary lists them as uncategorized.",
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(", ")
),
});
}
}
let mut years = vec![today.year()];
if today.month() == 1 {
years.insert(0, today.year() - 1);
}
for year in years {
let r = vendor_1099_report(&b.ledger, year, None);
for v in r.vendors.iter().filter(|v| v.reportable && !v.tin_on_file) {
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(Route::Company),
prompt: format!(
"Remind me to request a W-9 from {}; once I have it, mark it on file in wanderbooks with books.set_vendor_1099.",
v.name
),
});
}
}
let overdue = |rows: Vec<ledger_core::AgingRow>| -> (usize, Decimal) {
let late: Vec<Decimal> = rows
.iter()
.map(|r| r.days_30 + r.days_60 + r.days_90 + r.days_90_plus)
.filter(|d| !d.is_zero())
.collect();
(late.len(), late.iter().sum())
};
let (n, total) = overdue(b.ledger.ap_aging(today));
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) = overdue(b.ledger.ar_aging(today));
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
),
});
}
}
out.sort_by_key(|a| a.severity);
out
}