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:
1 parent
bc3ab779e7
commit
c09325ef8c
52 files changed
+14050
-113
No files matched your search
@@ -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
|
||||
}
|
||||
Reference in new issue
Block a user