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
494 lines
20 KiB
Rust
494 lines
20 KiB
Rust
//! 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),
|
||
);
|
||
}
|
||
},
|
||
);
|
||
});
|
||
}
|
||
},
|
||
);
|
||
}
|
||
});
|
||
}
|