Files
wanderbooks/crates/ledger-gui/src/pages/profit.rs
T
swaitsandClaude Opus 5.5 c09325ef8c 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

378 lines
14 KiB
Rust

//! Profit: are we making money, how is it trending, and what moved.
//! Spending by category this month against the recent average, income by
//! customer and by class.
use super::{columns, page};
use crate::app::Shell;
use crate::books::Books;
use crate::charts::{self, BarGroup};
use crate::fmt;
use crate::nav::{ReportKind, Route};
use crate::theme::{self, pal};
use crate::widgets;
use chrono::{Days, Months};
use egui::{CornerRadius, RichText, Sense, Ui, vec2};
use egui_phosphor::regular as icon;
use ledger_core::analysis::{Interval, interval_start, pnl_by_interval};
use ledger_core::reports::profit_and_loss_for_period;
use rust_decimal::Decimal;
use std::collections::BTreeMap;
fn pct(part: Decimal, whole: Decimal) -> Option<Decimal> {
(!whole.is_zero()).then(|| (part / whole * Decimal::from(100)).round_dp(1))
}
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
page(ui, "profit", |ui| {
let (ys, ye) = b.fiscal_ytd();
let ytd = profit_and_loss_for_period(&b.ledger, ys, ye, None).ok();
let (rev, exp) = ytd
.as_ref()
.map(|p| (p.total_revenue, p.total_expenses))
.unwrap_or_default();
let net = rev - exp;
let margin = pct(net, rev);
let sentence = match margin {
Some(m) => format!(
"This year: {} of income, {} of spending, {} {} — a {}% margin.",
fmt::dollars(rev),
fmt::dollars(exp),
fmt::dollars(net.abs()),
if net >= Decimal::ZERO {
"profit"
} else {
"loss"
},
m
),
None if exp.is_zero() => "No income or spending recorded this year yet.".into(),
None => format!(
"This year: {} of spending and no income yet.",
fmt::dollars(exp)
),
};
widgets::page_header(ui, "Profit", &sentence);
let first = b.kpis.first_entry.unwrap_or(b.today);
let (start, end) = b.trailing_months(12);
let start = start.max(interval_start(first, Interval::Month));
let months = pnl_by_interval(&b.ledger, start, end, Interval::Month).unwrap_or_default();
widgets::titled_card(
ui,
"By month",
|_| {},
|ui| {
if months.is_empty() {
widgets::empty(ui, icon::CHART_BAR, "No months yet", "");
return;
}
let groups: Vec<BarGroup> = months
.iter()
.map(|m| BarGroup {
label: m.start.format("%b").to_string(),
values: vec![m.revenue, m.expenses, m.net()],
})
.collect();
if let Some(i) = charts::bar_chart(
ui,
&groups,
&[
("Income", p.series[1]),
("Spending", p.series[2]),
("Net", p.series[0]),
],
230.0,
) {
shell.state.reports.range =
crate::pages::reports::Range::Custom(months[i].start, months[i].end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
ui.add_space(10.0);
// The same numbers as a table, with the margin.
let w = ui.available_width();
let cw = 130.0;
let header = |ui: &mut Ui, cells: [&str; 5]| {
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
cells[0],
theme::semibold(12.0),
p.muted,
);
for (i, c) in cells[1..].iter().enumerate() {
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
};
header(ui, ["Month", "Income", "Spending", "Net", "Margin"]);
for m in months.iter().rev() {
let (r, resp) = ui.allocate_exact_size(vec2(w, 26.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
fmt::month(m.start),
theme::regular(13.0),
p.text,
);
let vals = [
fmt::money(m.revenue),
fmt::money(m.expenses),
fmt::accounting(m.net()),
pct(m.net(), m.revenue)
.map(|x| format!("{x}%"))
.unwrap_or_else(|| "—".into()),
];
for (i, v) in vals.iter().enumerate() {
let color = if i == 2 && m.net() < Decimal::ZERO {
p.negative
} else {
p.text
};
ui.painter().text(
egui::pos2(r.right() - cw * (3 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
v,
theme::regular(13.0),
color,
);
}
if resp.clicked() {
shell.state.reports.range =
crate::pages::reports::Range::Custom(m.start, m.end);
shell.go(Route::Reports(ReportKind::ProfitAndLoss));
}
}
},
);
ui.add_space(16.0);
spending_changes(ui, b, shell);
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
0 => by_customer(ui, b),
_ => by_class(ui, b),
});
});
}
/// Each expense account: this month so far, last month, the three-month
/// average, and how far this month is running from it.
fn spending_changes(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
let p = pal(ui.ctx());
let ms = b.month_start();
let prev =
profit_and_loss_for_period(&b.ledger, ms - Months::new(1), ms - Days::new(1), None).ok();
let three =
profit_and_loss_for_period(&b.ledger, ms - Months::new(3), ms - Days::new(1), None).ok();
let now = profit_and_loss_for_period(&b.ledger, ms, b.today, None).ok();
let mut rows: BTreeMap<String, (String, Decimal, Decimal, Decimal)> = BTreeMap::new();
let mut add = |s: &Option<ledger_core::ProfitAndLossStatement>, slot: usize| {
for l in s.iter().flat_map(|s| &s.expenses) {
let e = rows.entry(l.code.clone()).or_insert((
l.name.clone(),
Decimal::ZERO,
Decimal::ZERO,
Decimal::ZERO,
));
match slot {
0 => e.1 = l.amount,
1 => e.2 = l.amount,
_ => e.3 = l.amount / Decimal::from(3),
}
}
};
add(&now, 0);
add(&prev, 1);
add(&three, 2);
let mut rows: Vec<_> = rows.into_iter().collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1.3.max(r.1.1)));
let mut go = None;
widgets::titled_card(
ui,
"Spending by category",
|ui| {
ui.label(
RichText::new("this month so far vs. the last three months")
.font(theme::regular(12.0))
.color(p.muted),
);
},
|ui| {
if rows.is_empty() {
widgets::empty(ui, icon::RECEIPT, "No spending yet", "");
return;
}
let w = ui.available_width();
let cw = 130.0;
let (r, _) = ui.allocate_exact_size(vec2(w, 24.0), Sense::hover());
for (i, c) in ["This month", "Last month", "3-month avg"]
.iter()
.enumerate()
{
ui.painter().text(
egui::pos2(r.right() - cw * (2 - i) as f32 - 4.0, r.center().y),
egui::Align2::RIGHT_CENTER,
*c,
theme::semibold(12.0),
p.muted,
);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
"Account",
theme::semibold(12.0),
p.muted,
);
for (code, (name, now, last, avg)) in &rows {
let (r, resp) = ui.allocate_exact_size(vec2(w, 28.0), Sense::click());
if resp.hovered() {
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
}
ui.painter().text(
r.left_center() + vec2(4.0, 0.0),
egui::Align2::LEFT_CENTER,
format!("{code} {name}"),
theme::regular(13.0),
p.text,
);
// Flag a category already running well above its average.
let hot = *avg > Decimal::ZERO && *now > *avg * Decimal::new(125, 2);
let vals = [(*now, hot), (*last, false), (*avg, false)];
for (i, (v, flag)) in vals.iter().enumerate() {
let x = r.right() - cw * (2 - i) as f32 - 4.0;
ui.painter().text(
egui::pos2(x, r.center().y),
egui::Align2::RIGHT_CENTER,
fmt::money(v.round_dp(2)),
theme::regular(13.0),
if *flag { p.warning } else { p.text },
);
if *flag {
ui.painter().text(
egui::pos2(x - 86.0, r.center().y),
egui::Align2::RIGHT_CENTER,
icon::ARROW_UP_RIGHT,
egui::FontId::proportional(13.0),
p.warning,
);
}
}
if resp.clicked() {
go = b.ledger.chart().find_by_code(code).map(|a| a.id);
}
}
},
);
if let Some(id) = go {
shell.state.accounts.range = Some((ms - Months::new(3), b.today));
shell.go(Route::Account(id));
}
ui.add_space(16.0);
}
fn by_customer(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let (ys, ye) = b.fiscal_ytd();
let mut by: BTreeMap<String, Decimal> = BTreeMap::new();
for id in 1..b.ledger.next_invoice_id() {
if let Some(inv) = b.ledger.get_invoice(id)
&& inv.date >= ys
&& inv.date <= ye
{
let who = b
.ledger
.get_customer(inv.customer_id)
.map(|c| c.name.clone())
.unwrap_or_default();
*by.entry(who).or_default() += inv.total();
}
}
let mut rows: Vec<(String, Decimal)> = by.into_iter().collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
widgets::titled_card(
ui,
"Invoiced by customer, this year",
|_| {},
|ui| {
if rows.is_empty() {
widgets::empty(
ui,
icon::USERS,
"No invoices this year",
"Income recorded without invoices doesn't show a customer.",
);
return;
}
let total: Decimal = rows.iter().map(|r| r.1).sum();
if let Some((top, v)) = rows.first() {
ui.label(
RichText::new(format!(
"{top} is {}% of invoiced income.",
pct(*v, total).unwrap_or_default()
))
.font(theme::regular(12.5))
.color(p.muted),
);
ui.add_space(4.0);
}
charts::hbars(ui, &rows, p.series[1]);
},
);
}
fn by_class(ui: &mut Ui, b: &Books) {
let p = pal(ui.ctx());
let (ys, ye) = b.fiscal_ytd();
let classes = b.ledger.list_classes();
widgets::titled_card(
ui,
"Net income by class, this year",
|_| {},
|ui| {
if classes.is_empty() {
widgets::empty(
ui,
icon::TAG,
"No classes",
"Tag lines with classes (departments, products) to see profit by each.",
);
return;
}
let mut rows: Vec<(String, Decimal)> = classes
.iter()
.filter_map(|c| {
let pl = profit_and_loss_for_period(&b.ledger, ys, ye, Some(c.id)).ok()?;
let net = pl.total_revenue - pl.total_expenses;
(!pl.total_revenue.is_zero() || !pl.total_expenses.is_zero())
.then(|| (c.name.clone(), net))
})
.collect();
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
if rows.is_empty() {
widgets::empty(
ui,
icon::TAG,
"No classified income or spending this year",
"",
);
return;
}
charts::hbars(ui, &rows, p.series[0]);
ui.label(
RichText::new("Only lines tagged with a class count here.")
.font(theme::regular(12.0))
.color(p.faint),
);
},
);
}