Files
wanderbooks/crates/ledger-core/src/classes.rs
T
swaitsandClaude Opus 5.5 321b94562a
check / check (push) Canceled after 0s
Projects: classes as entities, and the sidebar fits short windows
A project is a class with facts (description, status, kind, party,
dates, links, notes) in one project.set event behind the format
feature projects. Four tools (books.add_project, update_project,
projects, project); documents and decisions link to projects; B-37:
on-spec costs are expensed as incurred, repayment is revenue, and the
unrecovered cost is never below zero (Kani). Viewer: a Projects list
and a project page with its figures, by month and by account. The
sidebar measures its footer instead of reserving a fixed 172 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
2026-10-22 03:50:52 -06:00

524 lines
20 KiB
Rust

//! Classes: divisional P&L tracking.
//!
//! A class is a label on a journal line — a department, location, product
//! line, or job. Reports can filter the P&L to one class or break it down
//! by class, so one ledger serves several divisions without splitting the
//! chart of accounts. Classes live on lines, not accounts: one entry can
//! touch several divisions.
//!
//! Classes are master data, journaled like accounts. Tagging a line happens
//! on drafts only; posted entries are immutable, so their classes are frozen.
//!
//! A class is also a project: the class with its facts ([`ProjectFacts`]:
//! what it is, where it stands, who it is for; rules.md B-37). Every class
//! is one; a class an earlier version wrote has the default facts.
use chrono::NaiveDate;
use serde::{Deserialize, Serialize};
/// Opaque class handle. Created by [`crate::Ledger`]; never forged.
pub type ClassId = u32;
/// A named division for line-level P&L tracking.
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Class {
/// Class id (the allocator's).
pub id: ClassId,
/// Human name, e.g. `"Retail"` or `"Wholesale"`.
pub name: String,
}
/// Where a project stands (rules.md B-37).
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize,
)]
pub enum ProjectStatus {
/// Under way (the default: every class an earlier version wrote).
#[default]
Active,
/// Set aside for now, expected to resume.
Paused,
/// Done: its work is delivered or shipped.
Finished,
/// Dropped before it was done.
Abandoned,
}
impl ProjectStatus {
/// Every status, in display order.
pub const ALL: [ProjectStatus; 4] = [
ProjectStatus::Active,
ProjectStatus::Paused,
ProjectStatus::Finished,
ProjectStatus::Abandoned,
];
/// The key the journal and the tools use.
pub fn key(self) -> &'static str {
match self {
ProjectStatus::Active => "active",
ProjectStatus::Paused => "paused",
ProjectStatus::Finished => "finished",
ProjectStatus::Abandoned => "abandoned",
}
}
/// The status a key names, if any.
pub fn parse(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|s| s.key() == key)
}
/// Whether the project is over (finished or abandoned): only then may
/// it have an end date.
pub fn is_over(self) -> bool {
matches!(self, ProjectStatus::Finished | ProjectStatus::Abandoned)
}
/// The status in words, for the viewer.
pub fn words(self) -> &'static str {
match self {
ProjectStatus::Active => "Active",
ProjectStatus::Paused => "Paused",
ProjectStatus::Finished => "Finished",
ProjectStatus::Abandoned => "Abandoned",
}
}
}
/// What kind of work a project is (rules.md B-37): it decides the
/// accounting questions its costs raise.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
pub enum ProjectKind {
/// The company's own product.
Internal,
/// Work for a customer.
Client,
/// Work done hoping to be paid: its costs are expenses, and a
/// repayment is income only when received (ASC 450-30-25-1).
OnSpec,
/// Anything else.
Other,
}
impl ProjectKind {
/// Every kind, in display order.
pub const ALL: [ProjectKind; 4] = [
ProjectKind::Internal,
ProjectKind::Client,
ProjectKind::OnSpec,
ProjectKind::Other,
];
/// The key the journal and the tools use.
pub fn key(self) -> &'static str {
match self {
ProjectKind::Internal => "internal",
ProjectKind::Client => "client",
ProjectKind::OnSpec => "on_spec",
ProjectKind::Other => "other",
}
}
/// The kind a key names, if any.
pub fn parse(key: &str) -> Option<Self> {
Self::ALL.into_iter().find(|k| k.key() == key)
}
/// The kind in words, for the viewer.
pub fn words(self) -> &'static str {
match self {
ProjectKind::Internal => "Internal product",
ProjectKind::Client => "Client work",
ProjectKind::OnSpec => "On spec",
ProjectKind::Other => "Other",
}
}
}
/// The most links a project keeps.
pub const MAX_LINKS: usize = 8;
/// One link: one line of text ([`crate::settings::line_text`]'s length
/// and control-character rules) with no full TIN as a web address holds
/// one ([`crate::settings::holds_printed_tin_in_link`]: a printed SSN or
/// EIN is refused, a store listing's `id284882215` is not; rules.md B-26).
fn link_text(l: &str) -> Result<String, String> {
use crate::settings::{MAX_TEXT, holds_printed_tin_in_link, line_text};
// line_text's TIN rule is a plain text's, so it sees the link with
// its digits masked; the link's own rule runs on the link itself.
let masked: String = l
.chars()
.map(|c| if c.is_numeric() { 'x' } else { c })
.collect();
line_text("link", &masked, MAX_TEXT)?;
let t = l.trim();
if holds_printed_tin_in_link(t) {
return Err(
"link holds a number that reads as a full SSN or EIN (printed 3-2-4 or 2-7, or nine digits standing alone): a full TIN goes only into the vault (books.set_tin)"
.to_string(),
);
}
Ok(t.to_string())
}
/// A project's facts: what a class is, beyond its name.
///
/// ```text
/// GOAL: say what a project is, where it stands and who it is for, in
/// the few facts a job cost sheet carries, without a project
/// manager's sprawl (no budgets, estimates or percent complete).
/// SOURCE: docs/spec/rules.md B-37; DECISION (owner, 2026-10-21: "Projects
/// are entities too and can have name, description, whatever
/// other metadata"); job-order costing (a job cost sheet: the
/// job, its customer, its dates).
/// INVARIANTS:
/// 1. No field holds a full taxpayer identification number
/// (settings::line_text / long_text, rules.md B-26).
/// 2. ended_on only when the project is finished or abandoned, and
/// never before started_on.
/// 3. The party, if any, is in the directory (archived allowed).
/// 4. Links: at most MAX_LINKS, each one line without whitespace, none
/// twice, in the order given.
/// 5. The dates are informational: no figure filters by them.
/// ```
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct ProjectFacts {
/// The scope of work: what the project is.
#[serde(default)]
pub description: Option<String>,
/// Where it stands.
#[serde(default)]
pub status: ProjectStatus,
/// What kind of work it is; `None`: not recorded.
#[serde(default)]
pub kind: Option<ProjectKind>,
/// Who it is for or with, if anyone.
#[serde(default)]
pub party: Option<crate::PartyId>,
/// When it started.
#[serde(default)]
pub started_on: Option<NaiveDate>,
/// When it ended (finished or abandoned only).
#[serde(default)]
pub ended_on: Option<NaiveDate>,
/// Web addresses: its site, its store listing, its repository.
#[serde(default)]
pub links: Vec<String>,
/// Anything else.
#[serde(default)]
pub notes: Option<String>,
}
impl ProjectFacts {
/// Check the facts' form (INVARIANTS 1, 2 and 4; the party is the
/// ledger's to check) and return them normalized: text trimmed.
pub fn check(&self) -> Result<ProjectFacts, String> {
use crate::settings::{MAX_LONG_TEXT, long_text};
let description = self
.description
.as_deref()
.map(|t| long_text("description", t, MAX_LONG_TEXT))
.transpose()?;
let notes = self
.notes
.as_deref()
.map(|t| long_text("notes", t, MAX_LONG_TEXT))
.transpose()?;
for (what, d) in [("started_on", self.started_on), ("ended_on", self.ended_on)] {
if let Some(d) = d {
crate::calendar::check(d).map_err(|e| format!("{what}: {e}"))?;
}
}
if let Some(end) = self.ended_on {
if !self.status.is_over() {
return Err(format!(
"ended_on is for a finished or abandoned project; this one is {}: set status to finished or abandoned, or clear ended_on",
self.status.key()
));
}
if let Some(start) = self.started_on
&& end < start
{
return Err(format!("ended_on {end} is before started_on {start}"));
}
}
if self.links.len() > MAX_LINKS {
return Err(format!("a project keeps at most {MAX_LINKS} links"));
}
let mut links: Vec<String> = Vec::with_capacity(self.links.len());
for l in &self.links {
let l = link_text(l)?;
if l.chars().any(char::is_whitespace) {
return Err(format!(
"link {l:?} holds a space: one web address per link"
));
}
if links.iter().any(|o| o == &l) {
return Err(format!("link {l:?} is given twice"));
}
links.push(l);
}
Ok(ProjectFacts {
description,
status: self.status,
kind: self.kind,
party: self.party,
started_on: self.started_on,
ended_on: self.ended_on,
links,
notes,
})
}
/// Whether these are the defaults: a project with only its name.
pub fn is_default(&self) -> bool {
*self == ProjectFacts::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{AccountType, Currency, Ledger, LedgerError, Money, Side};
use chrono::NaiveDate;
use rust_decimal::Decimal;
fn d(cents: i64) -> Decimal {
Decimal::new(cents, 2)
}
fn test_ledger() -> Ledger {
let mut ledger = Ledger::new(Currency::USD);
ledger
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
ledger
.add_account("4000", "Revenue", AccountType::Revenue, None, false)
.unwrap();
ledger
.add_account("6000", "Expenses", AccountType::Expense, None, false)
.unwrap();
ledger
}
fn post_sale(
ledger: &mut Ledger,
date: NaiveDate,
revenue: Decimal,
expense: Decimal,
class: Option<ClassId>,
) {
let cash = ledger.chart().find_by_code("1000").unwrap().id;
let rev = ledger.chart().find_by_code("4000").unwrap().id;
let exp = ledger.chart().find_by_code("6000").unwrap().id;
let base = Currency::USD;
let e = ledger.create_entry(date, "sale");
ledger
.add_line(e, cash, Side::Debit, Money::new(revenue, base))
.unwrap();
ledger
.add_line(e, rev, Side::Credit, Money::new(revenue, base))
.unwrap();
if !expense.is_zero() {
ledger
.add_line(e, exp, Side::Debit, Money::new(expense, base))
.unwrap();
ledger
.add_line(e, cash, Side::Credit, Money::new(expense, base))
.unwrap();
}
if let Some(c) = class {
// Tag the revenue and expense lines (indices 1 and 2).
ledger.set_line_class(e, 1, Some(c)).unwrap();
if !expense.is_zero() {
ledger.set_line_class(e, 2, Some(c)).unwrap();
}
}
ledger.post_entry(e).unwrap();
}
#[test]
fn class_crud() {
let mut ledger = test_ledger();
assert_eq!(ledger.next_class_id(), 1);
let retail = ledger.add_class("Retail").unwrap();
let wholesale = ledger.add_class("Wholesale").unwrap();
assert_eq!((retail, wholesale), (1, 2));
assert_eq!(ledger.next_class_id(), 3);
assert_eq!(ledger.get_class(retail).unwrap().name, "Retail");
assert!(ledger.get_class(99).is_none());
let names: Vec<_> = ledger
.list_classes()
.iter()
.map(|c| c.name.clone())
.collect();
assert_eq!(names, vec!["Retail", "Wholesale"]);
// Case-insensitive lookup.
assert_eq!(ledger.find_class_by_name("retail").unwrap().id, retail);
assert_eq!(ledger.find_class_by_name("RETAIL").unwrap().id, retail);
assert!(ledger.find_class_by_name("nope").is_none());
// Empty and whitespace names rejected.
assert_eq!(ledger.add_class(""), Err(LedgerError::EmptyClassName));
assert_eq!(ledger.add_class(" "), Err(LedgerError::EmptyClassName));
// The failed adds did not move the allocator.
assert_eq!(ledger.next_class_id(), 3);
}
#[test]
fn set_line_class_draft_only() {
let mut ledger = test_ledger();
let class = ledger.add_class("Retail").unwrap();
let cash = ledger.chart().find_by_code("1000").unwrap().id;
let rev = ledger.chart().find_by_code("4000").unwrap().id;
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
let e = ledger.create_entry(date, "draft");
ledger
.add_line(e, cash, Side::Debit, Money::new(d(10_000), Currency::USD))
.unwrap();
ledger
.add_line(e, rev, Side::Credit, Money::new(d(10_000), Currency::USD))
.unwrap();
// Unknown class rejected.
assert_eq!(
ledger.set_line_class(e, 0, Some(99)),
Err(LedgerError::UnknownClass(99))
);
// Line index out of range rejected.
assert_eq!(
ledger.set_line_class(e, 7, Some(class)),
Err(LedgerError::LineIndexOutOfRange(7, e))
);
// Set, then clear.
ledger.set_line_class(e, 0, Some(class)).unwrap();
assert_eq!(
ledger.get_entry(e).unwrap().lines()[0].class_id,
Some(class)
);
ledger.set_line_class(e, 0, None).unwrap();
assert_eq!(ledger.get_entry(e).unwrap().lines()[0].class_id, None);
// Post: classes freeze. Tagging a posted entry is rejected.
ledger.post_entry(e).unwrap();
assert_eq!(
ledger.set_line_class(e, 0, Some(class)),
Err(LedgerError::PostedEntryImmutable)
);
// Unknown entry rejected.
assert_eq!(
ledger.set_line_class(999, 0, Some(class)),
Err(LedgerError::UnknownEntry(999))
);
}
#[test]
fn filtered_pl_math() {
let mut ledger = test_ledger();
let retail = ledger.add_class("Retail").unwrap();
let wholesale = ledger.add_class("Wholesale").unwrap();
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
post_sale(&mut ledger, date, d(100_000), d(40_000), Some(retail));
post_sale(&mut ledger, date, d(50_000), d(10_000), Some(wholesale));
post_sale(&mut ledger, date, d(20_000), d(5_000), None);
let r = crate::reports::profit_and_loss_filtered(&ledger, Some(retail)).unwrap();
assert_eq!(r.revenue, d(100_000));
assert_eq!(r.expenses, d(40_000));
assert_eq!(r.net_income(), d(60_000));
let w = crate::reports::profit_and_loss_filtered(&ledger, Some(wholesale)).unwrap();
assert_eq!(w.revenue, d(50_000));
assert_eq!(w.expenses, d(10_000));
// A class with no lines reports zeros.
let empty = ledger.add_class("Empty").unwrap();
let z = crate::reports::profit_and_loss_filtered(&ledger, Some(empty)).unwrap();
assert_eq!((z.revenue, z.expenses), (Decimal::ZERO, Decimal::ZERO));
// None counts every line: identical to the account-based P&L.
let all = crate::reports::profit_and_loss_filtered(&ledger, None).unwrap();
let plain = crate::reports::unclosed_earnings(&ledger).unwrap();
assert_eq!((all.revenue, all.expenses), (plain.revenue, plain.expenses));
assert_eq!((all.revenue, all.expenses), (d(170_000), d(55_000)));
}
#[test]
fn by_class_partitions_total_pl() {
let mut ledger = test_ledger();
let retail = ledger.add_class("Retail").unwrap();
let wholesale = ledger.add_class("Wholesale").unwrap();
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
post_sale(&mut ledger, date, d(100_000), d(40_000), Some(retail));
post_sale(&mut ledger, date, d(50_000), d(10_000), Some(wholesale));
post_sale(&mut ledger, date, d(20_000), d(5_000), None);
let rows = crate::reports::profit_and_loss_by_class(&ledger).unwrap();
assert_eq!(rows.len(), 3);
// Sorted by class name; unclassified last.
assert_eq!(rows[0].class.as_ref().unwrap().name, "Retail");
assert_eq!(rows[1].class.as_ref().unwrap().name, "Wholesale");
assert!(rows[2].class.is_none());
assert_eq!(rows[0].profit_and_loss.net_income(), d(60_000));
assert_eq!(rows[1].profit_and_loss.net_income(), d(40_000));
assert_eq!(rows[2].profit_and_loss.net_income(), d(15_000));
// The rows partition the ledger: they sum to the total P&L.
let total = crate::reports::unclosed_earnings(&ledger).unwrap();
let rev: Decimal = rows.iter().map(|r| r.profit_and_loss.revenue).sum();
let exp: Decimal = rows.iter().map(|r| r.profit_and_loss.expenses).sum();
assert_eq!((rev, exp), (total.revenue, total.expenses));
// A class with only balance-sheet lines gets no row.
let cash_only = ledger.add_class("CashOnly").unwrap();
let cash = ledger.chart().find_by_code("1000").unwrap().id;
let e = ledger.create_entry(date, "cash move");
ledger
.add_line(e, cash, Side::Debit, Money::new(d(1_000), Currency::USD))
.unwrap();
// Need a second cash-ish line to balance; use revenue... no — use a
// second asset account so no P&L line is tagged.
let bank = ledger
.add_account("1010", "Bank", AccountType::Asset, None, false)
.unwrap();
ledger
.add_line(e, bank, Side::Credit, Money::new(d(1_000), Currency::USD))
.unwrap();
ledger.set_line_class(e, 0, Some(cash_only)).unwrap();
ledger.set_line_class(e, 1, Some(cash_only)).unwrap();
ledger.post_entry(e).unwrap();
let rows = crate::reports::profit_and_loss_by_class(&ledger).unwrap();
assert_eq!(rows.len(), 3, "balance-sheet-only class must not add a row");
// Empty ledger: no rows at all.
let empty = test_ledger();
assert!(
crate::reports::profit_and_loss_by_class(&empty)
.unwrap()
.is_empty()
);
}
#[test]
fn replay_class_checks_id() {
let mut ledger = test_ledger();
let err = ledger.replay_class(5, "Retail".to_string());
assert_eq!(err, Err(LedgerError::ReplayIdMismatch(5, 1)));
assert_eq!(ledger.next_class_id(), 1);
ledger.replay_class(1, "Retail".to_string()).unwrap();
assert_eq!(ledger.next_class_id(), 2);
assert_eq!(ledger.get_class(1).unwrap().name, "Retail");
}
#[test]
fn reversal_carries_class() {
let mut ledger = test_ledger();
let retail = ledger.add_class("Retail").unwrap();
let date = NaiveDate::from_ymd_opt(2026, 10, 1).unwrap();
post_sale(&mut ledger, date, d(100_000), Decimal::ZERO, Some(retail));
let entry_id = ledger.posted_entries().map(|e| e.id()).next().unwrap();
let rev = ledger.reverse_entry(entry_id, date, "reversal").unwrap();
let rev_entry = ledger.get_entry(rev).unwrap();
// The flipped revenue line keeps its class; the cash lines were
// never tagged.
assert_eq!(rev_entry.lines()[0].class_id, None);
assert_eq!(rev_entry.lines()[1].class_id, Some(retail));
// The class P&L nets to zero after the reversal.
let pl = crate::reports::profit_and_loss_filtered(&ledger, Some(retail)).unwrap();
assert_eq!((pl.revenue, pl.expenses), (Decimal::ZERO, Decimal::ZERO));
}
}