Files
wanderbooks/crates/ledger-gui/src/pages/company.rs
T
swaitsandClaude Opus 5.5 105fd52694 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
2026-10-12 16:20:14 -07:00

494 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The company: who it is, who owns it, and the standing machinery of the
//! books — periods, fixed assets, amortization schedules, recurring
//! entries, vendors and customers.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::fmt;
use crate::nav::Route;
use crate::theme::{self, pal};
use crate::widgets;
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::assets::{accumulated_depreciation, book_value};
use ledger_core::{PeriodStatus, vendor_1099_report};
use rust_decimal::Decimal;
fn kv(ui: &mut Ui, k: &str, v: &str) {
let p = pal(ui.ctx());
ui.horizontal(|ui| {
let (r, _) = ui.allocate_exact_size(vec2(180.0, 20.0), Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
k,
theme::regular(13.0),
p.muted,
);
ui.add(egui::Label::new(RichText::new(v).font(theme::regular(13.0))).wrap());
});
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "company", |ui| {
let c = &b.settings.company;
widgets::page_header(
ui,
&c.name,
"The company's profile and the standing parts of its books.",
);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Profile",
|_| {},
|ui| {
if c.profile.is_empty() {
ui.label(
RichText::new("No [company] settings in wanderbooks.toml.")
.color(p.muted),
);
}
for (k, v) in &c.profile {
kv(ui, &fmt::label(k), v);
}
kv(ui, "Accounting basis", &c.basis);
kv(
ui,
"Tax return",
c.tax_form.map(fmt::tax_form).unwrap_or("not set"),
);
kv(ui, "Receipt threshold", &fmt::dollars(b.receipt_threshold));
},
);
}
_ => {
widgets::titled_card(
ui,
"Owners",
|_| {},
|ui| {
if c.members.is_empty() {
// A single owner or a corporation: equity by account.
for a in b
.ledger
.chart()
.all()
.filter(|a| a.account_type == ledger_core::AccountType::Equity)
{
ui.horizontal(|ui| {
ui.label(
RichText::new(format!("{} {}", a.code, a.name))
.font(theme::regular(13.0)),
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::accounting(
b.balance_on(a.id, b.today),
))
.font(theme::semibold(13.0)),
);
},
);
});
}
}
for m in &c.members {
let cap = b.ledger.chart().find_by_code(&m.capital_account);
let bal = cap.map(|a| b.balance_on(a.id, b.today)).unwrap_or_default();
ui.horizontal(|ui| {
ui.label(RichText::new(&m.name).font(theme::semibold(13.5)));
widgets::pill(
ui,
&format!("{}%", (m.share * Decimal::from(100)).normalize()),
p.accent,
p.accent_soft,
);
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
ui.label(
RichText::new(fmt::dollars(bal))
.font(theme::semibold(13.5)),
);
},
);
});
if let Some(a) = cap {
ui.label(
RichText::new(format!("Capital account {} {}", a.code, a.name))
.font(theme::regular(12.0))
.color(p.muted),
);
}
ui.add_space(6.0);
}
},
);
ui.add_space(12.0);
widgets::titled_card(
ui,
"Periods",
|_| {},
|ui| {
let periods = b.ledger.list_periods();
if periods.is_empty() {
ui.label(RichText::new("No periods yet. Closing a period locks it and moves its earnings into equity.").color(p.muted));
}
for per in periods {
ui.horizontal(|ui| {
let (g, color) = match per.status {
PeriodStatus::Closed => (icon::LOCK, p.muted),
PeriodStatus::Open => (icon::LOCK_OPEN, p.warning),
};
ui.label(RichText::new(g).color(color));
ui.label(RichText::new(&per.name).font(theme::semibold(13.0)));
ui.label(
RichText::new(format!(
"{} – {}",
fmt::date(per.start),
fmt::date(per.end)
))
.color(p.muted),
);
if let Some(e) = per.closing_entry
&& widgets::link(
ui,
RichText::new(format!("closing entry #{e}"))
.color(p.accent),
)
.clicked()
{
shell.go(Route::Entry(e));
}
});
}
},
);
}
});
assets(ui, b, shell);
schedules(ui, b);
recurring(ui, b);
parties(ui, b);
});
}
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let assets: Vec<_> = b.ledger.registered_assets().collect();
if assets.is_empty() {
return;
}
let mut go = None;
widgets::titled_card(
ui,
"Fixed assets",
|_| {},
|ui| {
for a in &assets {
// What has actually been posted, so this ties to the balance
// sheet; the schedule may run ahead of the books.
let acc: Decimal = b
.journal
.iter()
.filter(|l| {
l.line_type == "asset.depreciation_posted"
&& l.payload.get("asset_id").and_then(|v| v.as_u64()) == Some(a.id)
})
.filter_map(|l| {
l.payload
.get("amount")
.and_then(|v| v.as_str())
.and_then(|s| s.parse::<Decimal>().ok())
})
.sum();
let bv = a.cost.amount - acc;
let scheduled = accumulated_depreciation(a, b.today);
let _ = book_value;
let dep = a.cost.amount - a.salvage_value.amount;
let frac: f32 = if dep.is_zero() {
1.0
} else {
(acc / dep).try_into().unwrap_or(0.0)
};
ui.horizontal(|ui| {
if widgets::link(
ui,
RichText::new(&a.description).font(theme::semibold(13.5)),
)
.clicked()
{
go = Some(a.cost_account);
}
ui.label(
RichText::new(format!(
"bought {} · {} months · {}",
fmt::date(a.purchase_date),
a.useful_life_months,
fmt::words(&format!("{:?}", a.status)).to_lowercase()
))
.font(theme::regular(12.0))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(
RichText::new(format!("book value {}", fmt::dollars(bv)))
.font(theme::semibold(13.0)),
);
});
});
let behind = scheduled - acc;
let note = if behind > Decimal::ZERO {
format!(" · {} more scheduled through today", fmt::dollars(behind))
} else {
String::new()
};
progress(
ui,
frac,
&format!(
"{} of {} depreciated{note}",
fmt::dollars(acc),
fmt::dollars(dep)
),
);
ui.add_space(8.0);
}
},
);
if let Some(id) = go {
shell.go(Route::Account(id));
}
ui.add_space(16.0);
}
fn progress(ui: &mut Ui, frac: f32, label: &str) {
let p = pal(ui.ctx());
let (r, _) = ui.allocate_exact_size(vec2(ui.available_width(), 8.0), Sense::hover());
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
let mut done = r;
done.set_width(r.width() * frac.clamp(0.0, 1.0));
ui.painter()
.rect_filled(done, CornerRadius::same(4), p.accent);
ui.label(
RichText::new(label)
.font(theme::regular(11.5))
.color(p.faint),
);
}
fn schedules(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let list: Vec<_> = b.ledger.schedules().collect();
if list.is_empty() {
return;
}
widgets::titled_card(
ui,
"Amortization schedules",
|_| {},
|ui| {
for s in list {
let total = s.total.amount;
let remaining = s.remaining();
let frac: f32 = if total.is_zero() {
1.0
} else {
((total - remaining) / total).try_into().unwrap_or(0.0)
};
let due = s.due(b.today);
ui.horizontal(|ui| {
ui.label(RichText::new(&s.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {} months from {}",
fmt::words(&format!("{:?}", s.kind)),
s.months,
fmt::month(s.start)
))
.font(theme::regular(12.0))
.color(p.muted),
);
if !due.is_empty() {
widgets::pill(ui, &format!("{} due", due.len()), p.warning, p.warning_soft);
}
});
progress(
ui,
frac,
&format!(
"{} of {} recognized · {} left",
fmt::dollars(total - remaining),
fmt::dollars(total),
fmt::dollars(remaining)
),
);
ui.add_space(8.0);
}
},
);
ui.add_space(16.0);
}
fn recurring(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let list: Vec<_> = b.ledger.recurring_entries().collect();
if list.is_empty() {
return;
}
widgets::titled_card(
ui,
"Recurring entries",
|_| {},
|ui| {
for r in list {
let amount: Decimal = r
.lines
.iter()
.filter(|(_, s, _)| *s == ledger_core::Side::Debit)
.map(|(_, _, m)| m.amount)
.sum();
let next = (0..)
.map_while(|k| {
r.frequency
.nth(r.start, k)
.filter(|d| r.end.is_none_or(|e| *d <= e))
})
.find(|d| !r.generated.contains(d));
ui.horizontal(|ui| {
ui.label(RichText::new(&r.description).font(theme::semibold(13.5)));
ui.label(
RichText::new(format!(
"{} · {}",
fmt::words(&format!("{:?}", r.frequency)),
fmt::dollars(amount)
))
.font(theme::regular(12.0))
.color(p.muted),
);
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
let text = match next {
Some(d) if d <= b.today => format!("due {}", fmt::date(d)),
Some(d) => format!("next {}", fmt::date(d)),
None => "ended".into(),
};
ui.label(RichText::new(text).font(theme::regular(12.5)).color(
if next.is_some_and(|d| d <= b.today) {
p.warning
} else {
p.muted
},
));
});
});
ui.label(
RichText::new(format!(
"{} generated so far · drafts only; each one is posted with its receipt",
r.generated.len()
))
.font(theme::regular(11.5))
.color(p.faint),
);
ui.add_space(6.0);
}
},
);
ui.add_space(16.0);
}
fn parties(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let year = chrono::Datelike::year(&b.today);
let r1099 = vendor_1099_report(&b.ledger, year, None);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => {
widgets::titled_card(
ui,
"Vendors",
|_| {},
|ui| {
let vendors = b.ledger.list_vendors();
if vendors.is_empty() {
ui.label(RichText::new("No vendors yet.").color(p.muted));
}
for v in vendors {
let paid = r1099
.vendors
.iter()
.find(|x| x.vendor_id == v.id)
.map(|x| x.paid)
.unwrap_or_default();
ui.horizontal(|ui| {
ui.label(RichText::new(&v.name).font(theme::semibold(13.0)));
if let Some(f) = v.form_1099 {
widgets::pill(ui, f.label(), p.accent, p.accent_soft);
if v.tin_on_file {
widgets::pill(ui, "W-9", p.positive, p.positive_soft);
} else {
widgets::pill(ui, "no W-9", p.negative, p.negative_soft);
}
}
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !paid.is_zero() {
ui.label(
RichText::new(format!(
"{} paid in {year}",
fmt::dollars(paid)
))
.font(theme::regular(12.5))
.color(p.muted),
);
}
},
);
});
}
},
);
}
_ => {
widgets::titled_card(
ui,
"Customers",
|_| {},
|ui| {
let customers = b.ledger.list_customers();
if customers.is_empty() {
ui.label(RichText::new("No customers yet.").color(p.muted));
}
for c in customers {
let owed: Decimal = (1..b.ledger.next_invoice_id())
.filter_map(|i| b.ledger.get_invoice(i))
.filter(|i| i.customer_id == c.id)
.map(|i| i.balance())
.sum();
ui.horizontal(|ui| {
ui.label(RichText::new(&c.name).font(theme::semibold(13.0)));
ui.with_layout(
egui::Layout::right_to_left(egui::Align::Center),
|ui| {
if !owed.is_zero() {
ui.label(
RichText::new(format!("owes {}", fmt::dollars(owed)))
.font(theme::regular(12.5))
.color(p.muted),
);
}
},
);
});
}
},
);
}
});
}