Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s

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
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-22 03:50:52 -06:00
1 parent 0495726001
commit 321b94562a
71 files changed
+7446 -217

No files matched your search

+13 -1
View File
@@ -148,11 +148,23 @@ pub fn pnl_by_interval(
start: NaiveDate,
end: NaiveDate,
interval: Interval,
) -> Result<Vec<IntervalPnl>, LedgerError> {
pnl_by_interval_for(ledger, start, end, interval, None)
}
/// [`pnl_by_interval`] for one class (project): only the lines tagged
/// with it count (`None`: every line).
pub fn pnl_by_interval_for(
ledger: &Ledger,
start: NaiveDate,
end: NaiveDate,
interval: Interval,
class: Option<crate::ClassId>,
) -> Result<Vec<IntervalPnl>, LedgerError> {
intervals(start, end, interval)
.into_iter()
.map(|(s, e)| {
let pl = profit_and_loss_for_period(ledger, s, e, None)?;
let pl = profit_and_loss_for_period(ledger, s, e, class)?;
Ok(IntervalPnl {
start: s,
end: e,
+253
View File
@@ -8,7 +8,12 @@
//!
//! 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.
@@ -23,6 +28,254 @@ pub struct Class {
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::*;
+4
View File
@@ -273,6 +273,8 @@ pub struct NewDecision {
pub supersedes: Option<u64>,
/// The rule or document it rests on, if any.
pub citation: Option<String>,
/// The projects (classes) it is about, if any (rules.md B-37).
pub projects: Vec<crate::ClassId>,
}
/// A decision as the journal holds it.
@@ -794,6 +796,7 @@ pub fn check_decision(d: &NewDecision) -> Result<NewDecision, String> {
.as_deref()
.map(|c| long_text("citation", c, MAX_LONG_TEXT))
.transpose()?,
projects: d.projects.clone(),
})
}
@@ -982,6 +985,7 @@ mod tests {
evidence_attachment: None,
supersedes: None,
citation: None,
projects: vec![],
};
assert!(c.apply_decision(2, d.clone(), 1).is_err());
c.apply_decision(1, d.clone(), 1).unwrap();
+11 -2
View File
@@ -654,14 +654,23 @@ pub enum LedgerError {
#[error("unknown payroll run id {0}")]
UnknownPayrollRun(u64),
/// Class names must not be empty.
#[error("class name must not be empty")]
/// Class (project) names must not be blank.
#[error("a project (class) name is blank")]
EmptyClassName,
/// The referenced class does not exist.
#[error("unknown class id {0}")]
UnknownClass(u32),
/// Another class (project) already has this name, ignoring capitals
/// (rules.md B-37).
#[error("a project named {0:?} already exists: names are unique, ignoring capitals")]
DuplicateClassName(String),
/// A project's facts the books can't take (rules.md B-37): the reason.
#[error("{0}")]
InvalidProject(String),
/// A setting change the books can't honor (crate::settings): an
/// unknown key, a value its key refuses, or settings that wouldn't
/// hold together.
+21
View File
@@ -1491,6 +1491,27 @@ pub fn basis_figures<T: Amount>(
})
}
/// A project's unrecovered cost from its tagged revenue and expenses to
/// date: what it would take to be repaid in full (rules.md B-37 item 4).
///
/// ```text
/// GOAL: the "if repaid" figure of work done on spec, from one function
/// the tools, the viewer and bookspec share.
/// SOURCE: docs/spec/rules.md B-37 item 4 (ASC 450-30-25-1: a hoped-for
/// payment is a gain contingency, never recorded before it is
/// received; so this is a figure, never a receivable).
/// INVARIANTS:
/// 1. Never below zero.
/// 2. expenses - revenue when expenses >= revenue; zero otherwise.
/// 3. Never above max(expenses, 0) when revenue >= 0 (expenses can be
/// negative: a reimbursement credited to the expense above its cost).
/// 4. NEVER wraps: None when the difference can't be represented.
/// ```
pub fn unrecovered_cost<T: Amount>(revenue: T, expenses: T) -> Option<T> {
let gap = expenses.checked_sub(revenue)?;
Some(if gap > T::ZERO { gap } else { T::ZERO })
}
#[cfg(test)]
mod tests {
use super::*;
+200 -13
View File
@@ -237,6 +237,9 @@ pub struct Ledger {
payroll_runs: HashMap<PayrollRunId, PayrollRun>,
/// Classes for divisional P&L tracking.
classes: HashMap<ClassId, Class>,
/// The project facts set for classes (rules.md B-37); a class not here
/// has the defaults.
projects: std::collections::BTreeMap<ClassId, crate::classes::ProjectFacts>,
next_account_id: AccountId,
next_entry_id: EntryId,
next_asset_id: AssetId,
@@ -307,6 +310,7 @@ impl Ledger {
fx_rates: HashMap::new(),
payroll_runs: HashMap::new(),
classes: HashMap::new(),
projects: std::collections::BTreeMap::new(),
next_account_id: 1,
next_entry_id: 1,
next_asset_id: 1,
@@ -670,6 +674,7 @@ impl Ledger {
("fx_rates", self.fx_rates == other.fx_rates),
("payroll_runs", self.payroll_runs == other.payroll_runs),
("classes", self.classes == other.classes),
("projects", self.projects == other.projects),
(
"next_account_id",
self.next_account_id == other.next_account_id,
@@ -6170,6 +6175,14 @@ impl Ledger {
) -> Result<(u64, crate::conventions::NewDecision), LedgerError> {
let bad = LedgerError::InvalidConvention;
let d = crate::conventions::check_decision(d).map_err(bad)?;
for (i, p) in d.projects.iter().enumerate() {
if !self.classes.contains_key(p) {
return Err(LedgerError::UnknownClass(*p));
}
if d.projects[..i].contains(p) {
return Err(bad(format!("project {p} is named twice")));
}
}
if let Some(old) = d.supersedes {
match self.conventions.decision(old) {
None => {
@@ -6478,21 +6491,77 @@ impl Ledger {
// Classes
// ------------------------------------------------------------------
/// Add a class (division) for line-level P&L tracking. Returns its id.
/// Add a class (division) for line-level P&L tracking: a project with
/// only its name (rules.md B-37). Returns its id.
///
/// ```text
/// GOAL: a new class under a name no other class has.
/// SOURCE: docs/spec/rules.md B-37 item 1.
/// SCENARIOS:
/// 1. IF the name is blank: SHOULD refuse (EmptyClassName).
/// 2. IF it is longer than a line, holds a control character or a
/// full TIN: SHOULD refuse.
/// 3. IF another class has it, ignoring capitals and surrounding
/// space: SHOULD refuse (DuplicateClassName). Replay never checks
/// (replay_class), so old books holding a duplicate still open.
/// 4. IF it is digits only: SHOULD refuse (it would read as an id).
/// ```
pub fn add_class(&mut self, name: &str) -> Result<ClassId, LedgerError> {
let name = self.check_class_name(name, None)?;
let id = self.next_class_id;
self.next_class_id += 1;
self.classes.insert(id, Class { id, name });
Ok(id)
}
/// A class name, trimmed, if a class (other than `except`) may take it.
///
/// ```text
/// GOAL: one rule for every name a command gives a class.
/// SOURCE: docs/spec/rules.md B-37 item 1; B-26 (no full TIN).
/// SCENARIOS:
/// 1. IF `except` is a class and the name is its own, exactly: SHOULD
/// keep it as stored, unchecked, so a fact-only change never
/// renames (an earlier version stored names untrimmed) and never
/// refuses a name the books already hold.
/// 2. IF it is the class's own name up to capitals and surrounding
/// space: SHOULD check its text but not its uniqueness, so old
/// books holding a duplicate (replay never checks) can still
/// change either class.
/// 3. IF it is digits only: SHOULD refuse, since a project is named
/// by id or name and such a name would read as an id.
/// 4. IF another class has it, ignoring capitals and surrounding
/// space: SHOULD refuse (DuplicateClassName).
/// ```
fn check_class_name(&self, name: &str, except: Option<ClassId>) -> Result<String, LedgerError> {
let current = except.and_then(|id| self.classes.get(&id));
if let Some(c) = current
&& c.name == name
{
return Ok(c.name.clone());
}
if name.trim().is_empty() {
return Err(LedgerError::EmptyClassName);
}
let id = self.next_class_id;
self.next_class_id += 1;
self.classes.insert(
id,
Class {
id,
name: name.to_string(),
},
);
Ok(id)
let name = crate::settings::line_text("project name", name, crate::settings::MAX_TEXT)
.map_err(LedgerError::InvalidProject)?;
let folded = fold_class_name(&name);
if current.is_some_and(|c| fold_class_name(&c.name) == folded) {
return Ok(name);
}
if name.chars().all(|c| c.is_ascii_digit()) {
return Err(LedgerError::InvalidProject(format!(
"project name {name:?} is digits only, so it would read as a project id: add a word (\"Job {name}\")"
)));
}
if self
.classes
.values()
.any(|c| Some(c.id) != except && fold_class_name(&c.name) == folded)
{
return Err(LedgerError::DuplicateClassName(name));
}
Ok(name)
}
/// Look up a class.
@@ -6509,11 +6578,15 @@ impl Ledger {
.collect()
}
/// Find a class by name, case-insensitive.
/// Find a class by name, ignoring capitals (in any script) and
/// surrounding space: the fold the duplicate check uses. Among old
/// books' duplicates (replay never checks), the lowest id.
pub fn find_class_by_name(&self, name: &str) -> Option<&Class> {
let folded = fold_class_name(name);
self.classes
.values()
.find(|c| c.name.eq_ignore_ascii_case(name))
.filter(|c| fold_class_name(&c.name) == folded)
.min_by_key(|c| c.id)
}
/// Tag a draft entry's line with a class, or clear it with `None`.
@@ -6563,6 +6636,120 @@ impl Ledger {
self.classes.insert(id, Class { id, name });
Ok(())
}
/// A class's project facts: the ones set, or the defaults for a class
/// that has none (every class an earlier version wrote). `None` for no
/// such class.
pub fn project_facts(&self, id: ClassId) -> Option<crate::classes::ProjectFacts> {
self.classes.get(&id)?;
Some(self.projects.get(&id).cloned().unwrap_or_default())
}
/// The projects for or with a party, by name (ignoring capitals).
pub fn projects_of_party(&self, party: crate::PartyId) -> Vec<ClassId> {
let mut ids: Vec<ClassId> = self
.projects
.iter()
.filter(|(_, f)| f.party == Some(party))
.map(|(id, _)| *id)
.filter(|id| self.classes.contains_key(id))
.collect();
ids.sort_by_key(|id| {
let c = &self.classes[id];
(c.name.to_lowercase(), c.id)
});
ids
}
/// Check a whole project record before it is journaled: returns the
/// trimmed name and the normalized facts. Changes nothing.
///
/// ```text
/// GOAL: refuse any project record the books can't take, before a
/// line is written.
/// SOURCE: docs/spec/rules.md B-37 item 1 (and B-26: no full TIN).
/// SCENARIOS:
/// 1. IF the class doesn't exist: SHOULD refuse (UnknownClass).
/// 2. IF the name is blank, too long, or another class has it
/// (ignoring capitals; the class itself excepted): SHOULD refuse.
/// 3. IF a fact breaks its rule (ProjectFacts::check): SHOULD refuse.
/// 4. IF the party is not in the directory: SHOULD refuse.
/// ```
pub fn check_set_project(
&self,
id: ClassId,
name: &str,
facts: &crate::classes::ProjectFacts,
) -> Result<(String, crate::classes::ProjectFacts), LedgerError> {
if !self.classes.contains_key(&id) {
return Err(LedgerError::UnknownClass(id));
}
let name = self.check_class_name(name, Some(id))?;
let facts = facts.check().map_err(LedgerError::InvalidProject)?;
if let Some(p) = facts.party
&& self.get_party(p).is_none()
{
return Err(LedgerError::InvalidProject(format!(
"party {p} is not in the directory: books.parties lists them"
)));
}
Ok((name, facts))
}
/// Apply a project record after [`Ledger::check_set_project`]: rename
/// the class, keep the facts.
pub fn set_project(
&mut self,
id: ClassId,
name: &str,
facts: crate::classes::ProjectFacts,
) -> Result<(), LedgerError> {
let (name, facts) = self.check_set_project(id, name, &facts)?;
self.put_project(id, name, facts);
Ok(())
}
/// Replay a journaled project record: only what replay can't do
/// without (the class and the party exist), never the text rules, so a
/// rule tightened later never breaks old books (as
/// `AssetBasis::check_journaled`, rules.md B-25).
#[doc(hidden)]
pub fn replay_set_project(
&mut self,
id: ClassId,
name: String,
facts: crate::classes::ProjectFacts,
) -> Result<(), LedgerError> {
if !self.classes.contains_key(&id) {
return Err(LedgerError::UnknownClass(id));
}
if let Some(p) = facts.party
&& self.get_party(p).is_none()
{
return Err(LedgerError::InvalidProject(format!(
"party {p} is not in the directory"
)));
}
self.put_project(id, name, facts);
Ok(())
}
fn put_project(&mut self, id: ClassId, name: String, facts: crate::classes::ProjectFacts) {
if let Some(c) = self.classes.get_mut(&id) {
c.name = name;
}
if facts.is_default() {
self.projects.remove(&id);
} else {
self.projects.insert(id, facts);
}
}
}
/// A class name as names compare (rules.md B-37 item 1): trimmed, in
/// lower case (Unicode's, so `Éclair` and `éCLAIR` are one name).
fn fold_class_name(name: &str) -> String {
name.trim().to_lowercase()
}
/// Refuse an amount finer than its currency's minor unit (rules.md B-3).
+5 -4
View File
@@ -64,7 +64,7 @@ pub use cashflow::{
OperatingReconciliation, ReconcilingKind, ReconcilingLine, RoleList, RoleProblem, cash_flow,
role_problems,
};
pub use classes::{Class, ClassId};
pub use classes::{Class, ClassId, ProjectFacts, ProjectKind, ProjectStatus};
pub use error::LedgerError;
pub use form1099::{
Dated1099, Decision1099, ExemptReason, Form1099, Payment1099, PaymentAccounts, Status1099,
@@ -88,9 +88,10 @@ pub use periods::{
pub use reports::{
AssetRegister, AssetRegisterRow, BalanceSheet, BalanceSheetStatement, ClassProfitAndLoss,
GeneralLedger, LedgerAccount, LedgerPosting, MemberCapital, MemberEquityColumn,
MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement, StatementLine, TrialBalance,
TrialBalanceLine, balance_sheet_as_of, fixed_asset_register, general_ledger, members_equity,
profit_and_loss_for_period, trial_balance_as_of,
MembersEquityStatement, ProfitAndLoss, ProfitAndLossStatement, ProjectFigures, ProjectToDate,
StatementLine, TrialBalance, TrialBalanceLine, balance_sheet_as_of, fixed_asset_register,
general_ledger, members_equity, profit_and_loss_for_period, project_entries, project_figures,
project_to_date, trial_balance_as_of,
};
pub use schedules::{
AmortizationSchedule, Frequency, NewRecurring, NewSchedule, RecurringEntry, RecurringId,
+22
View File
@@ -1785,6 +1785,28 @@ fn basis_figures_laws() {
);
}
/// `kernel::unrecovered_cost` (rules.md B-37 item 4), for every revenue
/// and expense a line could carry: never below zero; the shortfall when
/// expenses exceed revenue, else zero; never above max(expenses, 0) when
/// revenue isn't negative (expenses may be negative: a reimbursement
/// credited to the expense above its cost).
#[kani::proof]
fn unrecovered_cost_laws() {
let (revenue, expenses) = (any_amount(), any_amount());
let u = kernel::unrecovered_cost(revenue, expenses).expect("no overflow");
assert!(u >= 0);
if expenses >= revenue {
assert_eq!(u, expenses - revenue);
} else {
assert_eq!(u, 0);
}
if revenue >= 0 {
assert!(u <= expenses.max(0));
}
kani::cover!(u > 0, "costs not yet repaid");
kani::cover!(u == 0 && revenue > expenses, "repaid, and more");
}
// ---------------------------------------------------------------------------
// The checker itself
// ---------------------------------------------------------------------------
+157
View File
@@ -504,6 +504,163 @@ pub fn profit_and_loss_for_period(
})
}
/// A project's figures (rules.md B-37 item 3): the period's statement and
/// months, and its figures to date.
#[derive(Clone, Debug)]
pub struct ProjectFigures {
/// The period's profit and loss, only the lines tagged to it.
pub period: ProfitAndLossStatement,
/// The period by calendar month: its last [`PROJECT_MONTHS`] at most.
pub months: Vec<crate::analysis::IntervalPnl>,
/// Every tagged line through the period's end.
pub to_date: ProjectToDate,
}
/// A project's figures to date: every posted revenue and expense line
/// tagged to it through `through`, whatever its dates (rules.md B-37).
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ProjectToDate {
/// The last day counted.
pub through: NaiveDate,
/// Its tagged revenue.
pub revenue: Decimal,
/// Its tagged expenses.
pub expenses: Decimal,
/// On spec only: expenses less revenue, never below zero (the "if
/// repaid" figure, never a receivable). `None` for any other kind.
pub unrecovered_cost: Option<Decimal>,
}
impl ProjectToDate {
/// Revenue less expenses.
pub fn net(&self) -> Decimal {
self.revenue - self.expenses
}
}
/// The most months [`ProjectFigures::months`] holds: the period's last.
pub const PROJECT_MONTHS: u32 = 240;
/// A project's figures for `[start, end]`, by month, and to date.
///
/// ```text
/// GOAL: what a project earned and cost, from one function the tools, the
/// viewer and bookspec share, so their figures never drift.
/// SOURCE: docs/spec/rules.md B-37 item 3 (job costing), B-11, B-14.
/// INVARIANTS:
/// 1. MUST be profit_and_loss_for_period with the project's class: only
/// revenue and expense lines tagged to it, closing entries left out.
/// 2. The months partition the period (their nets sum to its net)
/// when it spans at most PROJECT_MONTHS months; a longer one's
/// months are its last PROJECT_MONTHS.
/// 3. to_date counts every tagged line through `end`, from the first
/// day the books keep, whatever the project's own dates.
/// SCENARIOS:
/// 1. IF the class doesn't exist: SHOULD refuse (UnknownClass).
/// 2. IF `start` is after `end`: SHOULD refuse.
/// ```
pub fn project_figures(
ledger: &Ledger,
id: ClassId,
start: NaiveDate,
end: NaiveDate,
) -> Result<ProjectFigures, LedgerError> {
if ledger.get_class(id).is_none() {
return Err(LedgerError::UnknownClass(id));
}
let period = profit_and_loss_for_period(ledger, start, end, Some(id))?;
// The last PROJECT_MONTHS months at most: a span across the calendar
// is a hundred thousand months, each a statement.
let months_from = chrono::Datelike::with_day(&end, 1)
.and_then(|m| m.checked_sub_months(chrono::Months::new(PROJECT_MONTHS - 1)))
.map_or(start, |m| m.max(start));
let months = crate::analysis::pnl_by_interval_for(
ledger,
months_from,
end,
crate::analysis::Interval::Month,
Some(id),
)?;
let to_date = project_to_date(ledger, id, end)?;
Ok(ProjectFigures {
period,
months,
to_date,
})
}
/// A project's figures to date: every posted revenue and expense line
/// tagged to it through `through` (closing entries left out), and for an
/// on-spec project its unrecovered cost (kernel::unrecovered_cost).
pub fn project_to_date(
ledger: &Ledger,
id: ClassId,
through: NaiveDate,
) -> Result<ProjectToDate, LedgerError> {
let facts = ledger
.project_facts(id)
.ok_or(LedgerError::UnknownClass(id))?;
let start = crate::calendar::FIRST_DAY.min(through);
let pl = profit_and_loss_for_period(ledger, start, through, Some(id))?;
let unrecovered_cost = match facts.kind {
Some(crate::classes::ProjectKind::OnSpec) => Some(
crate::kernel::unrecovered_cost(pl.total_revenue, pl.total_expenses)
.ok_or(LedgerError::AmountOutOfRange)?,
),
_ => None,
};
Ok(ProjectToDate {
through,
revenue: pl.total_revenue,
expenses: pl.total_expenses,
unrecovered_cost,
})
}
/// Every entry with a line tagged to the project (drafts included),
/// newest first (by date, then id), with its project amount: its tagged
/// revenue lines less its tagged expense lines, each on its normal side.
/// A tagged balance-sheet line counts zero (rules.md B-37 item 3).
pub fn project_entries(ledger: &Ledger, id: ClassId) -> Vec<(crate::EntryId, Decimal)> {
let mut out: Vec<(NaiveDate, crate::EntryId, Decimal)> = Vec::new();
for eid in 1..ledger.next_entry_id() {
let Some(entry) = ledger.get_entry(eid) else {
continue;
};
let mut tagged = false;
let mut amount = Decimal::ZERO;
for line in entry.lines().iter().filter(|l| l.class_id == Some(id)) {
tagged = true;
let Some(account) = ledger.chart().get(line.account) else {
continue;
};
let debit = matches!(line.side, crate::Side::Debit);
match account.account_type {
AccountType::Revenue => {
amount += if debit {
-line.amount.amount
} else {
line.amount.amount
}
}
AccountType::Expense => {
amount -= if debit {
line.amount.amount
} else {
-line.amount.amount
}
}
_ => {}
}
}
if tagged {
out.push((entry.date(), eid, amount));
}
}
out.sort_by_key(|a| std::cmp::Reverse((a.0, a.1)));
out.into_iter().map(|(_, e, a)| (e, a)).collect()
}
/// Balance sheet as of `as_of`: every posted entry dated on or before it.
/// Revenue and expense not yet closed to retained earnings appear as
/// current earnings inside equity, so the accounting equation holds on
+26 -3
View File
@@ -602,6 +602,20 @@ pub fn decimal(s: &str) -> Option<Decimal> {
/// a hyphen or any other dash, a dot, or a space (or a dash with spaces
/// around it); full-width digits count as digits.
pub fn holds_printed_tin(text: &str) -> bool {
printed_tin(text, false)
}
/// [`holds_printed_tin`] for a web address: the printed SSN (3-2-4) and
/// EIN (2-7) forms count as there, but a bare run of nine digits joined
/// to a letter or to the URL punctuation `/ = ? & #` does not: it is an
/// id in a path or query (an app store listing's `id284882215`), not a
/// TIN someone wrote down (rules.md B-26).
pub fn holds_printed_tin_in_link(text: &str) -> bool {
printed_tin(text, true)
}
/// [`holds_printed_tin`], with a link's bare runs let through when `link`.
fn printed_tin(text: &str, link: bool) -> bool {
// Full-width digits read as the digits they are.
let c: Vec<char> = text
.chars()
@@ -645,11 +659,20 @@ pub fn holds_printed_tin(text: &str) -> bool {
}
Some(i)
};
let url_joined = |i: Option<usize>| {
i.and_then(|i| c.get(i))
.is_some_and(|ch| ch.is_alphabetic() || matches!(ch, '/' | '=' | '?' | '&' | '#'))
};
(0..c.len()).any(|at| {
(at == 0 || !digit(at - 1))
&& [&[9][..], &[3, 2, 4][..], &[2, 7][..]]
.iter()
.any(|g| groups_at(at, g).is_some_and(|end| !digit(end)))
&& [&[9][..], &[3, 2, 4][..], &[2, 7][..]].iter().any(|g| {
groups_at(at, g).is_some_and(|end| {
!digit(end)
&& !(link
&& g.len() == 1
&& (url_joined(at.checked_sub(1)) || url_joined(Some(end))))
})
})
})
}
+141 -1
View File
@@ -111,6 +111,9 @@ struct Setup {
/// The ways the company (or a member, for it) pays (rules.md B-19).
#[serde(default)]
instruments: Vec<InstrumentSpec>,
/// The projects (rules.md B-37), each added with its facts.
#[serde(default)]
projects: Vec<ProjectSpec>,
/// The day the books are asked on, in the company's time zone: what
/// an omitted date means (rules.md B-30). A query that omits a date
/// needs it.
@@ -118,6 +121,21 @@ struct Setup {
today: Option<String>,
}
/// A project (rules.md B-37): its name (once each), and its kind
/// (`internal`, `client`, `on_spec`, `other`), status and party (a
/// `setup.parties` name), if given.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct ProjectSpec {
name: String,
#[serde(default)]
kind: Option<String>,
#[serde(default)]
status: Option<String>,
#[serde(default)]
party: Option<String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct AccountSpec {
@@ -454,6 +472,9 @@ struct LineSpec {
account: String,
side: String,
amount: String,
/// The project (a `setup.projects` name) the line is tagged with.
#[serde(default)]
project: Option<String>,
}
/// A close of the period from `start` through the event's date, to
@@ -490,6 +511,9 @@ struct Expect {
/// The party a `party_part`, `party_totals` or `party_tin_last4`
/// query asks about (in `setup.parties`).
party: Option<String>,
/// The project (in `setup.projects`) a `project` query asks about.
#[serde(default)]
project: Option<String>,
/// `party_totals`' basis: `"cash"` or `"all"` (rules.md B-35 item 4).
basis: Option<String>,
/// The payee a `vendor_1099` query asks about (in `setup.payees`).
@@ -711,6 +735,8 @@ struct Books<'a> {
instruments: BTreeMap<String, InstrumentId>,
/// The fixed assets of `setup.assets`, by id.
assets: BTreeMap<String, AssetId>,
/// The projects of `setup.projects`, by name.
projects: BTreeMap<String, ledger_core::ClassId>,
/// The schedules of `setup.schedules` and `register_schedule` events,
/// by id.
schedules: BTreeMap<String, ScheduleId>,
@@ -1100,6 +1126,31 @@ impl<'a> Books<'a> {
};
took(store.set_vendor_1099(v, &dated, Some(false)), &what);
}
let mut projects = BTreeMap::new();
for p in &s.projects {
let what = format!("{}: project {}", ex.id, p.name);
let facts = ledger_core::ProjectFacts {
kind: p.kind.as_deref().map(|k| {
ledger_core::ProjectKind::parse(k)
.unwrap_or_else(|| panic!("{what}: kind {k:?}"))
}),
status: p.status.as_deref().map_or(Default::default(), |k| {
ledger_core::ProjectStatus::parse(k)
.unwrap_or_else(|| panic!("{what}: status {k:?}"))
}),
party: p.party.as_ref().map(|n| {
*parties
.get(n)
.unwrap_or_else(|| panic!("{what}: party {n:?} not in setup.parties"))
}),
..Default::default()
};
let id = took(store.add_project(&p.name, &facts), &what);
assert!(
projects.insert(p.name.clone(), id).is_none(),
"{what}: named twice"
);
}
let mut instruments = BTreeMap::new();
for i in &s.instruments {
let what = format!("{} instrument {}", ex.id, i.label);
@@ -1133,6 +1184,7 @@ impl<'a> Books<'a> {
payees,
instruments,
assets,
projects,
schedules,
posted: Vec::new(),
runs: BTreeMap::new(),
@@ -1344,7 +1396,7 @@ impl<'a> Books<'a> {
match kind {
"lines" => {
let entry = self.store.create_entry(day(), description)?;
for line in &e.lines {
for (i, line) in e.lines.iter().enumerate() {
let side = match line.side.as_str() {
"debit" => Side::Debit,
"credit" => Side::Credit,
@@ -1353,6 +1405,13 @@ impl<'a> Books<'a> {
let amount = Money::new(money(&line.amount), self.currency);
let account = self.id(&line.account);
self.store.add_line(entry, account, side, amount)?;
// Tagged as books.add_line tags it: after the line.
if let Some(p) = &line.project {
let class = *self.projects.get(p).unwrap_or_else(|| {
panic!("{what}: project {p:?} not in setup.projects")
});
self.store.set_line_class(entry, i, Some(class))?;
}
}
self.store.post_entry(entry)
}
@@ -2006,6 +2065,57 @@ fn check_party_totals(b: &Books<'_>, x: &Expect) {
);
}
/// Check one `project` expectation (rules.md B-37): the project's
/// `revenue`, `expenses` and `net` for the period (B-30 resolving omitted
/// dates), the same `to_date` (every tagged line through `to`), and its
/// `unrecovered_cost` (`"none"` unless it is on spec); every one given.
/// Through ledger_core::project_figures, as the tools and the viewer.
fn check_project(b: &Books<'_>, x: &Expect) {
let (ex, l) = (b.ex, b.l());
let name = x
.project
.as_ref()
.unwrap_or_else(|| panic!("{}: a project query names its project", ex.id));
let id = *b
.projects
.get(name)
.unwrap_or_else(|| panic!("{}: project {name:?} not in setup.projects", ex.id));
let (from, to) = x.period(ex);
let f =
ledger_core::project_figures(l, id, from, to).unwrap_or_else(|e| panic!("{}: {e}", ex.id));
let keys: BTreeSet<String> = [
"revenue",
"expenses",
"net",
"to_date.revenue",
"to_date.expenses",
"to_date.net",
"unrecovered_cost",
]
.iter()
.map(|k| k.to_string())
.collect();
exactly(ex, x, &keys);
let what = |k: &str| format!("{} project {name:?} [{from} .. {to}]: {k}", ex.id);
for (key, want) in x.figures(&ex.id) {
let got = match key.as_str() {
"revenue" => Some(f.period.total_revenue),
"expenses" => Some(f.period.total_expenses),
"net" => Some(f.period.net_income()),
"to_date.revenue" => Some(f.to_date.revenue),
"to_date.expenses" => Some(f.to_date.expenses),
"to_date.net" => Some(f.to_date.net()),
_ => f.to_date.unrecovered_cost,
};
match (want.as_str(), got) {
("none", None) => {}
("none", Some(g)) => panic!("{}: {g}, expected none", what(key)),
(w, Some(g)) => assert_eq!(g, exact(w, &what(key)), "{}", what(key)),
(w, None) => panic!("{}: none, expected {w}", what(key)),
}
}
}
/// Check one `party_tin_last4` expectation: the last four of the party's
/// TIN the books keep (its TIN facts', else its W-9's; `""` for none:
/// rules.md B-26).
@@ -2919,6 +3029,7 @@ fn check(b: &Books<'_>, x: &Expect) -> Option<&'static str> {
"party_part" => check_party_part(b, x),
"party_totals" => check_party_totals(b, x),
"party_tin_last4" => check_party_tin_last4(b, x),
"project" => check_project(b, x),
q => {
let &(_, by) = FORMAT_QUERIES
.iter()
@@ -3044,6 +3155,7 @@ fn every_bookspec_example_holds() {
("asset_basis", 6),
("k1_tax_basis", 2),
("k1_reconciliation", 1),
("project", 6),
] {
let n = checked.get(q).copied().unwrap_or(0);
assert!(n >= at_least, "{q} ran {n} times, fewer than {at_least}");
@@ -3611,3 +3723,31 @@ fn a_members_reconciliation_shows_every_cause() {
t.replacen(", \"Tobin Harlow.property_distributed\" = \"0.00\"", "", 1)
});
}
/// A project named twice in `setup.projects` is refused (names are
/// unique, rules.md B-37).
#[test]
#[should_panic(expected = "a project named")]
fn a_project_named_twice_is_refused() {
run_edited("projects-net-is-its-tagged-lines.toml", |t| {
t.replacen("name = \"Beta\"", "name = \"alpha\"", 1)
});
}
/// A line naming a project the setup doesn't have is refused.
#[test]
#[should_panic(expected = "not in setup.projects")]
fn a_line_naming_an_unknown_project_is_refused() {
run_edited("projects-net-is-its-tagged-lines.toml", |t| {
t.replacen("project = \"Beta\" }", "project = \"Gamma\" }", 1)
});
}
/// A project query lists every value it covers.
#[test]
#[should_panic(expected = "lists exactly what it covers")]
fn a_project_query_leaving_a_value_out_is_refused() {
run_edited("projects-on-spec-costs-are-expenses.toml", |t| {
t.replacen("unrecovered_cost = \"1460.00\"\n", "", 1)
});
}
+119
View File
@@ -933,3 +933,122 @@ proptest! {
}
}
}
proptest! {
#![proptest_config(ProptestConfig::with_cases(256))]
/// rules.md B-37 item 3: for any tags and any period, the projects'
/// figures plus the untagged lines' are the period's profit and loss
/// (no line counted twice or lost), each through
/// ledger_core::project_figures.
#[test]
fn prop_project_nets_partition_the_pnl(
entries in prop::collection::vec(
(0u32..120, 1i64..1_000_000, any::<bool>(), 0u32..4, 0u32..4),
1..20,
),
from in 0u32..120,
len in 0u32..120,
) {
let base = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut l = Ledger::new(Currency::USD);
let cash = l.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap();
let rev = l.add_account("4000", "Revenue", AccountType::Revenue, None, false).unwrap();
let exp = l.add_account("6000", "Costs", AccountType::Expense, None, false).unwrap();
let classes: Vec<u32> = (0..3).map(|i| l.add_class(&format!("P{i}")).unwrap()).collect();
for (day, c, income, tag_a, tag_b) in entries {
let e = l.create_entry(base + chrono::Days::new(u64::from(day)), "e");
let amt = Money::new(Decimal::new(c, 2), Currency::USD);
let (dr, cr) = if income { (cash, rev) } else { (exp, cash) };
l.add_line(e, dr, Side::Debit, amt).unwrap();
l.add_line(e, cr, Side::Credit, amt).unwrap();
// 3 means untagged; both lines may carry any project.
for (i, t) in [tag_a, tag_b].into_iter().enumerate() {
if let Some(&k) = classes.get(t as usize) {
l.set_line_class(e, i, Some(k)).unwrap();
}
}
l.post_entry(e).unwrap();
}
let start = base + chrono::Days::new(u64::from(from));
let end = start + chrono::Days::new(u64::from(len));
let all = profit_and_loss_for_period(&l, start, end, None).unwrap();
let mut rev_sum = Decimal::ZERO;
let mut exp_sum = Decimal::ZERO;
for &k in &classes {
let f = ledger_core::project_figures(&l, k, start, end).unwrap();
rev_sum += f.period.total_revenue;
exp_sum += f.period.total_expenses;
let months: Decimal = f.months.iter().map(|m| m.net()).sum();
prop_assert_eq!(months, f.period.net_income());
}
// The untagged lines: every line with no class.
let mut untagged_rev = Decimal::ZERO;
let mut untagged_exp = Decimal::ZERO;
for e in l.posted_entries() {
if e.date() < start || e.date() > end {
continue;
}
for line in e.lines().iter().filter(|x| x.class_id.is_none()) {
let a = line.amount.amount;
if line.account == rev {
untagged_rev += if line.side == Side::Credit { a } else { -a };
} else if line.account == exp {
untagged_exp += if line.side == Side::Debit { a } else { -a };
}
}
}
prop_assert_eq!(rev_sum + untagged_rev, all.total_revenue);
prop_assert_eq!(exp_sum + untagged_exp, all.total_expenses);
}
/// rules.md B-37 item 3: project_entries codes its own signs, so the
/// posted entries' project amounts must sum to project_to_date's net
/// (with no entry dated after the day), whatever the tags, the sides,
/// a tagged balance-sheet line, or drafts beside them.
#[test]
fn prop_project_entries_sum_to_the_net_to_date(
entries in prop::collection::vec(
(0u32..120, 1i64..1_000_000, 0u32..3, any::<bool>(), 0u32..3, 0u32..3, any::<bool>()),
1..20,
),
) {
let base = NaiveDate::from_ymd_opt(2026, 1, 1).unwrap();
let mut l = Ledger::new(Currency::USD);
let cash = l.add_account("1000", "Cash", AccountType::Asset, None, false).unwrap();
let rev = l.add_account("4000", "Revenue", AccountType::Revenue, None, false).unwrap();
let exp = l.add_account("6000", "Costs", AccountType::Expense, None, false).unwrap();
let classes: Vec<u32> = (0..2).map(|i| l.add_class(&format!("P{i}")).unwrap()).collect();
let accounts = [cash, rev, exp];
for (day, c, other, debit_first, tag_a, tag_b, post) in entries {
let e = l.create_entry(base + chrono::Days::new(u64::from(day)), "e");
let amt = Money::new(Decimal::new(c, 2), Currency::USD);
// Cash against any account, on either side: a refund debits
// revenue, a reimbursement credits an expense.
let other = accounts[other as usize];
let (dr, cr) = if debit_first { (other, cash) } else { (cash, other) };
l.add_line(e, dr, Side::Debit, amt).unwrap();
l.add_line(e, cr, Side::Credit, amt).unwrap();
for (i, t) in [tag_a, tag_b].into_iter().enumerate() {
if let Some(&k) = classes.get(t as usize) {
l.set_line_class(e, i, Some(k)).unwrap();
}
}
if post {
l.post_entry(e).unwrap();
}
}
let through = base + chrono::Days::new(200);
for &k in &classes {
let posted: Decimal = ledger_core::project_entries(&l, k)
.into_iter()
.filter(|(e, _)| {
l.get_entry(*e).unwrap().status() == ledger_core::EntryStatus::Posted
})
.map(|(_, a)| a)
.sum();
let t = ledger_core::project_to_date(&l, k, through).unwrap();
prop_assert_eq!(posted, t.net());
}
}
}
+633
View File
@@ -0,0 +1,633 @@
//! Projects (rules.md B-37): a class with its facts; its figures are the
//! revenue and expense lines tagged to it.
//!
//! ```text
//! GOAL: a project's record holds only what its rules allow, and its
//! figures are exactly its tagged lines, from one function.
//! SOURCE: docs/spec/rules.md B-37 (job costing; ASC 450-30-25-1 and
//! 340-40-25-5 for on-spec work), B-11 (closes left out).
//! ```
use chrono::NaiveDate;
use ledger_core::conventions::NewDecision;
use ledger_core::{
AccountType, ClassId, Currency, Ledger, LedgerError, Money, ProjectFacts, ProjectKind,
ProjectStatus, Side, project_entries, project_figures, project_to_date,
};
use rust_decimal::Decimal;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn usd(c: i64) -> Money {
Money::new(cents(c), Currency::USD)
}
/// Cash 1000, equipment 1200, revenue 4000, contractors 6000, rent 6200.
struct Books {
l: Ledger,
cash: u64,
equipment: u64,
revenue: u64,
contractors: u64,
rent: u64,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let mut add = |code: &str, t| l.add_account(code, code, t, None, false).unwrap();
let cash = add("1000", AccountType::Asset);
let equipment = add("1200", AccountType::Asset);
let revenue = add("4000", AccountType::Revenue);
let contractors = add("6000", AccountType::Expense);
let rent = add("6200", AccountType::Expense);
Books {
l,
cash,
equipment,
revenue,
contractors,
rent,
}
}
impl Books {
/// Post `dr`/`cr` for `c` cents on `date`, tagging the debit line
/// (`on_debit`) or the credit line with `class`.
fn post(
&mut self,
date: NaiveDate,
dr: u64,
cr: u64,
c: i64,
class: Option<(ClassId, bool)>,
) -> u64 {
let e = self.l.create_entry(date, "e");
self.l.add_line(e, dr, Side::Debit, usd(c)).unwrap();
self.l.add_line(e, cr, Side::Credit, usd(c)).unwrap();
if let Some((k, on_debit)) = class {
self.l
.set_line_class(e, if on_debit { 0 } else { 1 }, Some(k))
.unwrap();
}
self.l.post_entry(e).unwrap();
e
}
}
fn on_spec() -> ProjectFacts {
ProjectFacts {
kind: Some(ProjectKind::OnSpec),
..Default::default()
}
}
#[test]
fn project_facts_default_for_a_bare_class() {
let mut b = books();
let c = b.l.add_class("Harbor").unwrap();
let f = b.l.project_facts(c).unwrap();
assert_eq!(f, ProjectFacts::default());
assert_eq!(f.status, ProjectStatus::Active);
assert_eq!(f.kind, None);
assert!(b.l.project_facts(99).is_none());
}
#[test]
fn set_project_checks_every_field() {
let mut b = books();
let c = b.l.add_class("Harbor").unwrap();
b.l.add_class("Other").unwrap();
let ok = ProjectFacts::default();
let refused =
|l: &Ledger, name: &str, f: ProjectFacts| l.check_set_project(c, name, &f).is_err();
// The class exists.
assert_eq!(
b.l.check_set_project(99, "x", &ok),
Err(LedgerError::UnknownClass(99))
);
// Names: not blank, one line, not another's (ignoring capitals), the
// project's own name fine.
assert!(refused(&b.l, " ", ok.clone()));
assert!(refused(&b.l, &"x".repeat(201), ok.clone()));
assert!(refused(&b.l, "two\nlines", ok.clone()));
assert_eq!(
b.l.check_set_project(c, " other ", &ok),
Err(LedgerError::DuplicateClassName("other".into()))
);
assert_eq!(b.l.check_set_project(c, "HARBOR", &ok).unwrap().0, "HARBOR");
// No full TIN in any text.
for f in [
ProjectFacts {
description: Some("EIN 12-3456789".into()),
..ok.clone()
},
ProjectFacts {
notes: Some("SSN 123-45-6789".into()),
..ok.clone()
},
] {
assert!(refused(&b.l, "Harbor", f));
}
assert!(refused(&b.l, "123-45-6789", ok.clone()));
// ended_on only when over, never before started_on.
let ended = |status, start: Option<NaiveDate>, end| ProjectFacts {
status,
started_on: start,
ended_on: Some(end),
..Default::default()
};
assert!(refused(
&b.l,
"Harbor",
ended(ProjectStatus::Active, None, d(2026, 1, 1))
));
assert!(refused(
&b.l,
"Harbor",
ended(ProjectStatus::Paused, None, d(2026, 1, 1))
));
assert!(refused(
&b.l,
"Harbor",
ended(ProjectStatus::Finished, Some(d(2026, 2, 1)), d(2026, 1, 31))
));
assert!(!refused(
&b.l,
"Harbor",
ended(ProjectStatus::Finished, Some(d(2026, 2, 1)), d(2026, 2, 1))
));
assert!(!refused(
&b.l,
"Harbor",
ended(ProjectStatus::Abandoned, None, d(2026, 2, 1))
));
// Links: at most eight, one address each, none twice.
let links = |v: Vec<&str>| ProjectFacts {
links: v.into_iter().map(String::from).collect(),
..Default::default()
};
assert!(refused(&b.l, "Harbor", links(vec!["https://a.example"; 2])));
assert!(refused(&b.l, "Harbor", links(vec!["a b"])));
assert!(refused(&b.l, "Harbor", links(vec![""])));
let nine: Vec<String> = (0..9).map(|i| format!("https://{i}.example")).collect();
assert!(refused(
&b.l,
"Harbor",
links(nine.iter().map(String::as_str).collect())
));
assert!(!refused(
&b.l,
"Harbor",
links(nine[..8].iter().map(String::as_str).collect())
));
// The party is in the directory.
assert!(refused(
&b.l,
"Harbor",
ProjectFacts {
party: Some(7),
..Default::default()
}
));
let p = b.l.add_party("Harbor Co", &[]).unwrap();
let (name, facts) =
b.l.check_set_project(
c,
" Harbor ",
&ProjectFacts {
party: Some(p),
description: Some(" the app ".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(
(name.as_str(), facts.description.as_deref()),
("Harbor", Some("the app"))
);
}
#[test]
fn set_project_renames_the_class_and_moves_no_figure() {
let mut b = books();
let c = b.l.add_class("Harbor").unwrap();
b.post(d(2026, 3, 1), b.cash, b.revenue, 10_000, Some((c, false)));
let before = project_to_date(&b.l, c, d(2026, 12, 31)).unwrap();
b.l.set_project(c, "Harbor app", on_spec()).unwrap();
assert_eq!(b.l.get_class(c).unwrap().name, "Harbor app");
let after = project_to_date(&b.l, c, d(2026, 12, 31)).unwrap();
assert_eq!(
(before.revenue, before.expenses),
(after.revenue, after.expenses)
);
assert_eq!(
b.l.project_facts(c).unwrap().kind,
Some(ProjectKind::OnSpec)
);
}
#[test]
fn add_class_refuses_a_duplicate_name() {
let mut b = books();
b.l.add_class("Harbor").unwrap();
let next = b.l.next_class_id();
assert_eq!(
b.l.add_class(" harbor "),
Err(LedgerError::DuplicateClassName("harbor".into()))
);
assert_eq!(b.l.next_class_id(), next, "a refusal allocates nothing");
// Trimmed as kept.
let c = b.l.add_class(" Dock ").unwrap();
assert_eq!(b.l.get_class(c).unwrap().name, "Dock");
}
#[test]
fn names_compare_one_way_for_the_duplicate_check_and_the_lookup() {
let mut b = books();
let c = b.l.add_class("Éclair").unwrap();
// The duplicate check and the lookup fold the same: capitals in any
// script, surrounding space.
assert_eq!(
b.l.add_class("éCLAIR"),
Err(LedgerError::DuplicateClassName("éCLAIR".into()))
);
for name in ["éclair", "ÉCLAIR", " Éclair ", "\téclair"] {
assert_eq!(
b.l.find_class_by_name(name).map(|x| x.id),
Some(c),
"{name:?}"
);
}
assert!(b.l.find_class_by_name("eclair").is_none());
}
#[test]
fn old_books_duplicates_resolve_to_the_lowest_id_and_change_freely() {
let mut l = Ledger::new(Currency::USD);
// Replay never checks names, so an earlier version's duplicates load.
l.replay_class(1, "Alpha".into()).unwrap();
l.replay_class(2, "alpha".into()).unwrap();
l.replay_class(3, "Harbor".into()).unwrap();
for _ in 0..20 {
assert_eq!(l.find_class_by_name("ALPHA").map(|c| c.id), Some(1));
}
let paused = ProjectFacts {
status: ProjectStatus::Paused,
..Default::default()
};
// Each takes a fact-only change under its own name.
assert_eq!(l.check_set_project(1, "Alpha", &paused).unwrap().0, "Alpha");
assert_eq!(l.check_set_project(2, "alpha", &paused).unwrap().0, "alpha");
// Its own name up to capitals and space is still its own.
assert_eq!(
l.check_set_project(2, " Alpha ", &paused).unwrap().0,
"Alpha"
);
// A rename onto another project's name is still refused.
assert_eq!(
l.check_set_project(2, "harbor", &paused),
Err(LedgerError::DuplicateClassName("harbor".into()))
);
}
#[test]
fn an_unchanged_name_is_kept_exactly_and_not_checked_again() {
let mut l = Ledger::new(Currency::USD);
// An earlier version kept names untrimmed, and wrote rules since
// tightened (a nine-digit job number) without checking them.
l.replay_class(1, "Retail ".into()).unwrap();
l.replay_class(2, "Job 123456789".into()).unwrap();
let ok = ProjectFacts::default();
assert_eq!(l.check_set_project(1, "Retail ", &ok).unwrap().0, "Retail ");
assert_eq!(
l.check_set_project(2, "Job 123456789", &ok).unwrap().0,
"Job 123456789"
);
// A new name gets every rule.
assert!(l.check_set_project(2, "Job 223456789", &ok).is_err());
assert_eq!(l.check_set_project(1, "Retail", &ok).unwrap().0, "Retail");
}
#[test]
fn a_name_of_digits_only_is_refused() {
let mut b = books();
let c = b.l.add_class("Harbor").unwrap();
for name in ["1", " 42 ", "0007"] {
assert!(
matches!(b.l.add_class(name), Err(LedgerError::InvalidProject(_))),
"{name:?}"
);
assert!(
b.l.check_set_project(c, name, &ProjectFacts::default())
.is_err(),
"{name:?}"
);
}
assert!(b.l.add_class("Job 42").is_ok());
}
#[test]
fn a_link_is_read_as_a_web_address_for_a_full_tin() {
let mut b = books();
let c = b.l.add_class("Harbor").unwrap();
let link = |l: &str| {
b.l.check_set_project(
c,
"Harbor",
&ProjectFacts {
links: vec![l.to_string()],
..Default::default()
},
)
};
// An id in a path or query is no TIN.
for ok in [
"https://apps.apple.com/us/app/harbor/id284882215",
"https://store.example/listing/284882215",
"https://store.example/app?id=284882215&x=1",
"https://drive.example/file/d/284882215abc/view",
] {
assert!(link(ok).is_ok(), "{ok}");
}
// A printed SSN or EIN, or nine digits standing alone, is.
for bad in [
"https://files.example/123-45-6789",
"https://files.example/ein-12-3456789.pdf",
"https://files.example/a.123456789.pdf",
"123456789",
] {
assert!(
matches!(link(bad), Err(LedgerError::InvalidProject(_))),
"{bad}"
);
}
}
#[test]
fn replay_set_project_takes_what_the_text_rules_would_refuse() {
let mut b = books();
let c = b.l.add_class("Harbor").unwrap();
// An end with no end status, and a nine-link list: what a later rule
// might refuse, an earlier version may have journaled.
let odd = ProjectFacts {
ended_on: Some(d(2026, 1, 1)),
links: (0..9).map(|i| format!("l{i}")).collect(),
..Default::default()
};
assert!(b.l.check_set_project(c, "Harbor", &odd).is_err());
b.l.replay_set_project(c, "Harbor".into(), odd.clone())
.unwrap();
assert_eq!(b.l.project_facts(c), Some(odd));
// What replay can't do without is still refused.
assert!(
b.l.replay_set_project(9, "x".into(), ProjectFacts::default())
.is_err()
);
assert!(
b.l.replay_set_project(
c,
"x".into(),
ProjectFacts {
party: Some(42),
..Default::default()
}
)
.is_err()
);
}
#[test]
fn project_figures_are_its_tagged_lines() {
let mut b = books();
let alpha = b.l.add_class("Alpha").unwrap();
let beta = b.l.add_class("Beta").unwrap();
b.post(
d(2026, 2, 3),
b.cash,
b.revenue,
500_000,
Some((alpha, false)),
);
b.post(
d(2026, 2, 10),
b.contractors,
b.cash,
180_000,
Some((alpha, true)),
);
b.post(
d(2026, 3, 10),
b.contractors,
b.cash,
8_000,
Some((beta, true)),
);
b.post(d(2026, 3, 28), b.rent, b.cash, 150_000, None);
let f = project_figures(&b.l, alpha, d(2026, 1, 1), d(2026, 3, 31)).unwrap();
assert_eq!(f.period.total_revenue, cents(500_000));
assert_eq!(f.period.total_expenses, cents(180_000));
assert_eq!(f.period.revenue.len(), 1);
assert_eq!(f.period.expenses[0].code, "6000");
assert_eq!(f.months.len(), 3);
assert_eq!(f.months[1].net(), cents(320_000));
assert!(
f.months[2].net().is_zero(),
"March is Beta's and the rent's"
);
let total: Decimal = f.months.iter().map(|m| m.net()).sum();
assert_eq!(total, f.period.net_income());
assert_eq!(
project_figures(&b.l, 99, d(2026, 1, 1), d(2026, 3, 31)).err(),
Some(LedgerError::UnknownClass(99))
);
}
#[test]
fn project_to_date_ignores_the_start_and_the_projects_dates() {
let mut b = books();
let c = b.l.add_class("Harbor").unwrap();
b.l.set_project(
c,
"Harbor",
ProjectFacts {
started_on: Some(d(2026, 6, 1)),
..Default::default()
},
)
.unwrap();
// A cost before kickoff is still the project's.
b.post(
d(2025, 11, 5),
b.contractors,
b.cash,
20_000,
Some((c, true)),
);
b.post(
d(2026, 7, 5),
b.contractors,
b.cash,
10_000,
Some((c, true)),
);
let f = project_figures(&b.l, c, d(2026, 7, 1), d(2026, 7, 31)).unwrap();
assert_eq!(f.period.total_expenses, cents(10_000));
assert_eq!(f.to_date.expenses, cents(30_000));
assert_eq!(f.to_date.through, d(2026, 7, 31));
assert_eq!(
project_to_date(&b.l, c, d(2025, 12, 31)).unwrap().expenses,
cents(20_000)
);
}
#[test]
fn a_tagged_balance_sheet_line_counts_nothing() {
let mut b = books();
let c = b.l.add_class("Beta").unwrap();
let e = b.post(d(2026, 3, 5), b.equipment, b.cash, 90_000, Some((c, true)));
let t = project_to_date(&b.l, c, d(2026, 12, 31)).unwrap();
assert_eq!((t.revenue, t.expenses), (Decimal::ZERO, Decimal::ZERO));
// Listed among its entries, with nothing of the project's.
assert_eq!(project_entries(&b.l, c), vec![(e, Decimal::ZERO)]);
}
#[test]
fn closing_entries_are_left_out_of_project_figures() {
let mut b = books();
let c = b.l.add_class("Alpha").unwrap();
b.post(d(2025, 6, 1), b.cash, b.revenue, 40_000, Some((c, false)));
let p =
b.l.create_period("2025", d(2025, 1, 1), d(2025, 12, 31))
.unwrap();
let close = b.l.close_period(p).unwrap();
assert!(close.is_some());
let f = project_figures(&b.l, c, d(2025, 1, 1), d(2025, 12, 31)).unwrap();
assert_eq!(f.period.total_revenue, cents(40_000));
assert_eq!(f.to_date.revenue, cents(40_000));
}
#[test]
fn a_reversal_nets_a_project_to_zero() {
let mut b = books();
let c = b.l.add_class("Alpha").unwrap();
let e = b.post(
d(2026, 3, 12),
b.contractors,
b.cash,
180_000,
Some((c, true)),
);
let r =
b.l.reverse_entry(e, d(2026, 3, 13), "billed twice")
.unwrap();
let t = project_to_date(&b.l, c, d(2026, 12, 31)).unwrap();
assert!(t.expenses.is_zero());
let entries = project_entries(&b.l, c);
assert_eq!(entries, vec![(r, cents(180_000)), (e, cents(-180_000))]);
}
#[test]
fn unrecovered_cost_only_for_on_spec_and_never_negative() {
let mut b = books();
let spec = b.l.add_class("Harbor app").unwrap();
b.l.set_project(spec, "Harbor app", on_spec()).unwrap();
let client = b.l.add_class("Client").unwrap();
b.post(
d(2026, 4, 2),
b.contractors,
b.cash,
146_000,
Some((spec, true)),
);
b.post(
d(2026, 4, 2),
b.contractors,
b.cash,
5_000,
Some((client, true)),
);
let at = |b: &Books, k| project_to_date(&b.l, k, d(2026, 12, 31)).unwrap();
assert_eq!(at(&b, spec).unrecovered_cost, Some(cents(146_000)));
assert_eq!(at(&b, client).unrecovered_cost, None);
b.post(
d(2026, 6, 15),
b.cash,
b.revenue,
100_000,
Some((spec, false)),
);
assert_eq!(at(&b, spec).unrecovered_cost, Some(cents(46_000)));
b.post(
d(2026, 7, 1),
b.cash,
b.revenue,
70_000,
Some((spec, false)),
);
assert_eq!(at(&b, spec).unrecovered_cost, Some(Decimal::ZERO));
assert_eq!(at(&b, spec).net(), cents(24_000));
}
#[test]
fn projects_of_party_lists_them_by_name() {
let mut b = books();
let p = b.l.add_party("Riley Chen", &[]).unwrap();
let with = |l: &mut Ledger, name: &str| {
let c = l.add_class(name).unwrap();
l.set_project(
c,
name,
ProjectFacts {
party: Some(p),
..Default::default()
},
)
.unwrap();
c
};
let zeta = with(&mut b.l, "Zeta");
let alpha = with(&mut b.l, "alpha");
b.l.add_class("Unrelated").unwrap();
assert_eq!(b.l.projects_of_party(p), vec![alpha, zeta]);
assert!(b.l.projects_of_party(p + 1).is_empty());
}
#[test]
fn check_decision_refuses_an_unknown_or_repeated_project() {
let mut b = books();
let c = b.l.add_class("Harbor").unwrap();
let decision = |projects| NewDecision {
subject: "Harbor".into(),
decision_md: "Build it.".into(),
decided_by: vec!["Tobin Harlow".into()],
decided_on: d(2026, 4, 1),
evidence_attachment: None,
supersedes: None,
citation: None,
projects,
};
assert_eq!(
b.l.check_decision(&decision(vec![9])).err(),
Some(LedgerError::UnknownClass(9))
);
assert!(b.l.check_decision(&decision(vec![c, c])).is_err());
assert_eq!(
b.l.check_decision(&decision(vec![c])).unwrap().1.projects,
vec![c]
);
}
#[test]
fn differs_from_names_projects() {
let mut a = books();
let c = a.l.add_class("Harbor").unwrap();
let before = a.l.clone();
a.l.set_project(c, "Harbor", on_spec()).unwrap();
assert_eq!(a.l.differs_from(&before), vec!["projects"]);
}