Projects: classes as entities, and the sidebar fits short windows
check / check (push) Canceled after 0s
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:
71 files changed
+7446
-217
No files matched your search
@@ -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,
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user