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 b48addd0e0
commit 105fd52694
52 files changed
+14050 -113

No files matched your search

+245
View File
@@ -0,0 +1,245 @@
//! Time series over the books, for dashboards and interval-column reports.
//!
//! Statements answer "what was true for this range"; these answer "how did
//! it change": a range split into months, quarters, or years; balances of a
//! set of accounts on a series of dates; profit and loss per interval. They
//! are built from the same posted entries and statements as everything
//! else, so a number on a chart always ties to a number on a statement.
use crate::reports::profit_and_loss_for_period;
use crate::{AccountId, AccountType, Ledger, LedgerError, Side};
use chrono::{Datelike, Days, Months, NaiveDate};
use rust_decimal::Decimal;
use std::collections::HashSet;
/// How to split a date range into columns.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum Interval {
/// Calendar months.
Month,
/// Calendar quarters.
Quarter,
/// Calendar years.
Year,
}
impl Interval {
fn months(self) -> u32 {
match self {
Interval::Month => 1,
Interval::Quarter => 3,
Interval::Year => 12,
}
}
}
/// The first day of the calendar interval containing `d`.
pub fn interval_start(d: NaiveDate, interval: Interval) -> NaiveDate {
let month0 = (d.month0() / interval.months()) * interval.months();
NaiveDate::from_ymd_opt(d.year(), month0 + 1, 1).expect("valid")
}
/// `[start, end]` split on calendar interval boundaries, first and last
/// pieces clipped to the range. Empty when `end < start`.
pub fn intervals(
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Vec<(NaiveDate, NaiveDate)> {
let mut out = Vec::new();
let mut from = interval_start(start, interval);
while from <= end {
let next = from + Months::new(interval.months());
let to = (next - Days::new(1)).min(end);
out.push((from.max(start), to));
from = next;
}
out
}
/// The combined normal-side balance of `accounts` at the end of each date
/// in `dates` (which must be ascending). Assets and expenses count debits
/// as positive; liabilities, equity, and revenue count credits. Mixing
/// types is allowed but rarely meaningful.
pub fn balance_series(
ledger: &Ledger,
accounts: &[AccountId],
dates: &[NaiveDate],
) -> Vec<Decimal> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut postings: Vec<(NaiveDate, Decimal)> = Vec::new();
for entry in ledger.posted_entries() {
for line in entry.lines() {
if !set.contains(&line.account) {
continue;
}
let Some(account) = ledger.chart().get(line.account) else {
continue;
};
let signed = if line.side == account.account_type.normal_side() {
line.amount.amount
} else {
-line.amount.amount
};
postings.push((entry.date(), signed));
}
}
postings.sort_by_key(|(d, _)| *d);
let mut out = Vec::with_capacity(dates.len());
let mut running = Decimal::ZERO;
let mut i = 0;
for date in dates {
while i < postings.len() && postings[i].0 <= *date {
running += postings[i].1;
i += 1;
}
out.push(running);
}
out
}
/// Every date `accounts` moved, with the combined balance at the end of
/// that day: the exact shape of a balance-over-time chart.
pub fn balance_history(ledger: &Ledger, accounts: &[AccountId]) -> Vec<(NaiveDate, Decimal)> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut dates: Vec<NaiveDate> = ledger
.posted_entries()
.filter(|e| e.lines().iter().any(|l| set.contains(&l.account)))
.map(|e| e.date())
.collect();
dates.sort();
dates.dedup();
let balances = balance_series(ledger, accounts, &dates);
dates.into_iter().zip(balances).collect()
}
/// One interval's profit and loss.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct IntervalPnl {
/// First day.
pub start: NaiveDate,
/// Last day.
pub end: NaiveDate,
/// Total revenue.
pub revenue: Decimal,
/// Total expenses.
pub expenses: Decimal,
}
impl IntervalPnl {
/// Revenue minus expenses.
pub fn net(&self) -> Decimal {
self.revenue - self.expenses
}
}
/// Profit and loss per interval across `[start, end]` (closing entries
/// excluded, as on every period statement).
pub fn pnl_by_interval(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Result<Vec<IntervalPnl>, LedgerError> {
intervals(start, end, interval)
.into_iter()
.map(|(s, e)| {
let pl = profit_and_loss_for_period(ledger, s, e, None)?;
Ok(IntervalPnl {
start: s,
end: e,
revenue: pl.total_revenue,
expenses: pl.total_expenses,
})
})
.collect()
}
/// Money in and out of `accounts` per interval: (inflows, outflows), each
/// positive. Each entry counts by its net debit (in) or credit (out) to the
/// accounts together: for cash, deposits and payments; a transfer between
/// two of them (checking to savings) is neither; with a credit card in the
/// set, a charge is money out and paying the card moves nothing.
pub fn flows_by_interval(
ledger: &Ledger,
accounts: &[AccountId],
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> {
let set: HashSet<AccountId> = accounts.iter().copied().collect();
let mut out: Vec<(NaiveDate, NaiveDate, Decimal, Decimal)> = intervals(start, end, interval)
.into_iter()
.map(|(s, e)| (s, e, Decimal::ZERO, Decimal::ZERO))
.collect();
for entry in ledger.posted_entries() {
let d = entry.date();
let Some(slot) = out.iter_mut().find(|(s, e, _, _)| *s <= d && d <= *e) else {
continue;
};
// Debits in, credits out, for every account in the set: cash
// received is a debit to cash, and a charge on a card in the set is
// a credit to the card.
let mut net = Decimal::ZERO;
for line in entry.lines().iter().filter(|l| set.contains(&l.account)) {
match line.side {
Side::Debit => net += line.amount.amount,
Side::Credit => net -= line.amount.amount,
}
}
if net > Decimal::ZERO {
slot.2 += net;
} else {
slot.3 -= net;
}
}
out
}
/// Accounts of the given types, in chart order.
pub fn accounts_of_type(ledger: &Ledger, types: &[AccountType]) -> Vec<AccountId> {
ledger
.chart()
.all()
.filter(|a| types.contains(&a.account_type))
.map(|a| a.id)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn intervals_clip_to_the_range() {
assert_eq!(
intervals(d(2026, 1, 15), d(2026, 3, 10), Interval::Month),
[
(d(2026, 1, 15), d(2026, 1, 31)),
(d(2026, 2, 1), d(2026, 2, 28)),
(d(2026, 3, 1), d(2026, 3, 10)),
]
);
assert_eq!(
intervals(d(2026, 2, 1), d(2026, 12, 31), Interval::Quarter),
[
(d(2026, 2, 1), d(2026, 3, 31)),
(d(2026, 4, 1), d(2026, 6, 30)),
(d(2026, 7, 1), d(2026, 9, 30)),
(d(2026, 10, 1), d(2026, 12, 31)),
]
);
assert_eq!(
intervals(d(2025, 6, 1), d(2026, 2, 1), Interval::Year),
[
(d(2025, 6, 1), d(2025, 12, 31)),
(d(2026, 1, 1), d(2026, 2, 1)),
]
);
assert!(intervals(d(2026, 2, 1), d(2026, 1, 1), Interval::Month).is_empty());
}
}
+1
View File
@@ -17,6 +17,7 @@
//! (`docs/oracle.md`) possible.
pub mod account;
pub mod analysis;
pub mod arap;
pub mod assets;
pub mod classes;