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))))
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
});
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"]);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ pub struct PageState {
|
||||
pub accounts: pages::accounts::State,
|
||||
pub assets: pages::assets::State,
|
||||
pub parties: pages::parties::State,
|
||||
pub projects: pages::projects::State,
|
||||
pub documents: pages::documents::State,
|
||||
pub journal: pages::journal::State,
|
||||
}
|
||||
@@ -412,6 +413,7 @@ impl App {
|
||||
"accounts" => Route::Accounts,
|
||||
"assets" => Route::Assets,
|
||||
"parties" => Route::Parties,
|
||||
"projects" => Route::Projects,
|
||||
"documents" => Route::Documents,
|
||||
"company" => Route::Company,
|
||||
"journal" => Route::Journal,
|
||||
@@ -494,6 +496,12 @@ impl App {
|
||||
}
|
||||
Route::Party(party_id)
|
||||
}
|
||||
T::Project { class_id } => {
|
||||
if b.ledger.get_class(class_id).is_none() {
|
||||
return;
|
||||
}
|
||||
Route::Project(class_id)
|
||||
}
|
||||
T::Reconciliation { id } => {
|
||||
// Its own page: statement, balance, the lines it cleared.
|
||||
if b.reconciliation(id).is_none() {
|
||||
@@ -601,8 +609,16 @@ impl App {
|
||||
let current = self.nav.current.section();
|
||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||
// Short windows: the links scroll above the status box
|
||||
// instead of the box drawing over them.
|
||||
let links_h = (ui.available_height() - SIDEBAR_FOOTER).max(64.0);
|
||||
// instead of the box drawing over them. The box's height
|
||||
// is the one it was drawn at last frame.
|
||||
let footer_id = egui::Id::new("sidebar_footer_h");
|
||||
let footer_h = ui
|
||||
.ctx()
|
||||
.data(|d| d.get_temp::<f32>(footer_id))
|
||||
.unwrap_or(SIDEBAR_FOOTER);
|
||||
// At least one link always shows; below that the footer
|
||||
// gives way (footer_layout).
|
||||
let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(32.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("sidebar_links")
|
||||
.max_height(links_h)
|
||||
@@ -613,6 +629,10 @@ impl App {
|
||||
vec2(ui.available_width(), 32.0),
|
||||
Sense::click(),
|
||||
);
|
||||
// Named for assistive tech (and the tests).
|
||||
resp.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title())
|
||||
});
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||
@@ -668,7 +688,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::bottom_up(Align::Min), |ui| {
|
||||
let footer = footer_layout(ui, footer_h, Align::Min, |ui| {
|
||||
self.scheme_menu(ui, false);
|
||||
if let Some(b) = &books {
|
||||
ui.add_space(2.0);
|
||||
@@ -685,6 +705,7 @@ impl App {
|
||||
);
|
||||
}
|
||||
});
|
||||
remember_footer(ui, footer_id, footer.response.rect.height(), footer_h);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -722,7 +743,12 @@ impl App {
|
||||
ui.add_space(18.0);
|
||||
let current = self.nav.current.section();
|
||||
let attention = books.as_ref().map(|b| b.attention.len()).unwrap_or(0);
|
||||
let links_h = (ui.available_height() - RAIL_FOOTER).max(72.0);
|
||||
let footer_id = egui::Id::new("rail_footer_h");
|
||||
let footer_h = ui
|
||||
.ctx()
|
||||
.data(|d| d.get_temp::<f32>(footer_id))
|
||||
.unwrap_or(RAIL_FOOTER);
|
||||
let links_h = (ui.available_height() - footer_h - FOOTER_GAP).max(36.0);
|
||||
egui::ScrollArea::vertical()
|
||||
.id_salt("rail_links")
|
||||
.max_height(links_h)
|
||||
@@ -733,6 +759,9 @@ impl App {
|
||||
vec2(ui.available_width(), 36.0),
|
||||
Sense::click(),
|
||||
);
|
||||
resp.widget_info(|| {
|
||||
egui::WidgetInfo::labeled(egui::WidgetType::Button, true, s.title())
|
||||
});
|
||||
if on {
|
||||
ui.painter()
|
||||
.rect_filled(rect, CornerRadius::same(8), p.surface);
|
||||
@@ -768,7 +797,7 @@ impl App {
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.with_layout(Layout::bottom_up(Align::Center), |ui| {
|
||||
let footer = footer_layout(ui, footer_h, Align::Center, |ui| {
|
||||
self.scheme_menu(ui, true);
|
||||
if let Some(b) = &books {
|
||||
self.menu.ui(ui, b, true);
|
||||
@@ -814,6 +843,7 @@ impl App {
|
||||
resp.on_hover_text(tip);
|
||||
}
|
||||
});
|
||||
remember_footer(ui, footer_id, footer.response.rect.height(), footer_h);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -948,10 +978,41 @@ impl App {
|
||||
/// Width of the icon rail.
|
||||
const RAIL_W: f32 = 60.0;
|
||||
/// Height kept clear under the sidebar's links for the status box and the
|
||||
/// theme toggle; the rail's equivalent.
|
||||
/// theme toggle before the footer is first measured; the rail's
|
||||
/// equivalent. After the first frame the footer's drawn height is used
|
||||
/// ([`remember_footer`]).
|
||||
const SIDEBAR_FOOTER: f32 = 172.0;
|
||||
const RAIL_FOOTER: f32 = 84.0;
|
||||
|
||||
/// Space kept between the last link and the footer.
|
||||
const FOOTER_GAP: f32 = 8.0;
|
||||
|
||||
/// Keep the footer's drawn height for the next frame's links, and draw
|
||||
/// again at once when it changed (the first frame, a check appearing).
|
||||
/// The sidebar's footer, bottom up: on the panel's floor when it fits
|
||||
/// under the links, else right under them, running off the bottom of the
|
||||
/// window, so it gives way and never draws over a link.
|
||||
fn footer_layout<R>(
|
||||
ui: &mut Ui,
|
||||
footer_h: f32,
|
||||
align: Align,
|
||||
add: impl FnOnce(&mut Ui) -> R,
|
||||
) -> egui::InnerResponse<R> {
|
||||
if ui.available_height() >= footer_h {
|
||||
ui.with_layout(Layout::bottom_up(align), add)
|
||||
} else {
|
||||
let w = ui.available_width();
|
||||
ui.allocate_ui_with_layout(vec2(w, footer_h), Layout::bottom_up(align), add)
|
||||
}
|
||||
}
|
||||
|
||||
fn remember_footer(ui: &Ui, id: egui::Id, drawn: f32, used: f32) {
|
||||
if (drawn - used).abs() > 0.5 {
|
||||
ui.ctx().data_mut(|d| d.insert_temp(id, drawn));
|
||||
ui.ctx().request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn live_status(ui: &mut Ui, b: &Books, since_update: f64, live: bool) {
|
||||
let p = pal(ui.ctx());
|
||||
Frame::new()
|
||||
|
||||
@@ -1056,6 +1056,14 @@ impl Books {
|
||||
.unwrap_or_else(|| format!("party {id}"))
|
||||
}
|
||||
|
||||
/// A project's name ("project 3" for none such).
|
||||
pub fn project_label(&self, id: ledger_core::ClassId) -> String {
|
||||
self.ledger
|
||||
.get_class(id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|| format!("project {id}"))
|
||||
}
|
||||
|
||||
/// Whether a party files a 1099: it has a 1099 role (contractor,
|
||||
/// landlord), or its vendor record has been decided for one (from any
|
||||
/// date: its W-9 matters for those payments).
|
||||
@@ -1352,6 +1360,12 @@ impl Books {
|
||||
Subject::Account(a) => self.account_label(a),
|
||||
Subject::Asset(a) => self.asset_label(a),
|
||||
Subject::Party(p) => self.party_label(p),
|
||||
Subject::Project(c) => format!(
|
||||
"Project {}",
|
||||
u32::try_from(c)
|
||||
.ok()
|
||||
.map_or_else(|| c.to_string(), |c| self.project_label(c))
|
||||
),
|
||||
Subject::Invoice(i) => format!("Invoice {i}"),
|
||||
Subject::Bill(i) => format!("Bill {i}"),
|
||||
Subject::Period(i) => l
|
||||
@@ -1390,6 +1404,7 @@ impl Books {
|
||||
Subject::Account(a) => Route::Account(a),
|
||||
Subject::Asset(a) => Route::Asset(a),
|
||||
Subject::Party(p) => Route::Party(p),
|
||||
Subject::Project(c) => Route::Project(u32::try_from(c).ok()?),
|
||||
Subject::Invoice(i) => Route::Entry(l.get_invoice(i)?.entry_id),
|
||||
Subject::Bill(i) => Route::Entry(l.get_bill(i)?.entry_id),
|
||||
Subject::PayrollRun(i) => Route::Entry(l.get_payroll_run(i)?.entry_id),
|
||||
|
||||
@@ -236,6 +236,10 @@ fn tooltip(
|
||||
pub struct BarGroup {
|
||||
pub label: String,
|
||||
pub values: Vec<Decimal>,
|
||||
/// The group's year, when the chart spans more than one: the first
|
||||
/// label drawn, and each drawn in a new year, gains it ("Jan 26"),
|
||||
/// whichever labels a crowded chart leaves out.
|
||||
pub year: Option<i32>,
|
||||
}
|
||||
|
||||
/// Grouped bars (e.g. income and spending per month), with a legend.
|
||||
@@ -245,6 +249,22 @@ pub fn bar_chart(
|
||||
groups: &[BarGroup],
|
||||
series: &[(&str, Color32)],
|
||||
height: f32,
|
||||
) -> Option<usize> {
|
||||
bars(ui, groups, series, height, true)
|
||||
}
|
||||
|
||||
/// [`bar_chart`] that opens nothing: hovering shows each group's figures,
|
||||
/// and the pointer stays an arrow, since no page shows those figures.
|
||||
pub fn bar_chart_view(ui: &mut Ui, groups: &[BarGroup], series: &[(&str, Color32)], height: f32) {
|
||||
bars(ui, groups, series, height, false);
|
||||
}
|
||||
|
||||
fn bars(
|
||||
ui: &mut Ui,
|
||||
groups: &[BarGroup],
|
||||
series: &[(&str, Color32)],
|
||||
height: f32,
|
||||
clickable: bool,
|
||||
) -> Option<usize> {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
@@ -259,7 +279,12 @@ pub fn bar_chart(
|
||||
ui.add_space(8.0);
|
||||
}
|
||||
});
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), Sense::click());
|
||||
let sense = if clickable {
|
||||
Sense::click()
|
||||
} else {
|
||||
Sense::hover()
|
||||
};
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), height), sense);
|
||||
let painter = ui.painter_at(rect);
|
||||
if groups.is_empty() {
|
||||
return None;
|
||||
@@ -293,7 +318,12 @@ pub fn bar_chart(
|
||||
let bw = ((gw * 0.7) / n).min(28.0);
|
||||
let mut clicked = None;
|
||||
let hover = resp.hover_pos();
|
||||
let mut drawn_year: Option<i32> = None;
|
||||
for (gi, g) in groups.iter().enumerate() {
|
||||
let full = match g.year {
|
||||
Some(y) => format!("{} {:02}", g.label, y.rem_euclid(100)),
|
||||
None => g.label.clone(),
|
||||
};
|
||||
let gx = plot.left() + gw * gi as f32;
|
||||
let grect = Rect::from_min_max(pos2(gx, plot.top()), pos2(gx + gw, plot.bottom()));
|
||||
let hovered = hover.is_some_and(|h| grect.contains(h));
|
||||
@@ -325,10 +355,12 @@ pub fn bar_chart(
|
||||
// the latest so it's always labeled.
|
||||
let every = (34.0 / gw).ceil().max(1.0) as usize;
|
||||
if (groups.len() - 1 - gi).is_multiple_of(every) {
|
||||
let new_year = g.year.is_some() && g.year != drawn_year;
|
||||
drawn_year = g.year.or(drawn_year);
|
||||
painter.text(
|
||||
pos2(gx + gw / 2.0, plot.bottom() + 6.0),
|
||||
Align2::CENTER_TOP,
|
||||
&g.label,
|
||||
if new_year { &full } else { &g.label },
|
||||
theme::regular(11.0),
|
||||
p.faint,
|
||||
);
|
||||
@@ -342,7 +374,7 @@ pub fn bar_chart(
|
||||
tooltip(
|
||||
&painter,
|
||||
pos2(gx + gw / 2.0, plot.top()),
|
||||
&g.label,
|
||||
&full,
|
||||
&lines.join("\n"),
|
||||
p.surface,
|
||||
p.border,
|
||||
@@ -350,10 +382,12 @@ pub fn bar_chart(
|
||||
p.muted,
|
||||
rect,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(gi);
|
||||
if clickable {
|
||||
if resp.clicked() {
|
||||
clicked = Some(gi);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
}
|
||||
clicked
|
||||
@@ -362,6 +396,17 @@ pub fn bar_chart(
|
||||
/// Ranked horizontal bars with labels and exact amounts. Returns the
|
||||
/// clicked row.
|
||||
pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<usize> {
|
||||
hbars_signed(ui, rows, color, color)
|
||||
}
|
||||
|
||||
/// [`hbars`] with a row below zero (a loss) drawn in `negative`, its
|
||||
/// amount too, so the longest bar can't read as the best.
|
||||
pub fn hbars_signed(
|
||||
ui: &mut Ui,
|
||||
rows: &[(String, Decimal)],
|
||||
color: Color32,
|
||||
negative: Color32,
|
||||
) -> Option<usize> {
|
||||
let p = pal(ui.ctx());
|
||||
let max = rows
|
||||
.iter()
|
||||
@@ -371,13 +416,21 @@ pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<
|
||||
let mut clicked = None;
|
||||
for (i, (label, v)) in rows.iter().enumerate() {
|
||||
let (rect, resp) = ui.allocate_exact_size(vec2(ui.available_width(), 30.0), Sense::click());
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, label));
|
||||
let painter = ui.painter_at(rect);
|
||||
if resp.hovered() {
|
||||
painter.rect_filled(rect, CornerRadius::same(6), p.hover);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
}
|
||||
let below = *v < Decimal::ZERO;
|
||||
let fill = if below { negative } else { color };
|
||||
let amount_color = if below && negative != color {
|
||||
negative
|
||||
} else {
|
||||
p.text
|
||||
};
|
||||
let amount = fmt::dollars(*v);
|
||||
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), p.text);
|
||||
let ag = painter.layout_no_wrap(amount, theme::regular(13.0), amount_color);
|
||||
let label_w = (rect.width() * 0.38).min(220.0);
|
||||
let lg = painter.layout(label.clone(), theme::regular(13.0), p.text, label_w);
|
||||
painter.galley(
|
||||
@@ -392,14 +445,14 @@ pub fn hbars(ui: &mut Ui, rows: &[(String, Decimal)], color: Color32) -> Option<
|
||||
pos2(bar_left, rect.center().y - 6.0),
|
||||
vec2(w.max(2.0), 12.0),
|
||||
);
|
||||
painter.rect_filled(bar, CornerRadius::same(3), color);
|
||||
painter.rect_filled(bar, CornerRadius::same(3), fill);
|
||||
painter.galley(
|
||||
pos2(
|
||||
rect.right() - ag.size().x - 6.0,
|
||||
rect.center().y - ag.size().y / 2.0,
|
||||
),
|
||||
ag,
|
||||
p.text,
|
||||
amount_color,
|
||||
);
|
||||
if resp.clicked() {
|
||||
clicked = Some(i);
|
||||
@@ -491,4 +544,89 @@ mod tests {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Months from November 2024 through October 2026, as the project and
|
||||
/// party charts give them.
|
||||
fn two_years() -> Vec<BarGroup> {
|
||||
(0..24)
|
||||
.map(|i| {
|
||||
let m = d(2024, 11, 1) + chrono::Months::new(i);
|
||||
BarGroup {
|
||||
label: m.format("%b").to_string(),
|
||||
values: vec![Decimal::new(i64::from(i) * 100, 2)],
|
||||
year: Some(chrono::Datelike::year(&m)),
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A crowded chart over two years drops every other label, January's
|
||||
/// too, yet the first label drawn and the first drawn in each new year
|
||||
/// carry the year, so no month name stands for two months unread.
|
||||
#[test]
|
||||
fn a_crowded_chart_keeps_its_years() {
|
||||
for width in [380.0, 800.0] {
|
||||
let groups = two_years();
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(width, 300.0))
|
||||
.build_ui(move |ui| {
|
||||
bar_chart_view(ui, &groups, &[("Net", Color32::GRAY)], 200.0);
|
||||
});
|
||||
crate::theme::install_fonts(&h.ctx);
|
||||
crate::theme::apply(&h.ctx, false);
|
||||
h.run();
|
||||
let mut labels: Vec<(String, f32)> = painted(h.output())
|
||||
.into_iter()
|
||||
.filter(|(t, _, _)| {
|
||||
t.len() <= 6 && t.chars().next().is_some_and(char::is_alphabetic)
|
||||
})
|
||||
.filter(|(t, _, _)| t != "Net")
|
||||
.map(|(t, p, _)| (t, p.x))
|
||||
.collect();
|
||||
labels.sort_by(|a, b| a.1.total_cmp(&b.1));
|
||||
let names: Vec<&str> = labels.iter().map(|l| l.0.as_str()).collect();
|
||||
assert!(names.len() < 24, "{width}: crowded, so thinned: {names:?}");
|
||||
let tagged = |n: &&str| n.ends_with(char::is_numeric);
|
||||
assert!(tagged(&names[0]), "{width}: {names:?}");
|
||||
for year in [" 25", " 26"] {
|
||||
assert_eq!(
|
||||
names.iter().filter(|n| n.ends_with(year)).count(),
|
||||
1,
|
||||
"{width}: {names:?}"
|
||||
);
|
||||
}
|
||||
// 2026's tag sits on the first label drawn in 2026.
|
||||
let at = names.iter().position(|n| n.ends_with(" 26")).unwrap();
|
||||
assert!(
|
||||
["Jan", "Feb", "Mar"].contains(&&names[at][..3]) && !names[at - 1].ends_with(" 26"),
|
||||
"{width}: {names:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A chart with nothing to open takes no click: hovering shows its
|
||||
/// figures, and the pointer stays an arrow.
|
||||
#[test]
|
||||
fn a_view_only_chart_shows_figures_and_takes_no_click() {
|
||||
let groups = two_years();
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(800.0, 300.0))
|
||||
.build_ui(move |ui| {
|
||||
bar_chart_view(ui, &groups, &[("Net", Color32::GRAY)], 200.0);
|
||||
});
|
||||
crate::theme::install_fonts(&h.ctx);
|
||||
crate::theme::apply(&h.ctx, false);
|
||||
h.run();
|
||||
h.hover_at(egui::pos2(780.0, 120.0));
|
||||
h.run();
|
||||
let texts = painted(h.output());
|
||||
assert!(
|
||||
texts.iter().any(|(t, _, _)| t == "Oct 26"),
|
||||
"the tooltip names its month and year"
|
||||
);
|
||||
assert_ne!(
|
||||
h.output().platform_output.cursor_icon,
|
||||
egui::CursorIcon::PointingHand
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -242,8 +242,25 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
Some("depreciation"),
|
||||
)?;
|
||||
let bank_fees = acct(&mut s, "6900", "Bank Fees", Expense, None)?;
|
||||
let client_class = s.add_class("Client work")?;
|
||||
let template_class = s.add_class("Templates")?;
|
||||
// The projects (classes with their facts, rules.md B-37).
|
||||
let client_class = s.add_project(
|
||||
"Client work",
|
||||
&ledger_core::ProjectFacts {
|
||||
description: Some("Design work for the studio's clients".into()),
|
||||
kind: Some(ledger_core::ProjectKind::Client),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
let template_class = s.add_project(
|
||||
"Templates",
|
||||
&ledger_core::ProjectFacts {
|
||||
description: Some("Notion and Figma templates sold on Gumroad".into()),
|
||||
kind: Some(ledger_core::ProjectKind::Internal),
|
||||
started_on: Some(start),
|
||||
links: vec!["https://juniper.example/templates".into()],
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// Each vendor's 1099 decision, as the owner made it when they were
|
||||
// first paid; the card is a payment method, so what was charged to it
|
||||
@@ -282,6 +299,21 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
.iter()
|
||||
.map(|n| s.add_customer(n))
|
||||
.collect::<Result<_, _>>()?;
|
||||
// Work on spec for a customer, hoping they adopt it: its costs are
|
||||
// expenses, and nothing is receivable (rules.md B-37 item 4).
|
||||
let tidepool_started = start + Months::new(13);
|
||||
let tidepool = s.add_project(
|
||||
"Tidepool app",
|
||||
&ledger_core::ProjectFacts {
|
||||
description: Some(
|
||||
"An events app built for Tidepool Records ahead of a contract".into(),
|
||||
),
|
||||
kind: Some(ledger_core::ProjectKind::OnSpec),
|
||||
party: s.ledger().party_of_customer(customers[3]),
|
||||
started_on: Some(tidepool_started),
|
||||
..Default::default()
|
||||
},
|
||||
)?;
|
||||
|
||||
// The party directory: who they are, beyond the vendor and customer
|
||||
// records (roles, W-9 facts with only the TIN's last four, sites).
|
||||
@@ -827,7 +859,12 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
),
|
||||
)),
|
||||
Some(riley),
|
||||
Some(client_class),
|
||||
// The second milestone builds the Tidepool app.
|
||||
Some(if month_index == 13 {
|
||||
tidepool
|
||||
} else {
|
||||
client_class
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
}
|
||||
@@ -962,6 +999,33 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
|
||||
)?;
|
||||
s.link_attachment(contract_doc, Subject::Party(sam_party))?;
|
||||
s.link_attachment(contract_doc, Subject::Company)?;
|
||||
// The Tidepool app's pitch, linked to it, and the decision to build it
|
||||
// on spec.
|
||||
let (pitch, _, _) = s.file_document(
|
||||
"other",
|
||||
"Tidepool app pitch.pdf",
|
||||
"application/pdf",
|
||||
&receipt_pdf(
|
||||
"Tidepool app pitch",
|
||||
&[
|
||||
"Juniper Studio LLC for Tidepool Records".into(),
|
||||
"An events app: shows, tickets, a mailing list".into(),
|
||||
format!("Sent {}", tidepool_started.format("%B %-d, %Y")),
|
||||
],
|
||||
),
|
||||
Some("The pitch"),
|
||||
)?;
|
||||
s.link_attachment(pitch, Subject::Project(u64::from(tidepool)))?;
|
||||
s.record_decision(&ledger_core::conventions::NewDecision {
|
||||
subject: "The Tidepool app".into(),
|
||||
decision_md: "Build the Tidepool app on spec; bill only if they adopt it.".into(),
|
||||
decided_by: vec!["Alex Rivera".into()],
|
||||
decided_on: tidepool_started,
|
||||
evidence_attachment: Some(pitch),
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![tidepool],
|
||||
})?;
|
||||
|
||||
// Statements and reconciliations: savings reconciled through last
|
||||
// month; checking through the month before (its first reconciliation
|
||||
|
||||
@@ -70,6 +70,10 @@ pub enum Route {
|
||||
Parties,
|
||||
/// One party.
|
||||
Party(PartyId),
|
||||
/// The projects (classes with their facts).
|
||||
Projects,
|
||||
/// One project.
|
||||
Project(ledger_core::ClassId),
|
||||
Documents,
|
||||
Document(u64),
|
||||
Company,
|
||||
@@ -89,6 +93,7 @@ impl Route {
|
||||
Route::Accounts | Route::Account(_) | Route::Reconciliation(_) => Section::Accounts,
|
||||
Route::Assets | Route::Asset(_) => Section::Assets,
|
||||
Route::Parties | Route::Party(_) => Section::Parties,
|
||||
Route::Projects | Route::Project(_) => Section::Projects,
|
||||
Route::Documents | Route::Document(_) => Section::Documents,
|
||||
Route::Company => Section::Company,
|
||||
Route::Journal | Route::JournalEvent(_) => Section::Journal,
|
||||
@@ -106,13 +111,14 @@ pub enum Section {
|
||||
Accounts,
|
||||
Assets,
|
||||
Parties,
|
||||
Projects,
|
||||
Documents,
|
||||
Company,
|
||||
Journal,
|
||||
}
|
||||
|
||||
impl Section {
|
||||
pub const ALL: [Section; 11] = [
|
||||
pub const ALL: [Section; 12] = [
|
||||
Section::Overview,
|
||||
Section::Cash,
|
||||
Section::Profit,
|
||||
@@ -121,6 +127,7 @@ impl Section {
|
||||
Section::Accounts,
|
||||
Section::Assets,
|
||||
Section::Parties,
|
||||
Section::Projects,
|
||||
Section::Documents,
|
||||
Section::Company,
|
||||
Section::Journal,
|
||||
@@ -136,6 +143,7 @@ impl Section {
|
||||
Section::Accounts => "Accounts",
|
||||
Section::Assets => "Assets",
|
||||
Section::Parties => "Parties",
|
||||
Section::Projects => "Projects",
|
||||
Section::Documents => "Documents",
|
||||
Section::Company => "Company",
|
||||
Section::Journal => "Journal",
|
||||
@@ -153,6 +161,7 @@ impl Section {
|
||||
Section::Accounts => i::TREE_STRUCTURE,
|
||||
Section::Assets => i::PACKAGE,
|
||||
Section::Parties => i::ADDRESS_BOOK,
|
||||
Section::Projects => i::BRIEFCASE,
|
||||
Section::Documents => i::FILES,
|
||||
Section::Company => i::BUILDINGS,
|
||||
Section::Journal => i::SCROLL,
|
||||
@@ -169,6 +178,7 @@ impl Section {
|
||||
Section::Accounts => Route::Accounts,
|
||||
Section::Assets => Route::Assets,
|
||||
Section::Parties => Route::Parties,
|
||||
Section::Projects => Route::Projects,
|
||||
Section::Documents => Route::Documents,
|
||||
Section::Company => Route::Company,
|
||||
Section::Journal => Route::Journal,
|
||||
|
||||
@@ -385,6 +385,16 @@ pub(crate) fn row(
|
||||
Sense::hover()
|
||||
},
|
||||
);
|
||||
if click {
|
||||
// Named by its main cell, for assistive tech (and the tests).
|
||||
let name = cells
|
||||
.iter()
|
||||
.find(|c| c.1 == 0.0)
|
||||
.or(cells.first())
|
||||
.map(|c| c.0.clone())
|
||||
.unwrap_or_default();
|
||||
resp.widget_info(|| egui::WidgetInfo::labeled(egui::WidgetType::Button, true, &name));
|
||||
}
|
||||
if click && resp.hovered() {
|
||||
ui.painter().rect_filled(r, CornerRadius::same(4), p.hover);
|
||||
ui.ctx().set_cursor_icon(egui::CursorIcon::PointingHand);
|
||||
|
||||
@@ -135,6 +135,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
.map(|(s, _, i, o)| BarGroup {
|
||||
label: s.format("%b").to_string(),
|
||||
values: vec![*i, *o],
|
||||
year: None,
|
||||
})
|
||||
.collect();
|
||||
charts::bar_chart(
|
||||
|
||||
@@ -50,7 +50,7 @@ fn provenance(ui: &mut Ui, by: &str, on: chrono::NaiveDate) {
|
||||
|
||||
/// How the books are kept, as the owners decided: conventions, decisions,
|
||||
/// and what is open (each only when there is something to show).
|
||||
fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
fn kept_cards(ui: &mut Ui, b: &Books, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let kept = b.ledger.conventions();
|
||||
let conventions: Vec<_> = kept.conventions().filter(|c| c.retired.is_none()).collect();
|
||||
@@ -95,6 +95,27 @@ fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
RichText::new(&d.new.decision_md).font(theme::regular(12.5)),
|
||||
&d.new.decision_md,
|
||||
);
|
||||
if !d.new.projects.is_empty() {
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
for &c in &d.new.projects {
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!(
|
||||
"{} {}",
|
||||
icon::BRIEFCASE,
|
||||
b.project_label(c)
|
||||
))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.accent),
|
||||
)
|
||||
.on_hover_text("Open this project")
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Project(c));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
provenance(ui, &d.new.decided_by.join(" and "), d.new.decided_on);
|
||||
ui.add_space(6.0);
|
||||
}
|
||||
@@ -132,6 +153,7 @@ fn kept_cards(ui: &mut Ui, b: &Books) {
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let mut kept_go: Option<Route> = None;
|
||||
page(ui, "company", |ui| {
|
||||
let c = &b.settings.company;
|
||||
widgets::page_header(
|
||||
@@ -192,7 +214,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
);
|
||||
},
|
||||
);
|
||||
kept_cards(ui, b);
|
||||
kept_cards(ui, b, &mut kept_go);
|
||||
}
|
||||
_ => {
|
||||
widgets::titled_card(
|
||||
@@ -332,6 +354,9 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
recurring(ui, b);
|
||||
parties(ui, b, shell);
|
||||
});
|
||||
if let Some(r) = kept_go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
|
||||
fn assets(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
|
||||
@@ -371,6 +371,7 @@ pub fn subject_icon(s: Subject) -> &'static str {
|
||||
Subject::Account(_) => icon::TREE_STRUCTURE,
|
||||
Subject::Asset(_) => icon::PACKAGE,
|
||||
Subject::Party(_) => icon::ADDRESS_BOOK,
|
||||
Subject::Project(_) => icon::BRIEFCASE,
|
||||
Subject::Invoice(_) | Subject::Bill(_) => icon::FILE_TEXT,
|
||||
Subject::Period(_) => icon::CALENDAR_BLANK,
|
||||
Subject::PayrollRun(_) => icon::USERS,
|
||||
|
||||
@@ -239,13 +239,19 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
|
||||
if r.clicked() {
|
||||
shell.go(Route::Account(line.account));
|
||||
}
|
||||
if let Some(c) = line.class_id.and_then(|c| class_names.get(&c))
|
||||
{
|
||||
ui.label(
|
||||
if let Some((cid, c)) = line
|
||||
.class_id
|
||||
.and_then(|c| class_names.get(&c).map(|n| (c, n)))
|
||||
&& widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("{} {c}", icon::TAG))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
)
|
||||
.on_hover_text("Open this project")
|
||||
.clicked()
|
||||
{
|
||||
shell.go(Route::Project(cid));
|
||||
}
|
||||
if let (Some(cur), Some(fa)) =
|
||||
(&line.foreign_currency, line.foreign_amount)
|
||||
|
||||
@@ -479,7 +479,26 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
.map_or(Route::Company, Route::Party),
|
||||
),
|
||||
),
|
||||
"class.added" => ev(icon::TAG, format!("Added class {}", s(p, "name")), None),
|
||||
"class.added" => ev(
|
||||
icon::BRIEFCASE,
|
||||
format!("Added project {}", s(p, "name")),
|
||||
u32::try_from(id).ok().map(Route::Project),
|
||||
),
|
||||
"project.set" => {
|
||||
let status = ledger_core::ProjectStatus::parse(s(p, "status"))
|
||||
.map_or("", |x| x.words())
|
||||
.to_lowercase();
|
||||
let kind = match p.get("kind").and_then(|v| v.as_str()) {
|
||||
Some(k) => ledger_core::ProjectKind::parse(k)
|
||||
.map_or(k.to_string(), |x| x.words().to_lowercase()),
|
||||
None => "kind not recorded".into(),
|
||||
};
|
||||
ev(
|
||||
icon::BRIEFCASE,
|
||||
format!("Project {}: {status}, {kind}", s(p, "name")),
|
||||
u32::try_from(id).ok().map(Route::Project),
|
||||
)
|
||||
}
|
||||
"party.added" => {
|
||||
let roles: Vec<String> = p
|
||||
.get("roles")
|
||||
@@ -1114,8 +1133,8 @@ pub fn describe_any(b: &Books, line: &JournalLine) -> Event {
|
||||
.get("class_id")
|
||||
.and_then(|v| v.as_u64())
|
||||
.and_then(|c| b.ledger.get_class(c as u32))
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_else(|| "no class".into());
|
||||
.map(|c| format!("project {}", c.name))
|
||||
.unwrap_or_else(|| "no project".into());
|
||||
Event {
|
||||
icon: icon::TAG,
|
||||
text: format!(
|
||||
|
||||
@@ -11,6 +11,7 @@ pub mod journal;
|
||||
pub mod overview;
|
||||
pub mod parties;
|
||||
pub mod profit;
|
||||
pub mod projects;
|
||||
pub mod reconciliation;
|
||||
pub mod reports;
|
||||
pub mod transactions;
|
||||
@@ -61,6 +62,8 @@ pub fn show(ui: &mut Ui, b: &Books, route: &Route, shell: &mut Shell<'_>) {
|
||||
Route::Asset(id) => assets::show_asset(ui, b, *id, shell),
|
||||
Route::Parties => parties::show_list(ui, b, shell),
|
||||
Route::Party(id) => parties::show_party(ui, b, *id, shell),
|
||||
Route::Projects => projects::show_list(ui, b, shell),
|
||||
Route::Project(id) => projects::show_project(ui, b, *id, shell),
|
||||
Route::Documents => documents::show(ui, b, None, shell),
|
||||
Route::Document(id) => documents::show(ui, b, Some(*id), shell),
|
||||
Route::Company => company::show(ui, b, shell),
|
||||
|
||||
@@ -397,6 +397,7 @@ fn income_vs_spending(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
.map(|m| BarGroup {
|
||||
label: m.start.format("%b").to_string(),
|
||||
values: vec![m.revenue, m.expenses, m.net()],
|
||||
year: None,
|
||||
})
|
||||
.collect();
|
||||
let clicked = charts::bar_chart(
|
||||
|
||||
@@ -322,7 +322,7 @@ fn paid_cell(paid: Decimal, p: &theme::Palette) -> (String, egui::Color32) {
|
||||
}
|
||||
|
||||
/// A filter chip.
|
||||
fn chip(ui: &mut Ui, text: &str, on: bool) -> bool {
|
||||
pub(crate) fn chip(ui: &mut Ui, text: &str, on: bool) -> bool {
|
||||
let p = pal(ui.ctx());
|
||||
ui.add(
|
||||
egui::Button::new(
|
||||
@@ -642,7 +642,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::copyable(
|
||||
widgets::copyable_truncated(
|
||||
ui,
|
||||
RichText::new(party.label()).font(theme::bold(26.0)),
|
||||
party.label(),
|
||||
@@ -654,7 +654,7 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
widgets::copyable(ui, RichText::new(&party.name), &party.name);
|
||||
widgets::copyable_truncated(ui, RichText::new(&party.name), &party.name);
|
||||
});
|
||||
}
|
||||
ui.add_space(6.0);
|
||||
@@ -841,6 +841,11 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
}
|
||||
});
|
||||
|
||||
let projects = b.ledger.projects_of_party(id);
|
||||
if !projects.is_empty() {
|
||||
projects_card(ui, b, &projects, &mut go);
|
||||
ui.add_space(14.0);
|
||||
}
|
||||
entries_card(ui, b, &info, &mut go);
|
||||
ui.add_space(14.0);
|
||||
if !info.invoices.is_empty() || !info.bills.is_empty() {
|
||||
@@ -879,26 +884,9 @@ pub fn show_party(ui: &mut Ui, b: &Books, id: PartyId, shell: &mut Shell<'_>) {
|
||||
for &i in &info.events {
|
||||
let line = &b.journal[i];
|
||||
let ev = super::journal::describe_any(b, line);
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(ev.icon).color(p.muted));
|
||||
if widgets::link_truncated(
|
||||
ui,
|
||||
RichText::new(&ev.text).font(theme::regular(12.5)),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::JournalEvent(line.seq));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new(format!(
|
||||
"event {} · {}",
|
||||
line.seq,
|
||||
fmt::timestamp(&line.ts)
|
||||
))
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
});
|
||||
if widgets::history_row(ui, ev.icon, &ev.text, line.seq, &line.ts).clicked() {
|
||||
go = Some(Route::JournalEvent(line.seq));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -919,14 +907,14 @@ fn facts_card(ui: &mut Ui, b: &Books, party: &Party, go: &mut Option<Route>) {
|
||||
facts(ui, |ui, k| {
|
||||
let text = |ui: &mut Ui, v: &Option<String>| match v {
|
||||
Some(t) => {
|
||||
widgets::copyable(ui, RichText::new(t), t);
|
||||
widgets::copyable_truncated(ui, RichText::new(t), t);
|
||||
}
|
||||
None => {
|
||||
ui.label(RichText::new("not recorded").color(p.faint));
|
||||
}
|
||||
};
|
||||
k(ui, "Name");
|
||||
widgets::copyable(ui, RichText::new(&party.name), &party.name);
|
||||
widgets::copyable_truncated(ui, RichText::new(&party.name), &party.name);
|
||||
ui.end_row();
|
||||
k(ui, "Display name");
|
||||
text(ui, &party.display_name);
|
||||
@@ -1223,6 +1211,59 @@ fn records_card(ui: &mut Ui, b: &Books, party: &Party, info: &PartyInfo, go: &mu
|
||||
);
|
||||
}
|
||||
|
||||
/// The projects for or with the party (rules.md B-37), by name: status
|
||||
/// and net to date; a row opens its project.
|
||||
fn projects_card(ui: &mut Ui, b: &Books, ids: &[ledger_core::ClassId], go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Projects",
|
||||
|ui| {
|
||||
widgets::pill(ui, &ids.len().to_string(), p.muted, p.hover);
|
||||
},
|
||||
|ui| {
|
||||
let amt = widgets::AMOUNT_MIN + 24.0;
|
||||
let w = ui.available_width().max(100.0 + amt + 180.0);
|
||||
widgets::min_width(ui, "party_projects", w, |ui| {
|
||||
for &id in ids {
|
||||
let name = b.project_label(id);
|
||||
let f = b.ledger.project_facts(id).unwrap_or_default();
|
||||
let net = ledger_core::project_to_date(&b.ledger, id, b.today)
|
||||
.ok()
|
||||
.map(|t| t.net());
|
||||
let (net_t, net_c) = match net {
|
||||
Some(n) if n.is_zero() => ("—".to_string(), p.faint),
|
||||
Some(n) if n < Decimal::ZERO => (fmt::accounting(n), p.negative),
|
||||
Some(n) => (fmt::accounting(n), p.text),
|
||||
None => (fmt::CANT_COMPUTE.to_string(), p.faint),
|
||||
};
|
||||
let sub = format!("{} · net to date", super::projects::kind_words(f.kind));
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(name, 0.0, false, p.text),
|
||||
(
|
||||
f.status.words().to_string(),
|
||||
100.0,
|
||||
false,
|
||||
super::projects::status_colors(f.status, &p).0,
|
||||
),
|
||||
(net_t, amt, true, net_c),
|
||||
],
|
||||
Some((sub.as_str(), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open this project").clicked() {
|
||||
*go = Some(Route::Project(id));
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Every entry tagged with the party, newest first.
|
||||
fn entries_card(ui: &mut Ui, b: &Books, info: &PartyInfo, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
@@ -1608,15 +1649,12 @@ fn months_card(ui: &mut Ui, b: &Books, id: PartyId, span: (NaiveDate, NaiveDate)
|
||||
let groups: Vec<crate::charts::BarGroup> = shown
|
||||
.iter()
|
||||
.map(|m| crate::charts::BarGroup {
|
||||
label: if multi_year && m.month.month() == 1 {
|
||||
m.month.format("%b %y").to_string()
|
||||
} else {
|
||||
m.month.format("%b").to_string()
|
||||
},
|
||||
label: m.month.format("%b").to_string(),
|
||||
values: vec![m.paid, m.received],
|
||||
year: multi_year.then(|| m.month.year()),
|
||||
})
|
||||
.collect();
|
||||
crate::charts::bar_chart(
|
||||
crate::charts::bar_chart_view(
|
||||
ui,
|
||||
&groups,
|
||||
&[("Paid out", p.series[2]), ("Received in", p.series[1])],
|
||||
|
||||
@@ -18,10 +18,15 @@ use ledger_core::reports::profit_and_loss_for_period;
|
||||
use rust_decimal::Decimal;
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
fn pct(part: Decimal, whole: Decimal) -> Option<Decimal> {
|
||||
pub(crate) fn pct(part: Decimal, whole: Decimal) -> Option<Decimal> {
|
||||
part.checked_div(whole)?
|
||||
.checked_mul(Decimal::from(100))
|
||||
.map(|x| x.round_dp(1))
|
||||
.map(|x| {
|
||||
// One place always, so 100% prints as "100.0", as 64.0 does.
|
||||
let mut x = x.round_dp(1);
|
||||
x.rescale(1);
|
||||
x
|
||||
})
|
||||
}
|
||||
|
||||
pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
@@ -74,6 +79,7 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
.map(|m| BarGroup {
|
||||
label: m.start.format("%b").to_string(),
|
||||
values: vec![m.revenue, m.expenses, m.net()],
|
||||
year: None,
|
||||
})
|
||||
.collect();
|
||||
if let Some(i) = charts::bar_chart(
|
||||
@@ -160,10 +166,14 @@ pub fn show(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
);
|
||||
ui.add_space(16.0);
|
||||
spending_changes(ui, b, shell);
|
||||
let mut go = None;
|
||||
columns(ui, 980.0, &[1.0, 1.0], |i, ui| match i {
|
||||
0 => by_customer(ui, b),
|
||||
_ => by_class(ui, b),
|
||||
_ => by_class(ui, b, &mut go),
|
||||
});
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -359,46 +369,52 @@ fn rows_total(by: &BTreeMap<String, Decimal>) -> Option<Decimal> {
|
||||
.try_fold(Decimal::ZERO, |t, v| t.checked_add(*v))
|
||||
}
|
||||
|
||||
fn by_class(ui: &mut Ui, b: &Books) {
|
||||
/// Each project's net income this year, a bar each; a bar opens its
|
||||
/// project.
|
||||
fn by_class(ui: &mut Ui, b: &Books, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let (ys, ye) = b.fiscal_ytd();
|
||||
let classes = b.ledger.list_classes();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Net income by class, this year",
|
||||
"Net income by project, this year",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if classes.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::TAG,
|
||||
"No classes",
|
||||
"Tag lines with classes (departments, products) to see profit by each.",
|
||||
icon::BRIEFCASE,
|
||||
"No projects",
|
||||
"Tag lines with a project to see profit by each.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let mut rows: Vec<(String, Decimal)> = classes
|
||||
let mut rows: Vec<(ledger_core::ClassId, String, Decimal)> = classes
|
||||
.iter()
|
||||
.filter_map(|c| {
|
||||
let pl = profit_and_loss_for_period(&b.ledger, ys, ye, Some(c.id)).ok()?;
|
||||
let net = pl.total_revenue - pl.total_expenses;
|
||||
(!pl.total_revenue.is_zero() || !pl.total_expenses.is_zero())
|
||||
.then(|| (c.name.clone(), net))
|
||||
.then(|| (c.id, c.name.clone(), net))
|
||||
})
|
||||
.collect();
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.1));
|
||||
rows.sort_by_key(|r| std::cmp::Reverse(r.2));
|
||||
if rows.is_empty() {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::TAG,
|
||||
"No classified income or spending this year",
|
||||
icon::BRIEFCASE,
|
||||
"No project income or spending this year",
|
||||
"",
|
||||
);
|
||||
return;
|
||||
}
|
||||
charts::hbars(ui, &rows, p.series[0]);
|
||||
let ids: Vec<ledger_core::ClassId> = rows.iter().map(|r| r.0).collect();
|
||||
let bars: Vec<(String, Decimal)> = rows.into_iter().map(|r| (r.1, r.2)).collect();
|
||||
if let Some(i) = charts::hbars_signed(ui, &bars, p.series[0], p.negative) {
|
||||
*go = Some(Route::Project(ids[i]));
|
||||
}
|
||||
ui.label(
|
||||
RichText::new("Only lines tagged with a class count here.")
|
||||
RichText::new("Only lines tagged with a project count here.")
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
@@ -418,6 +434,9 @@ mod tests {
|
||||
assert_eq!(pct(cents(2_500), cents(20_000)), Some(Decimal::new(125, 1)));
|
||||
assert_eq!(pct(cents(1), cents(3)), Some(Decimal::new(333, 1)));
|
||||
assert_eq!(pct(cents(1), Decimal::ZERO), None);
|
||||
// One place whatever the value.
|
||||
assert_eq!(pct(cents(500), cents(500)).unwrap().to_string(), "100.0");
|
||||
assert_eq!(pct(cents(-100), cents(200)).unwrap().to_string(), "-50.0");
|
||||
assert_eq!(pct(Decimal::MAX, Decimal::new(1, 2)), None);
|
||||
}
|
||||
|
||||
@@ -440,6 +459,6 @@ mod tests {
|
||||
let (_dir, books) = b.books();
|
||||
let (texts, _) = render(books, Route::Profit, |_| {});
|
||||
find(&texts, "Invoiced by customer, this year");
|
||||
find(&texts, "Acme is 100% of invoiced income.");
|
||||
find(&texts, "Acme is 100.0% of invoiced income.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,915 @@
|
||||
//! Projects: every class, with its facts (what it is, where it stands, who
|
||||
//! it is for), and one project completely: what it earned and cost, by
|
||||
//! month and by account, its entries, documents, decisions and history
|
||||
//! (rules.md B-37). Every figure is ledger-core's (`project_figures`,
|
||||
//! `project_to_date`, `project_entries`), the same the server's
|
||||
//! `books.projects` and `books.project` return; this module only arranges
|
||||
//! them, and never writes.
|
||||
|
||||
use super::assets::{facts, row};
|
||||
use super::page;
|
||||
use super::parties::chip;
|
||||
use crate::app::Shell;
|
||||
use crate::books::Books;
|
||||
use crate::charts::{self, BarGroup};
|
||||
use crate::fmt;
|
||||
use crate::nav::Route;
|
||||
use crate::theme::{self, pal};
|
||||
use crate::widgets;
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use egui::{RichText, Ui, vec2};
|
||||
use egui_phosphor::regular as icon;
|
||||
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use rust_decimal::Decimal;
|
||||
|
||||
/// The list's filter, kept across navigation.
|
||||
#[derive(Default)]
|
||||
pub struct State {
|
||||
/// Only projects with this status (None: every one).
|
||||
pub status: Option<ProjectStatus>,
|
||||
}
|
||||
|
||||
/// One project on the list: its id, name, facts and net to date.
|
||||
pub struct Listed {
|
||||
pub id: ClassId,
|
||||
pub name: String,
|
||||
pub facts: ProjectFacts,
|
||||
pub to_date: Option<ledger_core::ProjectToDate>,
|
||||
}
|
||||
|
||||
/// Every project (every class), by name ignoring capitals, with its
|
||||
/// figures to date through today.
|
||||
pub fn listed(b: &Books) -> Vec<Listed> {
|
||||
let mut out: Vec<Listed> = b
|
||||
.ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.map(|c| Listed {
|
||||
id: c.id,
|
||||
name: c.name.clone(),
|
||||
facts: b.ledger.project_facts(c.id).unwrap_or_default(),
|
||||
to_date: ledger_core::project_to_date(&b.ledger, c.id, b.today).ok(),
|
||||
})
|
||||
.collect();
|
||||
out.sort_by_key(|a| (a.name.to_lowercase(), a.id));
|
||||
out
|
||||
}
|
||||
|
||||
/// A status's colors: (text, fill).
|
||||
pub(crate) fn status_colors(
|
||||
s: ProjectStatus,
|
||||
p: &theme::Palette,
|
||||
) -> (egui::Color32, egui::Color32) {
|
||||
match s {
|
||||
ProjectStatus::Active => (p.positive, p.positive_soft),
|
||||
ProjectStatus::Paused => (p.warning, p.warning_soft),
|
||||
ProjectStatus::Finished => (p.muted, p.hover),
|
||||
ProjectStatus::Abandoned => (p.faint, p.hover),
|
||||
}
|
||||
}
|
||||
|
||||
/// A kind in words, or that it isn't recorded.
|
||||
pub fn kind_words(k: Option<ProjectKind>) -> &'static str {
|
||||
k.map_or("Kind not recorded", ProjectKind::words)
|
||||
}
|
||||
|
||||
/// The list's sentence: how many in each status, and the net across them.
|
||||
fn summary(rows: &[Listed]) -> String {
|
||||
if rows.is_empty() {
|
||||
return "No projects yet.".into();
|
||||
}
|
||||
let parts: Vec<String> = ProjectStatus::ALL
|
||||
.into_iter()
|
||||
.filter_map(|s| {
|
||||
let n = rows.iter().filter(|r| r.facts.status == s).count();
|
||||
(n > 0).then(|| format!("{n} {}", s.key()))
|
||||
})
|
||||
.collect();
|
||||
let net: Option<Decimal> = rows
|
||||
.iter()
|
||||
.map(|r| r.to_date.as_ref().map(|t| t.net()))
|
||||
.try_fold(Decimal::ZERO, |t, n| t.checked_add(n?));
|
||||
let counts = if parts.len() == 1 && rows.len() > 1 {
|
||||
format!(
|
||||
"{}, all {}",
|
||||
fmt::count(rows.len(), "project", "projects"),
|
||||
rows[0].facts.status.key()
|
||||
)
|
||||
} else if rows.len() == 1 {
|
||||
format!("1 project, {}", rows[0].facts.status.key())
|
||||
} else {
|
||||
format!(
|
||||
"{}: {}",
|
||||
fmt::count(rows.len(), "project", "projects"),
|
||||
parts.join(", ")
|
||||
)
|
||||
};
|
||||
format!(
|
||||
"{counts}. Net to date across them: {}.",
|
||||
fmt::dollars_or(net)
|
||||
)
|
||||
}
|
||||
|
||||
/// The projects.
|
||||
pub fn show_list(ui: &mut Ui, b: &Books, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
page(ui, "projects", |ui| {
|
||||
let all = listed(b);
|
||||
widgets::page_header(ui, "Projects", &summary(&all));
|
||||
if all.is_empty() {
|
||||
widgets::card(ui, |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::BRIEFCASE,
|
||||
"No projects yet",
|
||||
"The agent adds them (books.add_project). Tag lines with a project to see what it earns and costs.",
|
||||
)
|
||||
});
|
||||
return;
|
||||
}
|
||||
let st = &mut shell.state.projects;
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
ui.spacing_mut().item_spacing = vec2(6.0, 6.0);
|
||||
if chip(ui, &format!("All {}", all.len()), st.status.is_none()) {
|
||||
st.status = None;
|
||||
}
|
||||
for s in ProjectStatus::ALL {
|
||||
let n = all.iter().filter(|r| r.facts.status == s).count();
|
||||
if n == 0 {
|
||||
continue;
|
||||
}
|
||||
let on = st.status == Some(s);
|
||||
if chip(ui, &format!("{} {n}", s.words()), on) {
|
||||
st.status = if on { None } else { Some(s) };
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(12.0);
|
||||
let rows: Vec<&Listed> = all
|
||||
.iter()
|
||||
.filter(|r| st.status.is_none_or(|s| r.facts.status == s))
|
||||
.collect();
|
||||
let mut go = None;
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Projects",
|
||||
|ui| {
|
||||
widgets::pill(ui, &rows.len().to_string(), p.muted, p.hover);
|
||||
},
|
||||
|ui| {
|
||||
let amt = widgets::AMOUNT_MIN + 24.0;
|
||||
let avail = ui.available_width();
|
||||
let status_w = 100.0;
|
||||
let kind_w = if avail >= 760.0 { 140.0 } else { 0.0 };
|
||||
let party_w = if avail >= 900.0 {
|
||||
(avail * 0.18).clamp(140.0, 220.0)
|
||||
} else {
|
||||
0.0
|
||||
};
|
||||
let w = avail.max(status_w + kind_w + party_w + amt + 180.0);
|
||||
let cells = |c: [(String, f32, bool, egui::Color32); 5]| -> Vec<_> {
|
||||
c.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| (*i != 2 || kind_w > 0.0) && (*i != 3 || party_w > 0.0))
|
||||
.map(|(_, c)| c)
|
||||
.collect()
|
||||
};
|
||||
widgets::min_width(ui, "project_list", w, |ui| {
|
||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&cells([
|
||||
head("Name", 0.0, false),
|
||||
head("Status", status_w, false),
|
||||
head("Kind", kind_w, false),
|
||||
head("Party", party_w, false),
|
||||
head("Net to date", amt, true),
|
||||
]),
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for r in &rows {
|
||||
let (sc, _) = status_colors(r.facts.status, &p);
|
||||
let party = r
|
||||
.facts
|
||||
.party
|
||||
.map(|x| b.party_label(x))
|
||||
.unwrap_or_else(|| "—".into());
|
||||
let (net_t, net_c) = match r.to_date.as_ref().map(|t| t.net()) {
|
||||
Some(n) if n.is_zero() => ("—".to_string(), p.faint),
|
||||
Some(n) if n < Decimal::ZERO => (fmt::accounting(n), p.negative),
|
||||
Some(n) => (fmt::accounting(n), p.text),
|
||||
None => (fmt::CANT_COMPUTE.to_string(), p.faint),
|
||||
};
|
||||
let sub = r
|
||||
.facts
|
||||
.description
|
||||
.clone()
|
||||
.unwrap_or_else(|| format!("Project {}", r.id));
|
||||
let sub = sub.lines().next().unwrap_or("").to_string();
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&cells([
|
||||
(r.name.clone(), 0.0, false, p.text),
|
||||
(r.facts.status.words().to_string(), status_w, false, sc),
|
||||
(kind_words(r.facts.kind).to_string(), kind_w, false, p.muted),
|
||||
(party, party_w, false, p.muted),
|
||||
(net_t, amt, true, net_c),
|
||||
]),
|
||||
Some((sub.as_str(), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
let resp = resp.on_hover_text("Open this project");
|
||||
resp.context_menu(|ui| {
|
||||
widgets::copy_item(ui, "Copy name", &r.name);
|
||||
widgets::copy_item(ui, "Copy project id", &r.id.to_string());
|
||||
});
|
||||
if resp.clicked() {
|
||||
go = Some(r.id);
|
||||
}
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(format!(
|
||||
"Net to date: every posted revenue and expense line tagged to the project through {}, closing entries left out; revenue less expenses.",
|
||||
fmt::date(b.today)
|
||||
))
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
if let Some(id) = go {
|
||||
shell.go(Route::Project(id));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// The journal events naming a project: its class and project records,
|
||||
/// links of files to it, and decisions naming it (indexes into
|
||||
/// [`Books::journal`], oldest first).
|
||||
pub fn project_events(b: &Books, id: ClassId) -> Vec<usize> {
|
||||
let id64 = u64::from(id);
|
||||
let u = |v: &serde_json::Value, k: &str| v.get(k).and_then(|x| x.as_u64());
|
||||
b.journal
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, line)| {
|
||||
let p = &line.payload;
|
||||
match line.line_type.as_str() {
|
||||
"class.added" | "project.set" => u(p, "id") == Some(id64),
|
||||
"attachment.linked" | "attachment.unlinked" => {
|
||||
p.get("subject")
|
||||
.and_then(|s| s.get("kind"))
|
||||
.and_then(|k| k.as_str())
|
||||
== Some("project")
|
||||
&& p.get("subject").and_then(|s| u(s, "id")) == Some(id64)
|
||||
}
|
||||
"decision.recorded" => p
|
||||
.get("projects")
|
||||
.and_then(|v| v.as_array())
|
||||
.is_some_and(|a| a.iter().any(|x| x.as_u64() == Some(id64))),
|
||||
_ => false,
|
||||
}
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// A number tile: (label, value, how it is computed, the line under it).
|
||||
type Tile = (
|
||||
String,
|
||||
Option<Decimal>,
|
||||
String,
|
||||
Option<(String, egui::Color32)>,
|
||||
);
|
||||
|
||||
/// One project, completely.
|
||||
pub fn show_project(ui: &mut Ui, b: &Books, id: ClassId, shell: &mut Shell<'_>) {
|
||||
let p = pal(ui.ctx());
|
||||
let (Some(class), Some(f)) = (b.ledger.get_class(id), b.ledger.project_facts(id)) else {
|
||||
page(ui, "project", |ui| {
|
||||
widgets::empty(
|
||||
ui,
|
||||
icon::BRIEFCASE,
|
||||
"No such project",
|
||||
"It isn't in these books.",
|
||||
);
|
||||
});
|
||||
return;
|
||||
};
|
||||
let mut go: Option<Route> = None;
|
||||
let today = b.today;
|
||||
let to_date = ledger_core::project_to_date(&b.ledger, id, today).ok();
|
||||
let entries = ledger_core::project_entries(&b.ledger, id);
|
||||
page(ui, "project", |ui| {
|
||||
ui.horizontal(|ui| {
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(format!("{} Projects", icon::ARROW_LEFT))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
)
|
||||
.clicked()
|
||||
{
|
||||
go = Some(Route::Projects);
|
||||
}
|
||||
widgets::copyable(
|
||||
ui,
|
||||
RichText::new(format!("· Project {id}"))
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
&id.to_string(),
|
||||
);
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
widgets::copyable_truncated(
|
||||
ui,
|
||||
RichText::new(&class.name).font(theme::bold(26.0)),
|
||||
&class.name,
|
||||
);
|
||||
ui.add_space(6.0);
|
||||
ui.horizontal_wrapped(|ui| {
|
||||
let (fg, bg) = status_colors(f.status, &p);
|
||||
widgets::pill(ui, f.status.words(), fg, bg);
|
||||
match f.kind {
|
||||
Some(ProjectKind::OnSpec) => {
|
||||
widgets::pill(ui, ProjectKind::OnSpec.words(), p.accent, p.accent_soft)
|
||||
.on_hover_text(
|
||||
"Work done hoping to be paid: its costs are expenses as they are spent, and a repayment is income only when it arrives.",
|
||||
);
|
||||
}
|
||||
k => {
|
||||
widgets::pill(ui, kind_words(k), p.muted, p.hover);
|
||||
}
|
||||
}
|
||||
});
|
||||
if let Some(d) = &f.description {
|
||||
ui.add_space(6.0);
|
||||
let w = ui.available_width().min(860.0);
|
||||
ui.allocate_ui(vec2(w, 0.0), |ui| {
|
||||
ui.set_max_width(w);
|
||||
ui.label(RichText::new(d).font(theme::regular(15.0)).color(p.muted));
|
||||
});
|
||||
}
|
||||
ui.add_space(14.0);
|
||||
|
||||
// What it earned and cost, to date.
|
||||
let span = format!(
|
||||
"Every posted line tagged to this project through {}, closing entries left out.",
|
||||
fmt::date(today)
|
||||
);
|
||||
let mut tiles: Vec<Tile> = vec![
|
||||
(
|
||||
"Income to date".into(),
|
||||
to_date.as_ref().map(|t| t.revenue),
|
||||
format!("{span} Revenue lines."),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Spending to date".into(),
|
||||
to_date.as_ref().map(|t| t.expenses),
|
||||
format!("{span} Expense lines."),
|
||||
None,
|
||||
),
|
||||
(
|
||||
"Net to date".into(),
|
||||
to_date.as_ref().map(|t| t.net()),
|
||||
format!("{span} Income less spending."),
|
||||
to_date.as_ref().map(|t| {
|
||||
let n = t.net();
|
||||
if n < Decimal::ZERO {
|
||||
("a loss so far".to_string(), p.negative)
|
||||
} else if n.is_zero() {
|
||||
("even".to_string(), p.muted)
|
||||
} else {
|
||||
("a profit so far".to_string(), p.positive)
|
||||
}
|
||||
}),
|
||||
),
|
||||
];
|
||||
if let Some(u) = to_date.as_ref().and_then(|t| t.unrecovered_cost) {
|
||||
tiles.push((
|
||||
"Unrecovered cost".into(),
|
||||
Some(u),
|
||||
format!(
|
||||
"{span} Spending less income, never below zero: what it would take to be repaid in full. A hoped-for payment isn't income or a receivable until it arrives (ASC 450-30-25-1), so this is a figure, not something owed to the company."
|
||||
),
|
||||
Some(("if repaid: not a receivable".into(), p.muted)),
|
||||
));
|
||||
}
|
||||
if f.kind == Some(ProjectKind::Client)
|
||||
&& let Some(t) = &to_date
|
||||
&& !t.revenue.is_zero()
|
||||
{
|
||||
tiles.push((
|
||||
"Margin to date".into(),
|
||||
None,
|
||||
format!("{span} Net as a share of income."),
|
||||
None,
|
||||
));
|
||||
}
|
||||
let margin = to_date
|
||||
.as_ref()
|
||||
.and_then(|t| super::profit::pct(t.net(), t.revenue));
|
||||
let n = tiles.len();
|
||||
super::grid(ui, n, 170.0, 6, |i, ui| {
|
||||
let (label, value, how, sub) = &tiles[i];
|
||||
let text = if label == "Margin to date" {
|
||||
margin.map_or_else(|| "—".into(), |m| format!("{m}%"))
|
||||
} else {
|
||||
fmt::dollars_or(*value)
|
||||
};
|
||||
let r = widgets::kpi(
|
||||
ui,
|
||||
widgets::Kpi {
|
||||
label,
|
||||
value: text,
|
||||
sub: sub.clone(),
|
||||
spark: None,
|
||||
how,
|
||||
},
|
||||
);
|
||||
if let Some(v) = value {
|
||||
r.context_menu(|ui| widgets::copy_item(ui, "Copy amount", &v.to_string()));
|
||||
}
|
||||
});
|
||||
ui.add_space(2.0);
|
||||
|
||||
// By month and by account.
|
||||
let first = entries
|
||||
.iter()
|
||||
.filter_map(|(e, _)| b.ledger.get_entry(*e))
|
||||
.filter(|e| e.status() == ledger_core::EntryStatus::Posted)
|
||||
.map(|e| e.date())
|
||||
.min();
|
||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
months_card(ui, b, id, first);
|
||||
} else {
|
||||
accounts_card(ui, b, id, &mut go);
|
||||
}
|
||||
});
|
||||
super::columns(ui, 860.0, &[1.0, 1.0], |col, ui| {
|
||||
if col == 0 {
|
||||
facts_card(ui, b, id, &class.name, &f, &mut go);
|
||||
} else {
|
||||
decisions_card(ui, b, id, &mut go);
|
||||
}
|
||||
});
|
||||
entries_card(ui, b, &entries, &mut go);
|
||||
ui.add_space(14.0);
|
||||
let here = [ledger_store::Subject::Project(u64::from(id))];
|
||||
super::documents::documents_card_with(
|
||||
ui,
|
||||
b,
|
||||
"Documents",
|
||||
&here,
|
||||
&here,
|
||||
"No documents linked to it (its contract, spec or pitch).",
|
||||
&mut go,
|
||||
);
|
||||
ui.add_space(14.0);
|
||||
let events = project_events(b, id);
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"History",
|
||||
|_| {},
|
||||
|ui| {
|
||||
if events.is_empty() {
|
||||
ui.label(RichText::new("No journal events name it.").color(p.muted));
|
||||
}
|
||||
for &i in &events {
|
||||
let line = &b.journal[i];
|
||||
let ev = super::journal::describe_any(b, line);
|
||||
if widgets::history_row(ui, ev.icon, &ev.text, line.seq, &line.ts).clicked() {
|
||||
go = Some(Route::JournalEvent(line.seq));
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
if let Some(r) = go {
|
||||
shell.go(r);
|
||||
}
|
||||
}
|
||||
|
||||
/// Income, spending and net by month: from the month of its first posted
|
||||
/// tagged entry through this month, at most the last 24.
|
||||
fn months_card(ui: &mut Ui, b: &Books, id: ClassId, first: Option<NaiveDate>) {
|
||||
let p = pal(ui.ctx());
|
||||
let span = first.map(|f| {
|
||||
let start = ledger_core::analysis::interval_start(
|
||||
f.min(b.today),
|
||||
ledger_core::analysis::Interval::Month,
|
||||
);
|
||||
crate::books::Books::last_months((start, b.today), 24)
|
||||
});
|
||||
let months = span
|
||||
.and_then(|(s, e)| ledger_core::project_figures(&b.ledger, id, s, e).ok())
|
||||
.map(|f| f.months)
|
||||
.unwrap_or_default();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"By month",
|
||||
|ui| {
|
||||
if let Some((s, _)) = span {
|
||||
ui.label(
|
||||
RichText::new(format!("since {}", fmt::month(s)))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if months.is_empty() {
|
||||
widgets::empty(ui, icon::CHART_BAR, "No tagged lines yet", "");
|
||||
return;
|
||||
}
|
||||
let multi_year =
|
||||
months.first().map(|m| m.start.year()) != months.last().map(|m| m.start.year());
|
||||
let groups: Vec<BarGroup> = months
|
||||
.iter()
|
||||
.map(|m| BarGroup {
|
||||
label: m.start.format("%b").to_string(),
|
||||
values: vec![m.revenue, m.expenses, m.net()],
|
||||
year: multi_year.then(|| m.start.year()),
|
||||
})
|
||||
.collect();
|
||||
// Hover shows a month's figures; a click opens nothing, since
|
||||
// the reports are the whole company's, not the project's.
|
||||
charts::bar_chart_view(
|
||||
ui,
|
||||
&groups,
|
||||
&[
|
||||
("Income", p.series[1]),
|
||||
("Spending", p.series[2]),
|
||||
("Net", p.series[0]),
|
||||
],
|
||||
200.0,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Its revenue then expense accounts to date, with a total.
|
||||
fn accounts_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
// The to-date statement alone: project_figures would build every
|
||||
// month's too, each frame.
|
||||
let pl = ledger_core::reports::profit_and_loss_for_period(
|
||||
&b.ledger,
|
||||
ledger_core::calendar::FIRST_DAY.min(b.today),
|
||||
b.today,
|
||||
Some(id),
|
||||
)
|
||||
.ok();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"By account, to date",
|
||||
|_| {},
|
||||
|ui| {
|
||||
let Some(pl) = pl.filter(|pl| !pl.revenue.is_empty() || !pl.expenses.is_empty()) else {
|
||||
widgets::empty(ui, icon::TREE_STRUCTURE, "No income or spending tagged", "");
|
||||
return;
|
||||
};
|
||||
let amt = widgets::amount_col_w(ui.available_width(), 1, 160.0, 140.0);
|
||||
let w = ui.available_width();
|
||||
let mut line = |ui: &mut Ui, l: &ledger_core::StatementLine, expense: bool| {
|
||||
let shown = if expense { -l.amount } else { l.amount };
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(format!("{} {}", l.code, l.name), 0.0, false, p.text),
|
||||
(
|
||||
fmt::accounting(shown),
|
||||
amt,
|
||||
true,
|
||||
if shown < Decimal::ZERO {
|
||||
p.negative
|
||||
} else {
|
||||
p.text
|
||||
},
|
||||
),
|
||||
],
|
||||
None,
|
||||
false,
|
||||
true,
|
||||
);
|
||||
if resp.on_hover_text("Open this account's register").clicked()
|
||||
&& let Some(a) = b.ledger.chart().find_by_code(&l.code)
|
||||
{
|
||||
*go = Some(Route::Account(a.id));
|
||||
}
|
||||
};
|
||||
for l in &pl.revenue {
|
||||
line(ui, l, false);
|
||||
}
|
||||
for l in &pl.expenses {
|
||||
line(ui, l, true);
|
||||
}
|
||||
widgets::rule(ui);
|
||||
let net = pl.net_income();
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
("Net".to_string(), 0.0, false, p.text),
|
||||
(
|
||||
fmt::accounting(net),
|
||||
amt,
|
||||
true,
|
||||
if net < Decimal::ZERO {
|
||||
p.negative
|
||||
} else {
|
||||
p.text
|
||||
},
|
||||
),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Spending shows in parentheses; only revenue and expense lines count.",
|
||||
)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// What it is: kind, status, party, dates, links, ids, description, notes.
|
||||
fn facts_card(
|
||||
ui: &mut Ui,
|
||||
b: &Books,
|
||||
id: ClassId,
|
||||
name: &str,
|
||||
f: &ProjectFacts,
|
||||
go: &mut Option<Route>,
|
||||
) {
|
||||
let p = pal(ui.ctx());
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Facts",
|
||||
|_| {},
|
||||
|ui| {
|
||||
facts(ui, |ui, k| {
|
||||
let none = |ui: &mut Ui| {
|
||||
ui.label(RichText::new("not recorded").color(p.faint));
|
||||
};
|
||||
k(ui, "Name");
|
||||
widgets::copyable_truncated(ui, RichText::new(name), name);
|
||||
ui.end_row();
|
||||
k(ui, "Kind");
|
||||
match f.kind {
|
||||
Some(kind) => {
|
||||
ui.label(kind.words());
|
||||
}
|
||||
None => none(ui),
|
||||
}
|
||||
ui.end_row();
|
||||
k(ui, "Status");
|
||||
ui.label(f.status.words());
|
||||
ui.end_row();
|
||||
k(ui, "Party");
|
||||
match f.party {
|
||||
Some(pid) => {
|
||||
if widgets::link(ui, RichText::new(b.party_label(pid)).color(p.accent))
|
||||
.on_hover_text("Open this party")
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::Party(pid));
|
||||
}
|
||||
}
|
||||
None => none(ui),
|
||||
}
|
||||
ui.end_row();
|
||||
k(ui, "Started");
|
||||
match f.started_on {
|
||||
Some(d) => {
|
||||
ui.label(fmt::date(d));
|
||||
}
|
||||
None => none(ui),
|
||||
}
|
||||
ui.end_row();
|
||||
if f.ended_on.is_some() || f.status.is_over() {
|
||||
k(ui, "Ended");
|
||||
match f.ended_on {
|
||||
Some(d) => {
|
||||
ui.label(fmt::date(d));
|
||||
}
|
||||
None => none(ui),
|
||||
}
|
||||
ui.end_row();
|
||||
}
|
||||
k(ui, "Links");
|
||||
if f.links.is_empty() {
|
||||
none(ui);
|
||||
} else {
|
||||
ui.vertical(|ui| {
|
||||
ui.spacing_mut().item_spacing.y = 0.0;
|
||||
for l in &f.links {
|
||||
widgets::copyable_truncated(ui, RichText::new(l), l);
|
||||
}
|
||||
});
|
||||
}
|
||||
ui.end_row();
|
||||
k(ui, "Project id");
|
||||
widgets::copyable(ui, RichText::new(id.to_string()), &id.to_string());
|
||||
ui.end_row();
|
||||
});
|
||||
if let Some(n) = &f.notes {
|
||||
ui.add_space(8.0);
|
||||
ui.label(
|
||||
RichText::new("Notes")
|
||||
.font(theme::regular(12.5))
|
||||
.color(p.muted),
|
||||
);
|
||||
ui.add(egui::Label::new(RichText::new(n).font(theme::regular(13.0))).wrap());
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// The decisions naming it, newest first; replaced ones muted.
|
||||
fn decisions_card(ui: &mut Ui, b: &Books, id: ClassId, go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
let decisions: Vec<_> = b
|
||||
.ledger
|
||||
.conventions()
|
||||
.decisions()
|
||||
.filter(|d| d.new.projects.contains(&id))
|
||||
.collect();
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Decisions",
|
||||
|ui| {
|
||||
if !decisions.is_empty() {
|
||||
widgets::pill(ui, &decisions.len().to_string(), p.muted, p.hover);
|
||||
}
|
||||
},
|
||||
|ui| {
|
||||
if decisions.is_empty() {
|
||||
ui.label(RichText::new("No decision names this project.").color(p.muted));
|
||||
return;
|
||||
}
|
||||
for (i, d) in decisions.iter().rev().enumerate() {
|
||||
if i > 0 {
|
||||
ui.add_space(4.0);
|
||||
widgets::rule(ui);
|
||||
ui.add_space(4.0);
|
||||
}
|
||||
let replaced = d.superseded_by.is_some();
|
||||
let text = if replaced { p.muted } else { p.text };
|
||||
if widgets::link(
|
||||
ui,
|
||||
RichText::new(&d.new.subject)
|
||||
.font(theme::semibold(13.0))
|
||||
.color(text),
|
||||
)
|
||||
.on_hover_text("Open it in the journal")
|
||||
.clicked()
|
||||
{
|
||||
*go = Some(Route::JournalEvent(d.seq));
|
||||
}
|
||||
ui.add(
|
||||
egui::Label::new(
|
||||
RichText::new(&d.new.decision_md)
|
||||
.font(theme::regular(12.5))
|
||||
.color(if replaced { p.muted } else { p.text }),
|
||||
)
|
||||
.wrap(),
|
||||
);
|
||||
let mut by = format!(
|
||||
"{} · {}",
|
||||
d.new.decided_by.join(" and "),
|
||||
fmt::date(d.new.decided_on)
|
||||
);
|
||||
if let Some(n) = d.superseded_by {
|
||||
by.push_str(&format!(" · replaced by #{n}"));
|
||||
}
|
||||
ui.label(RichText::new(by).font(theme::regular(11.0)).color(p.faint));
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// Every entry with a line tagged to it, newest first, with its project
|
||||
/// amount.
|
||||
fn entries_card(ui: &mut Ui, b: &Books, entries: &[(u64, Decimal)], go: &mut Option<Route>) {
|
||||
let p = pal(ui.ctx());
|
||||
widgets::titled_card(
|
||||
ui,
|
||||
"Entries",
|
||||
|ui| {
|
||||
ui.label(
|
||||
RichText::new(fmt::count(entries.len(), "entry", "entries"))
|
||||
.font(theme::regular(12.0))
|
||||
.color(p.muted),
|
||||
);
|
||||
},
|
||||
|ui| {
|
||||
if entries.is_empty() {
|
||||
ui.label(
|
||||
RichText::new("No line in the books is tagged with it yet.").color(p.muted),
|
||||
);
|
||||
return;
|
||||
}
|
||||
let amt = widgets::AMOUNT_MIN + 24.0;
|
||||
let w = ui.available_width().max(104.0 + 64.0 + 150.0 + amt + 200.0);
|
||||
widgets::min_width(ui, "project_entries", w, |ui| {
|
||||
let head = |t: &str, cw: f32, right: bool| (t.to_string(), cw, right, p.muted);
|
||||
row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
head("Date", 104.0, false),
|
||||
head("Entry", 64.0, false),
|
||||
head("Description", 0.0, false),
|
||||
head("Kind", 150.0, false),
|
||||
head("Project amount", amt, true),
|
||||
],
|
||||
None,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
widgets::rule(ui);
|
||||
for (eid, amount) in entries {
|
||||
let Some(e) = b.entry(*eid) else {
|
||||
continue;
|
||||
};
|
||||
let draft = e.status != ledger_core::EntryStatus::Posted;
|
||||
let text = if draft { p.muted } else { p.text };
|
||||
let kind = if draft {
|
||||
format!("{} · draft", e.origin)
|
||||
} else {
|
||||
e.origin.to_string()
|
||||
};
|
||||
let mut sub: Vec<String> = Vec::new();
|
||||
if e.amount != Some(amount.abs()) {
|
||||
sub.push(format!("of {}", fmt::dollars_or(e.amount)));
|
||||
}
|
||||
if !e.receipts.is_empty() {
|
||||
sub.push(fmt::count(e.receipts.len(), "receipt", "receipts"));
|
||||
}
|
||||
let accounts: Vec<String> =
|
||||
e.accounts.iter().map(|a| b.account_name(*a)).collect();
|
||||
sub.push(accounts.join(" → "));
|
||||
let sub = sub.join(" · ");
|
||||
let resp = row(
|
||||
ui,
|
||||
w,
|
||||
&[
|
||||
(fmt::date(e.date), 104.0, false, text),
|
||||
(format!("#{}", e.id), 64.0, false, p.muted),
|
||||
(e.description.clone(), 0.0, false, text),
|
||||
(kind, 150.0, false, if draft { p.warning } else { p.muted }),
|
||||
(
|
||||
fmt::accounting(*amount),
|
||||
amt,
|
||||
true,
|
||||
if *amount < Decimal::ZERO {
|
||||
p.negative
|
||||
} else {
|
||||
text
|
||||
},
|
||||
),
|
||||
],
|
||||
Some((sub.as_str(), p.faint)),
|
||||
false,
|
||||
true,
|
||||
);
|
||||
resp.context_menu(|ui| {
|
||||
widgets::copy_item(ui, "Copy entry number", &format!("#{}", e.id));
|
||||
widgets::copy_item(ui, "Copy project amount", &amount.to_string());
|
||||
});
|
||||
if resp.on_hover_text("Open this entry").clicked() {
|
||||
*go = Some(Route::Entry(e.id));
|
||||
}
|
||||
}
|
||||
});
|
||||
ui.add_space(4.0);
|
||||
ui.label(
|
||||
RichText::new(
|
||||
"Project amount: the entry's tagged income less its tagged spending; a tagged balance-sheet line counts nothing.",
|
||||
)
|
||||
.font(theme::regular(11.5))
|
||||
.color(p.faint),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -244,6 +244,10 @@ pub fn fuzzy(hay: &str, query: &str) -> Option<i32> {
|
||||
/// "reconciliation reconciled", its account's code and name, and its
|
||||
/// statement date (as shown and as YYYY-MM-DD); it opens
|
||||
/// Route::Reconciliation(its id), voided ones too, saying so.
|
||||
/// INVARIANTS (projects):
|
||||
/// 9. A project is listed when the query fuzzily matches its name and
|
||||
/// description; a query found whole in its name (ignoring case) adds
|
||||
/// 500. It opens Route::Project(its id).
|
||||
/// ```
|
||||
pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||
let q = query.trim();
|
||||
@@ -381,6 +385,22 @@ pub fn search(b: &Books, query: &str) -> Vec<Item> {
|
||||
});
|
||||
}
|
||||
}
|
||||
for c in b.ledger.list_classes() {
|
||||
let f = b.ledger.project_facts(c.id).unwrap_or_default();
|
||||
let desc = f.description.as_deref().unwrap_or("");
|
||||
let hay = format!("{} {desc}", c.name);
|
||||
if let Some(score) = fuzzy(&hay, q) {
|
||||
let whole = c.name.to_lowercase().contains(&q.to_lowercase());
|
||||
let kind = f.kind.map_or("Kind not recorded", |k| k.words());
|
||||
out.push(Item {
|
||||
icon: icon::BRIEFCASE,
|
||||
title: c.name.clone(),
|
||||
detail: format!("{kind} · {}", f.status.words()),
|
||||
route: Route::Project(c.id),
|
||||
score: score + if whole { 500 } else { 0 },
|
||||
});
|
||||
}
|
||||
}
|
||||
for st in &b.statements {
|
||||
let file = b
|
||||
.attachment(st.attachment_id)
|
||||
@@ -514,6 +534,7 @@ pub fn describe(b: &Books, r: &Route) -> String {
|
||||
.unwrap_or_default(),
|
||||
Route::Asset(id) => b.asset_label(*id),
|
||||
Route::Party(id) => b.party_label(*id),
|
||||
Route::Project(id) => b.project_label(*id),
|
||||
Route::Reconciliation(id) => b.subject_label(ledger_store::Subject::Reconciliation(*id)),
|
||||
Route::JournalEvent(seq) => format!("Event {seq}"),
|
||||
_ => String::new(),
|
||||
|
||||
@@ -990,6 +990,7 @@ mod painted {
|
||||
Route::Reports(ReportKind::BalanceSheet),
|
||||
Route::Accounts,
|
||||
Route::Parties,
|
||||
Route::Projects,
|
||||
Route::Journal,
|
||||
];
|
||||
// A page a thread: each checks every scheme.
|
||||
|
||||
@@ -73,6 +73,37 @@ pub fn pill(ui: &mut Ui, text: &str, fg: Color32, bg: Color32) -> Response {
|
||||
.response
|
||||
}
|
||||
|
||||
/// One journal event on a History card: its icon, its text as a link
|
||||
/// (cut with an ellipsis), then its number and time, which keep their
|
||||
/// room, so a long name in the text never widens the card. Returns the
|
||||
/// link's response.
|
||||
pub fn history_row(ui: &mut Ui, icon: &str, text: &str, seq: u64, ts: &str) -> Response {
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.label(RichText::new(icon).color(p.muted));
|
||||
let meta = format!("event {seq} · {}", crate::fmt::timestamp(ts));
|
||||
let meta_w = ui
|
||||
.painter()
|
||||
.layout_no_wrap(meta.clone(), theme::regular(11.0), p.faint)
|
||||
.size()
|
||||
.x;
|
||||
let w = (ui.available_width() - meta_w - ui.spacing().item_spacing.x).max(40.0);
|
||||
let link = ui
|
||||
.scope(|ui| {
|
||||
ui.set_max_width(w);
|
||||
link_truncated(ui, RichText::new(text).font(theme::regular(12.5)))
|
||||
})
|
||||
.inner;
|
||||
ui.label(
|
||||
RichText::new(meta)
|
||||
.font(theme::regular(11.0))
|
||||
.color(p.faint),
|
||||
);
|
||||
link
|
||||
})
|
||||
.inner
|
||||
}
|
||||
|
||||
/// Text that looks like plain text until you hover it, then like a link.
|
||||
pub fn link(ui: &mut Ui, text: RichText) -> Response {
|
||||
link_label(ui, egui::Label::new(text))
|
||||
@@ -276,11 +307,31 @@ pub fn recently_copied(ui: &Ui, id: egui::Id) -> bool {
|
||||
/// A value that copies itself when clicked: a pointer and a copy icon on
|
||||
/// hover, a check for a moment after.
|
||||
pub fn copyable(ui: &mut Ui, text: RichText, value: &str) -> Response {
|
||||
copyable_label(ui, text, value, false)
|
||||
}
|
||||
|
||||
/// [`copyable`] on one line, cut with an ellipsis where the space ends
|
||||
/// (the copy icon's slot kept), the full text on hover: for text of any
|
||||
/// length with no spaces to wrap at, a long name or a web address.
|
||||
pub fn copyable_truncated(ui: &mut Ui, text: RichText, value: &str) -> Response {
|
||||
copyable_label(ui, text, value, true)
|
||||
}
|
||||
|
||||
fn copyable_label(ui: &mut Ui, text: RichText, value: &str, truncate: bool) -> Response {
|
||||
let id = ui.id().with(("copy", value));
|
||||
let p = pal(ui.ctx());
|
||||
ui.horizontal(|ui| {
|
||||
ui.spacing_mut().item_spacing.x = 4.0;
|
||||
let r = ui.add(egui::Label::new(text).sense(Sense::click()));
|
||||
let r = if truncate {
|
||||
let w = (ui.available_width() - 18.0).max(24.0);
|
||||
ui.scope(|ui| {
|
||||
ui.set_max_width(w);
|
||||
ui.add(egui::Label::new(text).truncate().sense(Sense::click()))
|
||||
})
|
||||
.inner
|
||||
} else {
|
||||
ui.add(egui::Label::new(text).sense(Sense::click()))
|
||||
};
|
||||
let recent = recently_copied(ui, id);
|
||||
let (slot, _) = ui.allocate_exact_size(vec2(14.0, 14.0), Sense::hover());
|
||||
if r.hovered() || recent {
|
||||
|
||||
@@ -49,6 +49,7 @@ fn crumbs_link_every_level_but_the_last() {
|
||||
let entry = b.entries.first().unwrap().id;
|
||||
let mut routes = vec![
|
||||
Route::Party(party),
|
||||
Route::Project(b.ledger.list_classes()[0].id),
|
||||
Route::Reconciliation(rec.id),
|
||||
Route::Entry(entry),
|
||||
Route::Reports(ReportKind::ProfitAndLoss),
|
||||
@@ -112,7 +113,11 @@ fn clicking_a_crumb_goes_there() {
|
||||
h.get_all_by_label(&account).next().unwrap().click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().route(), &Route::Account(rec.account_id));
|
||||
h.get_by_label("Accounts").click();
|
||||
// The crumb, not the sidebar's link of the same name (left of it).
|
||||
h.get_all_by_label("Accounts")
|
||||
.max_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
||||
.unwrap()
|
||||
.click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state().route(), &Route::Accounts);
|
||||
}
|
||||
@@ -13,6 +13,7 @@
|
||||
|
||||
use chrono::{Datelike, NaiveDate};
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::parties::{EntryFlow, entry_flow, money_accounts};
|
||||
use ledger_core::{AccountType, Currency, Money, PartyShare, Role, Share, Side};
|
||||
use ledger_gui::books::{Books, PartyPeriod, share_of_what_for};
|
||||
@@ -276,3 +277,122 @@ fn the_page_state_a_test_sets_is_the_apps() {
|
||||
PartyPeriod::Year(2025)
|
||||
);
|
||||
}
|
||||
|
||||
/// The sidebar link named `name` (the leftmost node of that name).
|
||||
fn sidebar_link(h: &Harness<'_, App>, name: &str) -> egui::Rect {
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, name)
|
||||
.map(|n| n.rect())
|
||||
.min_by(|a, b| a.left().total_cmp(&b.left()))
|
||||
.unwrap_or_else(|| panic!("no {name} link"))
|
||||
}
|
||||
|
||||
/// A short window (owner, 2026-10-21: the links went under the status
|
||||
/// box): scrolled to its end, the last link stops above the box.
|
||||
#[test]
|
||||
fn sidebar_links_never_draw_under_the_status_box() {
|
||||
let f = fixture("sidebar-short");
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 500.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
|
||||
h.run_steps(4);
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
|
||||
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
||||
.unwrap()
|
||||
.scroll_to_me();
|
||||
h.run_steps(4);
|
||||
let journal = sidebar_link(&h, "Journal");
|
||||
let status = h.get_by_label_contains("files verified").rect();
|
||||
assert!(
|
||||
journal.bottom() <= status.top(),
|
||||
"Journal ends at {} under the status box at {}",
|
||||
journal.bottom(),
|
||||
status.top()
|
||||
);
|
||||
}
|
||||
|
||||
/// The rail's equivalent: its last link stops above its footer.
|
||||
#[test]
|
||||
fn rail_links_never_draw_under_its_footer() {
|
||||
let f = fixture("rail-short");
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(900.0, 420.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
|
||||
h.run_steps(4);
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
|
||||
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
||||
.unwrap()
|
||||
.scroll_to_me();
|
||||
h.run_steps(4);
|
||||
let journal = sidebar_link(&h, "Journal");
|
||||
assert!(journal.right() <= 60.0, "the rail is drawn: {journal:?}");
|
||||
let footer = h
|
||||
.ctx
|
||||
.data(|d| d.get_temp::<f32>(egui::Id::new("rail_footer_h")))
|
||||
.expect("the rail's footer was measured");
|
||||
// The rail's frame keeps 16 points under its footer.
|
||||
let footer_top = 420.0 - 16.0 - footer;
|
||||
assert!(
|
||||
journal.bottom() <= footer_top,
|
||||
"Journal ends at {} under the footer at {footer_top}",
|
||||
journal.bottom()
|
||||
);
|
||||
}
|
||||
|
||||
/// A window too short for the links and the status box together keeps
|
||||
/// one link whole above the box (the box gives way, off the bottom),
|
||||
/// sidebar and rail alike.
|
||||
#[test]
|
||||
fn a_very_short_window_keeps_one_link() {
|
||||
for (w, h_px, tag) in [(1440.0, 220.0, "sidebar-tiny"), (900.0, 200.0, "rail-tiny")] {
|
||||
let f = fixture(tag);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(w, h_px))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
|
||||
h.run_steps(4);
|
||||
let first = sidebar_link(&h, "Overview");
|
||||
assert!(
|
||||
first.height() >= 31.0 && first.bottom() <= h_px,
|
||||
"{tag}: Overview at {first:?}"
|
||||
);
|
||||
if let Some(status) = h.query_by_label_contains("files verified") {
|
||||
assert!(
|
||||
first.bottom() <= status.rect().top(),
|
||||
"{tag}: Overview ends at {} under the status box at {}",
|
||||
first.bottom(),
|
||||
status.rect().top()
|
||||
);
|
||||
}
|
||||
let key = if w < 1000.0 {
|
||||
"rail_footer_h"
|
||||
} else {
|
||||
"sidebar_footer_h"
|
||||
};
|
||||
let footer = h
|
||||
.ctx
|
||||
.data(|d| d.get_temp::<f32>(egui::Id::new(key)))
|
||||
.expect("the footer was measured");
|
||||
assert!(footer > 0.0, "{tag}");
|
||||
}
|
||||
}
|
||||
|
||||
/// At the usual height every section's link shows and nothing scrolls.
|
||||
#[test]
|
||||
fn a_normal_window_shows_every_link_unscrolled() {
|
||||
let f = fixture("sidebar-normal");
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 920.0))
|
||||
.build_eframe(|cc| App::with_books(&cc.egui_ctx, f.books, false, Route::Overview));
|
||||
h.run_steps(4);
|
||||
let first = sidebar_link(&h, "Overview");
|
||||
for s in ledger_gui::nav::Section::ALL {
|
||||
sidebar_link(&h, s.title());
|
||||
}
|
||||
let status = h.get_by_label_contains("files verified").rect();
|
||||
assert!(sidebar_link(&h, "Journal").bottom() <= status.top());
|
||||
h.get_all_by_role_and_label(egui::accesskit::Role::Button, "Journal")
|
||||
.min_by(|a, b| a.rect().left().total_cmp(&b.rect().left()))
|
||||
.unwrap()
|
||||
.scroll_to_me();
|
||||
h.run_steps(4);
|
||||
assert_eq!(sidebar_link(&h, "Overview"), first, "the links scrolled");
|
||||
}
|
||||
@@ -116,6 +116,7 @@ fn the_company_page_shows_how_the_books_are_kept() {
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
@@ -126,6 +127,7 @@ fn the_company_page_shows_how_the_books_are_kept() {
|
||||
evidence_attachment: None,
|
||||
supersedes: Some(first),
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.raise_open_item(&item("Mail the IRS letter", Some(d(2026, 10, 16))))
|
||||
|
||||
@@ -198,11 +198,13 @@ fn routes(b: &Books) -> Vec<Route> {
|
||||
Route::Accounts,
|
||||
Route::Assets,
|
||||
Route::Parties,
|
||||
Route::Projects,
|
||||
Route::Documents,
|
||||
Route::Company,
|
||||
Route::Journal,
|
||||
];
|
||||
out.extend(ReportKind::ALL.map(Route::Reports));
|
||||
out.extend(b.ledger.list_classes().iter().map(|c| Route::Project(c.id)));
|
||||
out.extend((1..=b.ledger.next_entry_id()).map(Route::Entry));
|
||||
out.extend(b.ledger.chart().all().map(|a| Route::Account(a.id)));
|
||||
out.extend(b.ledger.registered_assets().map(|a| Route::Asset(a.id)));
|
||||
@@ -535,3 +537,97 @@ fn an_older_books_dirs_config_json_sets_its_receipt_rule() {
|
||||
.unwrap();
|
||||
assert_ne!(load(&t).receipt_threshold, rust_decimal::Decimal::ZERO);
|
||||
}
|
||||
|
||||
/// rules.md B-37: the classes an earlier version wrote read as projects
|
||||
/// with the default facts (active, kind not recorded), listed by name,
|
||||
/// their figures ledger-core's.
|
||||
#[test]
|
||||
fn old_books_classes_read_as_projects() {
|
||||
let mut seen = 0;
|
||||
// Copies of their own: the shards above copy the same fixtures under
|
||||
// the fixtures' names, side by side.
|
||||
let root =
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("../ledger-store/tests/fixtures/old-books");
|
||||
let mut fixtures: Vec<PathBuf> = std::fs::read_dir(&root)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
fixtures.sort();
|
||||
let copies = fixtures.iter().map(|f| {
|
||||
let name = f.file_name().unwrap().to_string_lossy().to_string();
|
||||
let t = temp(&format!("projects-{name}"));
|
||||
copy_books(f, &t.0.join("books-data"));
|
||||
write_settings(&t.0);
|
||||
(name, t)
|
||||
});
|
||||
for (name, dir) in copies {
|
||||
let b = data::load_once(&ledger_gui::Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
today: Some(d(2026, 10, 12)),
|
||||
})
|
||||
.unwrap();
|
||||
let listed = ledger_gui::pages::projects::listed(&b);
|
||||
assert_eq!(listed.len(), b.ledger.list_classes().len(), "{name}");
|
||||
for p in &listed {
|
||||
seen += 1;
|
||||
assert_eq!(p.facts, ledger_core::ProjectFacts::default(), "{name}");
|
||||
assert_eq!(
|
||||
p.to_date,
|
||||
ledger_core::project_to_date(&b.ledger, p.id, b.today).ok(),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
let names: Vec<String> = listed.iter().map(|p| p.name.to_lowercase()).collect();
|
||||
let mut sorted = names.clone();
|
||||
sorted.sort();
|
||||
assert_eq!(names, sorted, "{name}: alphabetical");
|
||||
}
|
||||
// No fixture holds a class: books with the lines an earlier version
|
||||
// wrote for one (class.added, a line tagged with it), and nothing more.
|
||||
let t = temp("old-class");
|
||||
let data = t.0.join("books-data");
|
||||
let mut store = ledger_store::Store::create(&data, ledger_core::Currency::USD).unwrap();
|
||||
let cash = store
|
||||
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let rev = store
|
||||
.add_account(
|
||||
"4000",
|
||||
"Sales",
|
||||
ledger_core::AccountType::Revenue,
|
||||
None,
|
||||
false,
|
||||
)
|
||||
.unwrap();
|
||||
let class = store.add_class("Harbor app").unwrap();
|
||||
let e = store.create_entry(d(2026, 3, 1), "sale").unwrap();
|
||||
let usd =
|
||||
|c| ledger_core::Money::new(rust_decimal::Decimal::new(c, 2), ledger_core::Currency::USD);
|
||||
store
|
||||
.add_line(e, cash, ledger_core::Side::Debit, usd(5000))
|
||||
.unwrap();
|
||||
store
|
||||
.add_line(e, rev, ledger_core::Side::Credit, usd(5000))
|
||||
.unwrap();
|
||||
store.set_line_class(e, 1, Some(class)).unwrap();
|
||||
store.post_entry(e).unwrap();
|
||||
let journal = std::fs::read_to_string(data.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
assert!(!journal.contains("project.set"), "nothing new was written");
|
||||
drop(store);
|
||||
write_settings(&t.0);
|
||||
let b = data::load_once(&ledger_gui::Source {
|
||||
data_dir: data,
|
||||
today: Some(d(2026, 10, 12)),
|
||||
})
|
||||
.unwrap();
|
||||
let listed = ledger_gui::pages::projects::listed(&b);
|
||||
assert_eq!(listed.len(), 1);
|
||||
seen += 1;
|
||||
assert_eq!(listed[0].name, "Harbor app");
|
||||
assert_eq!(listed[0].facts, ledger_core::ProjectFacts::default());
|
||||
let t = listed[0].to_date.as_ref().unwrap();
|
||||
assert_eq!(t.revenue, rust_decimal::Decimal::new(5000, 2));
|
||||
assert_eq!(t.unrecovered_cost, None);
|
||||
assert!(seen > 0);
|
||||
}
|
||||
@@ -0,0 +1,590 @@
|
||||
//! Projects in the viewer (rules.md B-37): the list and a project's page
|
||||
//! show ledger-core's figures; rows, links and bars open what they name;
|
||||
//! the journal, books.show and the party page know projects.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use egui::accesskit::Role;
|
||||
use egui_kittest::Harness;
|
||||
use egui_kittest::kittest::Queryable;
|
||||
use ledger_core::conventions::NewDecision;
|
||||
use ledger_core::{AccountType, ClassId, Currency, Money, ProjectFacts, ProjectKind, Side};
|
||||
use ledger_gui::books::Books;
|
||||
use ledger_gui::nav::{Route, Section};
|
||||
use ledger_gui::pages::journal::describe;
|
||||
use ledger_gui::pages::projects::listed;
|
||||
use ledger_gui::{App, Source, data};
|
||||
use ledger_store::ops::ViewerTarget;
|
||||
use ledger_store::{Store, Subject};
|
||||
use rust_decimal::Decimal;
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TempDir(PathBuf);
|
||||
|
||||
impl Drop for TempDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
fn today() -> NaiveDate {
|
||||
d(2026, 10, 15)
|
||||
}
|
||||
|
||||
/// Books with three projects: "Zeta site" (client work for Riley Chen:
|
||||
/// $5,000.00 earned, $1,800.00 of contractors), "alpha app" (on spec:
|
||||
/// $300.00 of costs, and test devices bought for it, an asset), and
|
||||
/// "Beta" (a bare class); rent is no project's. A pitch is linked to
|
||||
/// alpha, and a decision names it.
|
||||
struct Fixture {
|
||||
_dir: TempDir,
|
||||
books: Books,
|
||||
zeta: ClassId,
|
||||
alpha: ClassId,
|
||||
beta: ClassId,
|
||||
riley: u64,
|
||||
alpha_cost: u64,
|
||||
pitch: u64,
|
||||
}
|
||||
|
||||
fn fixture(tag: &str) -> Fixture {
|
||||
let dir = TempDir(
|
||||
std::env::temp_dir().join(format!("ledger-gui-projects-{tag}-{}", std::process::id())),
|
||||
);
|
||||
let _ = std::fs::remove_dir_all(&dir.0);
|
||||
let data_dir = dir.0.join("books-data");
|
||||
let mut s = Store::create(&data_dir, Currency::USD).unwrap();
|
||||
let mut add = |code: &str, name: &str, ty| s.add_account(code, name, ty, None, false).unwrap();
|
||||
let cash = add("1000", "Cash", AccountType::Asset);
|
||||
let devices = add("1200", "Equipment", AccountType::Asset);
|
||||
let revenue = add("4000", "Design Services", AccountType::Revenue);
|
||||
let contractors = add("6000", "Contractors", AccountType::Expense);
|
||||
let rent = add("6200", "Rent", AccountType::Expense);
|
||||
let riley = s.add_party("Riley Chen", &[]).unwrap();
|
||||
let zeta = s
|
||||
.add_project(
|
||||
"Zeta site",
|
||||
&ProjectFacts {
|
||||
kind: Some(ProjectKind::Client),
|
||||
party: Some(riley),
|
||||
description: Some("A site for Riley".into()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let alpha = s
|
||||
.add_project(
|
||||
"alpha app",
|
||||
&ProjectFacts {
|
||||
kind: Some(ProjectKind::OnSpec),
|
||||
links: vec!["https://alpha.example".into()],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let beta = s.add_class("Beta").unwrap();
|
||||
let post = |s: &mut Store, date, what: &str, dr, cr, c, tag: Option<(ClassId, bool)>| {
|
||||
let e = s.create_entry(date, what).unwrap();
|
||||
s.add_line(e, dr, Side::Debit, usd(c)).unwrap();
|
||||
s.add_line(e, cr, Side::Credit, usd(c)).unwrap();
|
||||
if let Some((class, debit)) = tag {
|
||||
s.set_line_class(e, if debit { 0 } else { 1 }, Some(class))
|
||||
.unwrap();
|
||||
}
|
||||
s.post_entry(e).unwrap();
|
||||
e
|
||||
};
|
||||
post(
|
||||
&mut s,
|
||||
d(2026, 3, 2),
|
||||
"Zeta milestone",
|
||||
cash,
|
||||
revenue,
|
||||
500_000,
|
||||
Some((zeta, false)),
|
||||
);
|
||||
post(
|
||||
&mut s,
|
||||
d(2026, 3, 9),
|
||||
"Zeta contractor",
|
||||
contractors,
|
||||
cash,
|
||||
180_000,
|
||||
Some((zeta, true)),
|
||||
);
|
||||
let alpha_cost = post(
|
||||
&mut s,
|
||||
d(2026, 4, 1),
|
||||
"Alpha prototype",
|
||||
contractors,
|
||||
cash,
|
||||
30_000,
|
||||
Some((alpha, true)),
|
||||
);
|
||||
post(
|
||||
&mut s,
|
||||
d(2026, 4, 2),
|
||||
"Alpha test devices",
|
||||
devices,
|
||||
cash,
|
||||
90_000,
|
||||
Some((alpha, true)),
|
||||
);
|
||||
post(&mut s, d(2026, 4, 30), "Rent", rent, cash, 150_000, None);
|
||||
let (pitch, _, _) = s
|
||||
.file_document(
|
||||
"other",
|
||||
"alpha pitch.pdf",
|
||||
"application/pdf",
|
||||
b"%PDF-1.4 pitch",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
s.link_attachment(pitch, Subject::Project(u64::from(alpha)))
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
subject: "Alpha on spec".into(),
|
||||
decision_md: "Build it on spec.".into(),
|
||||
decided_by: vec!["Tobin Harlow".into()],
|
||||
decided_on: d(2026, 4, 1),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![alpha],
|
||||
})
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let books = load(&dir);
|
||||
Fixture {
|
||||
_dir: dir,
|
||||
books,
|
||||
zeta,
|
||||
alpha,
|
||||
beta,
|
||||
riley,
|
||||
alpha_cost,
|
||||
pitch,
|
||||
}
|
||||
}
|
||||
|
||||
fn load(dir: &TempDir) -> Books {
|
||||
data::load_once(&Source {
|
||||
data_dir: dir.0.join("books-data"),
|
||||
today: Some(today()),
|
||||
})
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
fn harness(f: &Fixture, route: Route) -> Harness<'static, App> {
|
||||
let books = load(&f._dir);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 2000.0))
|
||||
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, route.clone()));
|
||||
h.run_steps(3);
|
||||
h
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn projects_page_lists_every_project_alphabetically() {
|
||||
let f = fixture("list");
|
||||
let rows = listed(&f.books);
|
||||
let names: Vec<&str> = rows.iter().map(|r| r.name.as_str()).collect();
|
||||
assert_eq!(names, ["alpha app", "Beta", "Zeta site"]);
|
||||
assert_eq!(rows[1].facts, ProjectFacts::default());
|
||||
assert_eq!(Section::Projects.home(), Route::Projects);
|
||||
assert_eq!(Route::Project(f.beta).section(), Section::Projects);
|
||||
let h = harness(&f, Route::Projects);
|
||||
h.get_by_label("3 projects, all active. Net to date across them: $2,900.00.");
|
||||
for n in names {
|
||||
h.get_by_role_and_label(Role::Button, n);
|
||||
}
|
||||
assert_eq!(rows[1].facts.kind, None, "Beta: kind not recorded");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_figures_are_ledger_cores() {
|
||||
let f = fixture("figures");
|
||||
let t = ledger_core::project_to_date(&f.books.ledger, f.zeta, today()).unwrap();
|
||||
assert_eq!((t.revenue, t.expenses), (cents(500_000), cents(180_000)));
|
||||
let row = listed(&f.books)
|
||||
.into_iter()
|
||||
.find(|r| r.id == f.zeta)
|
||||
.unwrap();
|
||||
assert_eq!(row.to_date, Some(t.clone()));
|
||||
let h = harness(&f, Route::Project(f.zeta));
|
||||
h.get_by_label("$5,000.00");
|
||||
h.get_by_label("$1,800.00");
|
||||
h.get_by_label("$3,200.00");
|
||||
// By account to date: the same statement lines.
|
||||
let fig = ledger_core::project_figures(
|
||||
&f.books.ledger,
|
||||
f.zeta,
|
||||
ledger_core::calendar::FIRST_DAY,
|
||||
today(),
|
||||
)
|
||||
.unwrap();
|
||||
for l in fig.period.revenue.iter().chain(&fig.period.expenses) {
|
||||
h.get_by_role_and_label(Role::Button, &format!("{} {}", l.code, l.name));
|
||||
}
|
||||
h.get_by_label("Margin to date");
|
||||
h.get_by_label("64.0%");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unrecovered_cost_shows_only_on_spec() {
|
||||
let f = fixture("unrecovered");
|
||||
let h = harness(&f, Route::Project(f.alpha));
|
||||
h.get_by_label("Unrecovered cost");
|
||||
h.get_by_label("if repaid: not a receivable");
|
||||
// The devices (an asset) count nothing: spending and the unrecovered
|
||||
// cost are both the prototype's $300.00.
|
||||
assert_eq!(h.get_all_by_label("$300.00").count(), 2);
|
||||
let h = harness(&f, Route::Project(f.zeta));
|
||||
assert!(h.query_by_label("Unrecovered cost").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_project_row_opens_the_project() {
|
||||
let f = fixture("row");
|
||||
let mut h = harness(&f, Route::Projects);
|
||||
h.get_by_role_and_label(Role::Button, "alpha app").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_entry_row_opens_the_entry() {
|
||||
let f = fixture("entry-row");
|
||||
let mut h = harness(&f, Route::Project(f.alpha));
|
||||
h.get_by_role_and_label(Role::Button, "Alpha prototype")
|
||||
.click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state_mut().route(), &Route::Entry(f.alpha_cost));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_document_opens_the_document() {
|
||||
let f = fixture("doc");
|
||||
let mut h = harness(&f, Route::Project(f.alpha));
|
||||
h.get_by_label("alpha pitch.pdf").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state_mut().route(), &Route::Document(f.pitch));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_party_link_opens_the_party() {
|
||||
let f = fixture("party-link");
|
||||
let mut h = harness(&f, Route::Project(f.zeta));
|
||||
h.get_by_label("Riley Chen").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state_mut().route(), &Route::Party(f.riley));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn profit_by_project_bar_opens_the_project() {
|
||||
let f = fixture("profit");
|
||||
let mut h = harness(&f, Route::Profit);
|
||||
h.get_by_label("Net income by project, this year");
|
||||
h.get_by_role_and_label(Role::Button, "Zeta site").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state_mut().route(), &Route::Project(f.zeta));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn party_page_lists_its_projects() {
|
||||
let f = fixture("party");
|
||||
assert_eq!(f.books.ledger.projects_of_party(f.riley), vec![f.zeta]);
|
||||
let mut h = harness(&f, Route::Party(f.riley));
|
||||
h.get_by_role_and_label(Role::Button, "Zeta site").click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state_mut().route(), &Route::Project(f.zeta));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn entry_line_project_opens_the_project() {
|
||||
let f = fixture("entry-line");
|
||||
let mut h = harness(&f, Route::Entry(f.alpha_cost));
|
||||
h.get_by_label(&format!("{} alpha app", egui_phosphor::regular::TAG))
|
||||
.click();
|
||||
h.run_steps(3);
|
||||
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn journal_describes_project_set() {
|
||||
let f = fixture("journal");
|
||||
let b = &f.books;
|
||||
let texts: Vec<(String, Option<Route>)> = b
|
||||
.journal
|
||||
.iter()
|
||||
.filter_map(|l| describe(b, l).map(|e| (e.text, e.route)))
|
||||
.collect();
|
||||
let has = |t: &str, r: Route| texts.iter().any(|(x, y)| x == t && *y == Some(r.clone()));
|
||||
assert!(
|
||||
has("Added project Zeta site", Route::Project(f.zeta)),
|
||||
"{texts:?}"
|
||||
);
|
||||
assert!(
|
||||
has(
|
||||
"Project Zeta site: active, client work",
|
||||
Route::Project(f.zeta)
|
||||
),
|
||||
"{texts:?}"
|
||||
);
|
||||
assert!(
|
||||
has(
|
||||
"Project alpha app: active, on spec",
|
||||
Route::Project(f.alpha)
|
||||
),
|
||||
"{texts:?}"
|
||||
);
|
||||
assert!(
|
||||
has("Added project Beta", Route::Project(f.beta)),
|
||||
"{texts:?}"
|
||||
);
|
||||
assert!(
|
||||
texts
|
||||
.iter()
|
||||
.any(|(t, _)| t == "Linked alpha pitch.pdf to Project alpha app"),
|
||||
"{texts:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn show_project_target_opens_its_page() {
|
||||
let f = fixture("show");
|
||||
let mut h = harness(&f, Route::Overview);
|
||||
h.state_mut()
|
||||
.show(ViewerTarget::Project { class_id: f.alpha });
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
|
||||
h.state_mut().show(ViewerTarget::Project { class_id: 999 });
|
||||
h.run_steps(1);
|
||||
assert_eq!(h.state_mut().route(), &Route::Project(f.alpha));
|
||||
h.state_mut().show(ViewerTarget::Page {
|
||||
page: "projects".into(),
|
||||
});
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state_mut().route(), &Route::Projects);
|
||||
// Every project's page, and one that doesn't exist, render.
|
||||
for c in [f.zeta, f.alpha, f.beta, 999] {
|
||||
h.state_mut().goto(Route::Project(c));
|
||||
h.run_steps(2);
|
||||
}
|
||||
}
|
||||
|
||||
/// A project named `name` with `link`, a $100.00 sale and a decision, in
|
||||
/// books of its own; a harness `w` by `h` on its page.
|
||||
fn edge(tag: &str, name: &str, link: &str, w: f32, h: f32) -> (TempDir, Harness<'static, App>) {
|
||||
let dir = TempDir(std::env::temp_dir().join(format!(
|
||||
"ledger-gui-projects-edge-{tag}-{}",
|
||||
std::process::id()
|
||||
)));
|
||||
let _ = std::fs::remove_dir_all(&dir.0);
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
let cash = s
|
||||
.add_account("1000", "Cash", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let revenue = s
|
||||
.add_account("4000", "Design Services", AccountType::Revenue, None, false)
|
||||
.unwrap();
|
||||
let id = s
|
||||
.add_project(
|
||||
name,
|
||||
&ProjectFacts {
|
||||
kind: Some(ProjectKind::Client),
|
||||
links: vec![link.to_string()],
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(2026, 3, 2), "Sale").unwrap();
|
||||
s.add_line(e, cash, Side::Debit, usd(10_000)).unwrap();
|
||||
s.add_line(e, revenue, Side::Credit, usd(10_000)).unwrap();
|
||||
s.set_line_class(e, 1, Some(id)).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
subject: "Take the job".into(),
|
||||
decision_md: "Do it.".into(),
|
||||
decided_by: vec!["Tobin Harlow".into()],
|
||||
decided_on: d(2026, 3, 1),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![id],
|
||||
})
|
||||
.unwrap();
|
||||
drop(s);
|
||||
let books = load(&dir);
|
||||
let mut harness = Harness::builder()
|
||||
.with_size(egui::vec2(w, h))
|
||||
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, Route::Project(id)));
|
||||
harness.run_steps(4);
|
||||
// For eyes: SHOT_DIR keeps a picture of each edge page.
|
||||
if let Ok(out) = std::env::var("SHOT_DIR") {
|
||||
let _ = std::fs::create_dir_all(&out);
|
||||
if let Ok(img) = harness.render() {
|
||||
img.save(std::path::Path::new(&out).join(format!("{tag}.png")))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
(dir, harness)
|
||||
}
|
||||
|
||||
/// A 200-character link with no space to wrap at stays inside the Facts
|
||||
/// card: left of the Decisions card beside it, and inside the window.
|
||||
#[test]
|
||||
fn a_long_link_stays_in_its_card() {
|
||||
let link = format!("https://git.example/{}", "a".repeat(180));
|
||||
for (w, tag) in [(1440.0, "link-1440"), (900.0, "link-900")] {
|
||||
let (_dir, h) = edge(tag, "Harbor", &link, w, 2600.0);
|
||||
let shown = h.get_by_label_contains("https://git.example/").rect();
|
||||
let decisions = h.get_by_label("Decisions").rect();
|
||||
assert!(
|
||||
shown.right() <= w,
|
||||
"{w}: the link runs to {}",
|
||||
shown.right()
|
||||
);
|
||||
if decisions.left() > shown.left() {
|
||||
assert!(
|
||||
shown.right() <= decisions.left(),
|
||||
"{w}: the link runs to {}, under Decisions at {}",
|
||||
shown.right(),
|
||||
decisions.left()
|
||||
);
|
||||
}
|
||||
let net = h.get_by_label("Net to date").rect();
|
||||
assert!(net.right() <= w, "{w}: Net to date at {net:?}");
|
||||
}
|
||||
}
|
||||
|
||||
/// A 200-character name, with spaces or without, keeps the page inside
|
||||
/// the window: the title and every tile show.
|
||||
#[test]
|
||||
fn a_long_name_keeps_the_page_in_the_window() {
|
||||
for (name, tag) in [
|
||||
("Harbor ".repeat(28).trim().to_string(), "name-spaces"),
|
||||
("H".repeat(200), "name-nospace"),
|
||||
] {
|
||||
let (_dir, h) = edge(tag, &name, "https://harbor.example", 1440.0, 2000.0);
|
||||
for tile in ["Income to date", "Net to date", "Margin to date"] {
|
||||
let r = h.get_by_label(tile).rect();
|
||||
assert!(r.right() <= 1440.0, "{tag}: {tile} at {r:?}");
|
||||
}
|
||||
let decisions = h.get_by_label("Decisions").rect();
|
||||
assert!(
|
||||
decisions.right() <= 1440.0,
|
||||
"{tag}: Decisions at {decisions:?}"
|
||||
);
|
||||
let named: Vec<_> = h
|
||||
.query_all_by_label_contains("HHHHHHHHHH")
|
||||
.chain(h.query_all_by_label_contains("Harbor Harbor"))
|
||||
.collect();
|
||||
assert!(!named.is_empty(), "{tag}: the name shows");
|
||||
for title in named {
|
||||
assert!(
|
||||
title.rect().right() <= 1440.0,
|
||||
"{tag}: {title:?} at {:?}",
|
||||
title.rect()
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The by-month bars are the project's own: a click opens no report (the
|
||||
/// reports are the whole company's), and the margin keeps one place.
|
||||
#[test]
|
||||
fn a_month_bar_opens_nothing_and_margin_keeps_one_place() {
|
||||
let (_dir, mut h) = edge("month", "Harbor", "https://harbor.example", 1440.0, 2000.0);
|
||||
let route = h.state_mut().route().clone();
|
||||
// All income, no spending: 100.0%, as 64.0% elsewhere.
|
||||
h.get_by_label("100.0%");
|
||||
let title = h.get_by_label("By month").rect();
|
||||
let at = egui::pos2(title.left() + 200.0, title.bottom() + 120.0);
|
||||
h.hover_at(at);
|
||||
h.run_steps(2);
|
||||
for pressed in [true, false] {
|
||||
h.event(egui::Event::PointerButton {
|
||||
pos: at,
|
||||
button: egui::PointerButton::Primary,
|
||||
pressed,
|
||||
modifiers: egui::Modifiers::NONE,
|
||||
});
|
||||
h.run_steps(1);
|
||||
}
|
||||
h.run_steps(2);
|
||||
assert_eq!(h.state_mut().route(), &route);
|
||||
}
|
||||
|
||||
/// An entry with no receipt says nothing about receipts.
|
||||
#[test]
|
||||
fn an_entry_without_receipts_says_nothing_of_them() {
|
||||
fn walk(s: &egui::Shape, out: &mut Vec<String>) {
|
||||
match s {
|
||||
egui::Shape::Text(t) => out.push(t.galley.text().to_string()),
|
||||
egui::Shape::Vec(v) => v.iter().for_each(|s| walk(s, out)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let f = fixture("no-receipts");
|
||||
let h = harness(&f, Route::Project(f.zeta));
|
||||
let mut texts = Vec::new();
|
||||
for c in &h.output().shapes {
|
||||
walk(&c.shape, &mut texts);
|
||||
}
|
||||
let subs: Vec<&String> = texts.iter().filter(|t| t.contains("Contractors")).collect();
|
||||
assert!(
|
||||
!subs.is_empty(),
|
||||
"the entries' sub-lines are painted: {texts:?}"
|
||||
);
|
||||
assert!(
|
||||
!texts.iter().any(|t| t.contains("0 receipts")),
|
||||
"no \"0 receipts\": {subs:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// The party page has the same long-name edge: a 200-character name keeps
|
||||
/// to its Facts card and the window.
|
||||
#[test]
|
||||
fn a_long_party_name_keeps_to_its_card() {
|
||||
let dir = TempDir(std::env::temp_dir().join(format!(
|
||||
"ledger-gui-projects-party-long-{}",
|
||||
std::process::id()
|
||||
)));
|
||||
let _ = std::fs::remove_dir_all(&dir.0);
|
||||
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||
let name = "P".repeat(200);
|
||||
let party = s.add_party(&name, &[]).unwrap();
|
||||
drop(s);
|
||||
let books = load(&dir);
|
||||
let mut h = Harness::builder()
|
||||
.with_size(egui::vec2(1440.0, 1400.0))
|
||||
.build_eframe(move |cc| App::with_books(&cc.egui_ctx, books, false, Route::Party(party)));
|
||||
h.run_steps(4);
|
||||
let linked = h.get_by_label("Linked records").rect();
|
||||
let named: Vec<_> = h.query_all_by_label_contains("PPPPPPPPPP").collect();
|
||||
assert!(!named.is_empty());
|
||||
for n in named {
|
||||
let r = n.rect();
|
||||
assert!(r.right() <= 1440.0, "{n:?} at {r:?}");
|
||||
// The Facts card's Name row, beside Linked records.
|
||||
let key = h.get_by_label("Name").rect();
|
||||
if (r.center().y - key.center().y).abs() < 12.0 && r.left() < linked.left() {
|
||||
assert!(
|
||||
r.right() <= linked.left(),
|
||||
"{n:?} at {r:?} under Linked records"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -136,6 +136,9 @@ fn the_viewer_never_writes_into_the_books() {
|
||||
.map(|a| a.id)
|
||||
.unwrap();
|
||||
let party = books.ledger.parties().next().map(|p| p.id).unwrap();
|
||||
// Every project's page too (visiting_project_pages_changes_nothing).
|
||||
let projects: Vec<u32> = books.ledger.list_classes().iter().map(|c| c.id).collect();
|
||||
assert!(!projects.is_empty(), "the demo has projects");
|
||||
drop(books);
|
||||
|
||||
// Follow live for a moment.
|
||||
@@ -178,12 +181,16 @@ fn the_viewer_never_writes_into_the_books() {
|
||||
Route::Asset(asset),
|
||||
Route::Parties,
|
||||
Route::Party(party),
|
||||
Route::Projects,
|
||||
Route::Document(doc),
|
||||
Route::JournalEvent(event),
|
||||
Route::Documents,
|
||||
Route::Company,
|
||||
Route::Journal,
|
||||
];
|
||||
let routes = routes
|
||||
.into_iter()
|
||||
.chain(projects.iter().map(|&c| Route::Project(c)));
|
||||
for route in routes {
|
||||
let books = data::load_once(&source).unwrap();
|
||||
let mut harness = Harness::builder()
|
||||
|
||||
@@ -89,6 +89,22 @@ fn screenshots() {
|
||||
.rev()
|
||||
.find(|a| loaded.links_of(a.id).len() > 1)
|
||||
.map_or(last_doc, |a| a.id);
|
||||
// A project by name (SHOT_PROJECT: an id): the on-spec one, and the
|
||||
// client work, else the first.
|
||||
let project_named = |name: &str| {
|
||||
loaded
|
||||
.ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.find(|c| c.name == name)
|
||||
.or_else(|| loaded.ledger.list_classes().into_iter().next())
|
||||
.map_or(1, |c| c.id)
|
||||
};
|
||||
let project = std::env::var("SHOT_PROJECT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or_else(|| project_named("Tidepool app"));
|
||||
let project_client = project_named("Client work");
|
||||
drop(loaded);
|
||||
let routes: Vec<(&str, Route)> = vec![
|
||||
("overview", Route::Overview),
|
||||
@@ -122,6 +138,9 @@ fn screenshots() {
|
||||
("asset", Route::Asset(first_asset)),
|
||||
("parties", Route::Parties),
|
||||
("party", Route::Party(party)),
|
||||
("projects", Route::Projects),
|
||||
("project", Route::Project(project)),
|
||||
("project-client", Route::Project(project_client)),
|
||||
("document", Route::Document(last_doc)),
|
||||
("document-linked", Route::Document(linked_doc)),
|
||||
("reconciliation", Route::Reconciliation(reconciliation)),
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
# Projects
|
||||
|
||||
- A project is a class with its facts: books.add_project, books.update_project, books.projects, books.project (its figures, entries, documents, decisions). books.start lists the ones under way (active and paused).
|
||||
- When the company's conventions ask for it (books.conventions: a rule to link things to projects), link whatever belongs to a project as you record it: tag each of its revenue and expense lines (books.add_line class), link its documents (books.link_attachment subject kind project, or link_to when filing), and name it in decisions about it (books.record_decision projects). An invoice or a bill takes no project yet (books.create_invoice and books.create_bill post at once, untagged): link its document to the project, and when its lines must count, book it as a hand-built entry instead (books.draft_entry, books.add_line class), which then is no invoice or bill in the agings: ask the human which matters more. Ask the human when it isn't clear which project; leave company-wide costs (rent, insurance, bookkeeping) untagged, and split a shared cost across lines only by a basis the human gives.
|
||||
- Keep the facts current: status when it changes, ended_on when it is finished or abandoned, the party it is for.
|
||||
- On spec: its costs are expenses when incurred. A hoped-for payment is not recorded (no receivable, no income) until it is received; then book it as revenue tagged to the project. books.project shows the unrecovered cost.
|
||||
- Only revenue and expense lines make a project's figures; a tagged asset purchase is listed with its entries but counts nothing until it is depreciated or expensed. Show a project to the human with books.show project.
|
||||
@@ -71,6 +71,7 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
||||
"revenue-and-payouts",
|
||||
include_str!("../handbook/revenue-and-payouts.md"),
|
||||
),
|
||||
("projects", include_str!("../handbook/projects.md")),
|
||||
(
|
||||
"parties-and-tins",
|
||||
include_str!("../handbook/parties-and-tins.md"),
|
||||
|
||||
@@ -62,7 +62,7 @@ const C: Needs = PostWhenConfirmed;
|
||||
const K: Needs = PostForApprovalRules;
|
||||
|
||||
/// Every tool and what it needs (INVARIANT 1).
|
||||
pub const TABLE: [(&str, Needs); 115] = [
|
||||
pub const TABLE: [(&str, Needs); 119] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
// Settings are committed at once: the settings commit of the plan.
|
||||
@@ -181,6 +181,10 @@ pub const TABLE: [(&str, Needs); 115] = [
|
||||
("books.add_class", W),
|
||||
("books.list_classes", R),
|
||||
("books.profit_and_loss_by_class", R),
|
||||
("books.add_project", W),
|
||||
("books.update_project", W),
|
||||
("books.projects", R),
|
||||
("books.project", R),
|
||||
("books.export_cpa_package", A),
|
||||
("books.access_log", A),
|
||||
("books.backup", R),
|
||||
|
||||
@@ -785,6 +785,7 @@ impl BooksServer {
|
||||
+ Self::files_router()
|
||||
+ Self::backup_router()
|
||||
+ Self::instruments_router()
|
||||
+ Self::projects_router()
|
||||
}
|
||||
|
||||
/// The settings in force for this request (reloaded at its start, in
|
||||
@@ -1903,7 +1904,7 @@ fn resource_list() -> Vec<Resource> {
|
||||
}
|
||||
|
||||
/// The subject kinds, for messages.
|
||||
const SUBJECT_KINDS: &str = "entry, account, asset, party, invoice, bill, period, payroll_run, schedule, reconciliation, company";
|
||||
const SUBJECT_KINDS: &str = "entry, account, asset, party, project, invoice, bill, period, payroll_run, schedule, reconciliation, company";
|
||||
|
||||
/// Resolve a tool's subject: its kind, and its id resolved the way the
|
||||
/// other tools resolve references (an account by code or id, an asset by
|
||||
@@ -1932,6 +1933,11 @@ fn resolve_subject(store: &Store, s: &SubjectParam) -> Result<ledger_store::Subj
|
||||
"account" => Subject::Account(resolve_account(store, &t)?),
|
||||
"asset" => Subject::Asset(resolve_asset_ref(store, &t)?),
|
||||
"party" => Subject::Party(resolve_party(store, &t)?),
|
||||
// A JSON number is an id, never a name.
|
||||
"project" => match &s.id {
|
||||
Some(RefId::Number(n)) => Subject::Project(*n),
|
||||
_ => Subject::Project(u64::from(resolve_class(store, &t)?)),
|
||||
},
|
||||
k => {
|
||||
let id = t
|
||||
.parse::<u64>()
|
||||
@@ -1962,6 +1968,10 @@ fn subject_out(store: &Store, sub: ledger_store::Subject) -> SubjectOut {
|
||||
None => format!("asset {i} {}", a.description),
|
||||
}),
|
||||
S::Party(i) => l.get_party(i).map(|p| format!("party {i} {}", p.label())),
|
||||
S::Project(i) => u32::try_from(i)
|
||||
.ok()
|
||||
.and_then(|c| l.get_class(c))
|
||||
.map(|c| format!("project {i} {}", c.name)),
|
||||
S::Period(i) => l
|
||||
.list_periods()
|
||||
.iter()
|
||||
@@ -2031,7 +2041,8 @@ fn receipt_out(store: &Store, a: ledger_store::Attachment) -> Result<ReceiptOut,
|
||||
}
|
||||
|
||||
/// Check every `link_to` subject before anything is filed: each resolves,
|
||||
/// none twice, none the entry the file is filed on (all or nothing).
|
||||
/// its journal feature is declared, none twice, none the entry the file is
|
||||
/// filed on (all or nothing).
|
||||
fn check_link_to(
|
||||
store: &Store,
|
||||
filed_on: Option<u64>,
|
||||
@@ -2040,6 +2051,12 @@ fn check_link_to(
|
||||
let mut out: Vec<ledger_store::Subject> = Vec::new();
|
||||
for s in link_to {
|
||||
let sub = resolve_subject(store, s)?;
|
||||
// A link to a subject kind an older version doesn't know needs its
|
||||
// journal feature, asked here, before the file is filed: a refusal
|
||||
// leaves nothing half-filed (rules.md B-27).
|
||||
if let Some(f) = Store::subject_feature(sub) {
|
||||
store.ensure_feature(f).map_err(|e| e.to_string())?;
|
||||
}
|
||||
if out.contains(&sub) {
|
||||
return Err(format!("link_to names {} twice", sub.describe()));
|
||||
}
|
||||
@@ -2590,21 +2607,31 @@ fn party_bills<'a>(ledger: &'a Ledger, party: &ledger_core::Party) -> Vec<&'a le
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Resolve a class by id or name; a number that is one class's id and
|
||||
/// another's name is refused (INVARIANT 6).
|
||||
/// Resolve a class (a project) by id or name, trimmed, the name ignoring
|
||||
/// capitals as the duplicate check does; a number that is one class's id
|
||||
/// and another's name is refused (INVARIANT 6). New names can't be digits
|
||||
/// only, so only old books can hold such a pair.
|
||||
fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
|
||||
let class = class.trim();
|
||||
let by_id = class
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
.filter(|id| store.ledger().get_class(*id).is_some());
|
||||
let by_name = store.ledger().find_class_by_name(class).map(|c| c.id);
|
||||
match (by_id, by_name) {
|
||||
(Some(i), Some(n)) if i != n => Err(format!(
|
||||
"class {class:?} is ambiguous: it is class {i}'s id and class {n}'s name; pass the id"
|
||||
)),
|
||||
(Some(i), Some(n)) if i != n => {
|
||||
let name = store
|
||||
.ledger()
|
||||
.get_class(i)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
Err(format!(
|
||||
"project {class:?} is ambiguous: it is project {i}'s id and project {n}'s name; pass {n} for project {n}, or {name:?} (project {i}'s name) for project {i}"
|
||||
))
|
||||
}
|
||||
(Some(i), _) | (None, Some(i)) => Ok(i),
|
||||
(None, None) => Err(format!(
|
||||
"unknown class {class:?}: use an id or name from books.list_classes"
|
||||
"unknown project {class:?}: use an id or name from books.projects"
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -2923,6 +2950,8 @@ mod files;
|
||||
mod instruments;
|
||||
#[path = "tools/kept.rs"]
|
||||
mod kept;
|
||||
#[path = "tools/projects.rs"]
|
||||
mod projects;
|
||||
pub use kept::SettingKeyOut;
|
||||
|
||||
#[tool_router(router = core_router)]
|
||||
@@ -3106,7 +3135,7 @@ impl BooksServer {
|
||||
name = "books.profit_and_loss",
|
||||
title = "Profit and loss",
|
||||
description = described(
|
||||
"Profit and loss for a period: revenue, expenses, net income. Pass class (name or id) to filter to one division.",
|
||||
"Profit and loss for a period: revenue, expenses, net income. Pass class (a project: name or id; see books.projects) to filter to one project.",
|
||||
ledger_core::calendar::PeriodKind::Statement
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
@@ -3297,7 +3326,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.add_line",
|
||||
title = "Add a line to a draft",
|
||||
description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (name or id) to tag the line for divisional tracking.",
|
||||
description = "Add one line to a draft entry. Amount is a decimal string, never a float. The entry must balance before posting. For foreign-currency lines, pass foreign_currency, foreign_amount, and fx_rate (foreign -> base); the base amount is computed as foreign_amount * fx_rate. Pass class (a project: name or id) to tag the line; see books.projects.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -3356,7 +3385,8 @@ impl BooksServer {
|
||||
.set_line_class(p.entry_id, line_index, Some(class_id))
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
Ok(Json(AddLineOut { ok: true }))
|
||||
let note = class_id.and_then(|c| projects::late_tag_note(store.ledger(), c));
|
||||
Ok(Json(AddLineOut { ok: true, note }))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
@@ -3611,7 +3641,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.attach_receipt",
|
||||
title = "Attach a receipt",
|
||||
description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 for a file of at most 256 KiB. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
|
||||
description = "Attach a receipt to an entry, or file it unlinked and link later with books.link_receipt. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 for a file of at most 256 KiB. Files are content-addressed and deduplicated. link_to links it to more subjects in the same call (another entry it also supports, an asset, a party, a project, ...: see books.link_attachment); every subject is checked first, so one bad subject files nothing. A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -3666,7 +3696,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.file_document",
|
||||
title = "File a document",
|
||||
description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, the company: see books.link_attachment), all checked first. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 (at most 256 KiB). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
|
||||
description = "File a document: category formation, legal, tax, accounting, statement (a bank or card statement: then books.record_statement), contract, photo, warranty, import_source (a bank export), or other. link_to links it to subjects in the same call (an account, an asset, a party, a project, the company: see books.link_attachment), all checked first. The bytes: upload_id (books.begin_upload), or content_base64 with its sha256 (at most 256 KiB). Never a W-9 (books.set_w9 w9_upload_id puts it in the vault). A file holding a full TIN is refused: one the server read or wrote with TINs, or text with a TIN the vault keeps or an SSN or EIN printed 3-2-4 or 2-7 with hyphens.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -3828,7 +3858,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.link_attachment",
|
||||
title = "Link a file to a subject",
|
||||
description = "Link a stored receipt or document to a subject, so one file is evidence wherever it applies without filing it again. subject: {kind, id}: entry (id), account (code or id: its statements, the agreement that opened it), asset (tag or id: photos, warranty, manual), party (id or name: contracts, correspondence), invoice, bill, period (its close package), payroll_run, schedule (the prepaid contract), reconciliation, or company (no id: formation documents). A file linked to an entry counts as one of its receipts. Journaled as attachment.linked. Returns the attachment's links.",
|
||||
description = "Link a stored receipt or document to a subject, so one file is evidence wherever it applies without filing it again. subject: {kind, id}: entry (id), account (code or id: its statements, the agreement that opened it), asset (tag or id: photos, warranty, manual), party (id or name: contracts, correspondence), project (id or name: its contract, spec, pitch), invoice, bill, period (its close package), payroll_run, schedule (the prepaid contract), reconciliation, or company (no id: formation documents). A file linked to an entry counts as one of its receipts. Journaled as attachment.linked. Returns the attachment's links.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -4181,7 +4211,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.show",
|
||||
title = "Show in the viewer",
|
||||
description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
|
||||
description = "Show something in the human's viewer (the read-only desktop app): an entry (with its lines, receipt, and history), an account's register (optionally for start/end dates), a report (optionally for start/end dates, split into month/quarter/year columns, compared with the prior period or prior year), a stored receipt or document, a fixed asset (by tag or id), a party (by id or name: who they are and everything the company did with them), a project (by id or name), a reconciliation (by id: its account with its statements and reconciliations), a search of the transactions, or a page. Give exactly one target; work out dates yourself (\"last quarter\" means its first and last day). Use it whenever the human asks to see something, and after posting so they can look at what you did. Changes nothing in the books.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn show(&self, params: Parameters<ShowParams>) -> Result<Json<ShowOut>, String> {
|
||||
@@ -4196,6 +4226,7 @@ impl BooksServer {
|
||||
p.attachment_id.is_some(),
|
||||
p.asset.is_some(),
|
||||
p.party.is_some(),
|
||||
p.project.is_some(),
|
||||
p.reconciliation.is_some(),
|
||||
p.search.is_some(),
|
||||
p.page.is_some(),
|
||||
@@ -4205,7 +4236,7 @@ impl BooksServer {
|
||||
.count();
|
||||
if given != 1 {
|
||||
return Err(
|
||||
"give exactly one of entry_id, account, report, attachment_id, asset, party, reconciliation, search, page"
|
||||
"give exactly one of entry_id, account, report, attachment_id, asset, party, project, reconciliation, search, page"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
@@ -4251,7 +4282,7 @@ impl BooksServer {
|
||||
"ap_aging",
|
||||
"fixed_asset_register",
|
||||
];
|
||||
const PAGES: [&str; 11] = [
|
||||
const PAGES: [&str; 12] = [
|
||||
"overview",
|
||||
"cash",
|
||||
"profit",
|
||||
@@ -4260,6 +4291,7 @@ impl BooksServer {
|
||||
"accounts",
|
||||
"assets",
|
||||
"parties",
|
||||
"projects",
|
||||
"documents",
|
||||
"company",
|
||||
"journal",
|
||||
@@ -4285,6 +4317,13 @@ impl BooksServer {
|
||||
ViewerTarget::Party { party_id: id },
|
||||
format!("party {id}, {}", party.label()),
|
||||
)
|
||||
} else if let Some(r) = &p.project {
|
||||
let id = resolve_class(&store, r)?;
|
||||
let c = ledger.get_class(id).expect("resolved");
|
||||
(
|
||||
ViewerTarget::Project { class_id: id },
|
||||
format!("project {id}, {}", c.name),
|
||||
)
|
||||
} else if let Some(id) = p.reconciliation {
|
||||
let r = store
|
||||
.reconciliations(None)
|
||||
@@ -5084,6 +5123,7 @@ impl BooksServer {
|
||||
.create_entry(parse_date(&p.date)?, &p.description)
|
||||
.map_err(|e| e.to_string())?;
|
||||
let mut described = Vec::new();
|
||||
let mut tagged: Vec<u32> = Vec::new();
|
||||
for (index, (account, side, amount, class)) in plan.into_iter().enumerate() {
|
||||
store
|
||||
.add_line(entry_id, account, side, Money::new(amount, currency))
|
||||
@@ -5092,6 +5132,9 @@ impl BooksServer {
|
||||
store
|
||||
.set_line_class(entry_id, index, Some(class))
|
||||
.map_err(|e| e.to_string())?;
|
||||
if !tagged.contains(&class) {
|
||||
tagged.push(class);
|
||||
}
|
||||
}
|
||||
let code = store
|
||||
.ledger()
|
||||
@@ -5109,6 +5152,10 @@ impl BooksServer {
|
||||
adjustments: money(totals.adjustments),
|
||||
deposit: money(totals.deposit),
|
||||
lines: described,
|
||||
notes: tagged
|
||||
.into_iter()
|
||||
.filter_map(|c| projects::late_tag_note(store.ledger(), c))
|
||||
.collect(),
|
||||
next: "Attach the payout statement (books.attach_receipt), then preview and post with books.post_entry.".to_string(),
|
||||
}))
|
||||
}
|
||||
@@ -8215,7 +8262,7 @@ impl BooksServer {
|
||||
name = "books.party",
|
||||
title = "Get a party",
|
||||
description = described(
|
||||
"One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.",
|
||||
"One party, by id or name: its facts and roles, vendor and customer record ids, the year's totals (paid to it and received from it on the basis asked, cash by default or all with property in kind, the basis echoed; invoiced, billed, the 1099 amount for a vendor, payroll gross and net), what it is owed and owes now (open bills and invoices), every entry it is on (tagged with it or its vendor record, its invoices and their payments, and their reversals), its invoices and bills, its projects, and the payroll lines naming it. Totals come from the journal: the output's schema says exactly how.",
|
||||
ledger_core::calendar::PeriodKind::CalendarYear
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
@@ -8346,6 +8393,11 @@ impl BooksServer {
|
||||
})
|
||||
.collect::<Result<_, _>>()?,
|
||||
payroll,
|
||||
projects: ledger
|
||||
.projects_of_party(id)
|
||||
.into_iter()
|
||||
.map(|c| projects::project_ref(ledger, c))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -8876,7 +8928,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.add_class",
|
||||
title = "Add a class",
|
||||
description = "Add a tracking class for divisional P&L (e.g. an app, website, or channel). Tag journal lines with it via books.add_line's class param, then report per division.",
|
||||
description = "Add a class: a project with only its name (books.add_project records its facts too; books.update_project adds them later). Names are unique, ignoring capitals. Tag journal lines with it via books.add_line's class param.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -8896,7 +8948,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.list_classes",
|
||||
title = "List classes",
|
||||
description = "List tracking classes in id order.",
|
||||
description = "List classes (projects) in id order; books.projects gives their facts and figures.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn list_classes(&self) -> Result<Json<ListClassesOut>, String> {
|
||||
@@ -8916,7 +8968,7 @@ impl BooksServer {
|
||||
name = "books.profit_and_loss_by_class",
|
||||
title = "Profit and loss by class",
|
||||
description = described(
|
||||
"P&L broken down by tracking class for a period, plus an unclassified row for untagged lines.",
|
||||
"P&L broken down by class (a project: see books.projects) for a period, plus an unclassified row for untagged lines.",
|
||||
ledger_core::calendar::PeriodKind::Statement
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
@@ -10639,7 +10691,7 @@ mod tests {
|
||||
const D: Hints = (Some(false), Some(true), None);
|
||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||
let table: [(&str, Hints); 115] = [
|
||||
let table: [(&str, Hints); 119] = [
|
||||
("books.handbook", R),
|
||||
("books.start", R),
|
||||
("books.set_setting", DI),
|
||||
@@ -10750,6 +10802,10 @@ mod tests {
|
||||
("books.list_payroll_runs", R),
|
||||
("books.add_class", W),
|
||||
("books.list_classes", R),
|
||||
("books.add_project", W),
|
||||
("books.update_project", WI),
|
||||
("books.projects", R),
|
||||
("books.project", R),
|
||||
("books.profit_and_loss_by_class", R),
|
||||
("books.export_cpa_package", W),
|
||||
("books.access_log", R),
|
||||
@@ -11387,6 +11443,11 @@ mod tests {
|
||||
PeriodKind::Statement,
|
||||
&[("start", START_DOC), ("end", END_DOC)][..],
|
||||
),
|
||||
(
|
||||
"books.project",
|
||||
PeriodKind::Statement,
|
||||
&[("start", START_DOC), ("end", END_DOC)][..],
|
||||
),
|
||||
(
|
||||
"books.cash_flow",
|
||||
PeriodKind::Statement,
|
||||
|
||||
@@ -185,6 +185,20 @@ pub struct OpenItemBriefOut {
|
||||
pub due: Option<String>,
|
||||
}
|
||||
|
||||
/// A project, in brief.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectBriefOut {
|
||||
/// Its id (a class id).
|
||||
pub id: u32,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// active or paused.
|
||||
pub status: String,
|
||||
/// Its kind, if recorded.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub kind: Option<String>,
|
||||
}
|
||||
|
||||
/// A handbook section.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct SectionOut {
|
||||
@@ -241,9 +255,12 @@ pub struct StartOut {
|
||||
pub conventions: Vec<ConventionBriefOut>,
|
||||
/// What is open, oldest first.
|
||||
pub open_items: Vec<OpenItemBriefOut>,
|
||||
/// The projects under way (active and paused), by name, at most 20
|
||||
/// (books.projects lists every one with its figures).
|
||||
pub projects: Vec<ProjectBriefOut>,
|
||||
/// Lists cut short to fit: what was left out, e.g. "conventions: 4
|
||||
/// more" (the full lists are in books.conventions and
|
||||
/// books.open_items).
|
||||
/// more" (the full lists are in books.conventions,
|
||||
/// books.open_items and books.projects).
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub truncated: Vec<String>,
|
||||
/// The handbook's sections (books.handbook {section}).
|
||||
@@ -459,6 +476,9 @@ pub struct RecordDecisionParams {
|
||||
/// The rule or document it rests on, if any.
|
||||
#[serde(default)]
|
||||
pub citation: Option<String>,
|
||||
/// The projects it is about (ids or names), if any.
|
||||
#[serde(default)]
|
||||
pub projects: Vec<String>,
|
||||
}
|
||||
|
||||
/// A decision.
|
||||
@@ -486,6 +506,9 @@ pub struct DecisionOut {
|
||||
/// What it rests on.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub citation: Option<String>,
|
||||
/// The projects it is about.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub projects: Vec<ProjectRefOut>,
|
||||
/// The journal line.
|
||||
pub seq: u64,
|
||||
}
|
||||
@@ -496,6 +519,9 @@ pub struct DecisionsParams {
|
||||
/// Include decisions a later one replaced.
|
||||
#[serde(default)]
|
||||
pub include_superseded: bool,
|
||||
/// Only the decisions naming this project (id or name).
|
||||
#[serde(default)]
|
||||
pub project: Option<String>,
|
||||
}
|
||||
|
||||
/// Result of `books.decisions`.
|
||||
@@ -681,7 +707,7 @@ fn convention_out(c: &ledger_core::conventions::Convention) -> ConventionOut {
|
||||
}
|
||||
}
|
||||
|
||||
fn decision_out(d: &ledger_core::conventions::Decision) -> DecisionOut {
|
||||
pub(super) fn decision_out(ledger: &Ledger, d: &ledger_core::conventions::Decision) -> DecisionOut {
|
||||
DecisionOut {
|
||||
id: d.id,
|
||||
subject: d.new.subject.clone(),
|
||||
@@ -692,6 +718,12 @@ fn decision_out(d: &ledger_core::conventions::Decision) -> DecisionOut {
|
||||
supersedes: d.new.supersedes,
|
||||
superseded_by: d.superseded_by,
|
||||
citation: d.new.citation.clone(),
|
||||
projects: d
|
||||
.new
|
||||
.projects
|
||||
.iter()
|
||||
.map(|&p| super::projects::project_ref(ledger, p))
|
||||
.collect(),
|
||||
seq: d.seq,
|
||||
}
|
||||
}
|
||||
@@ -789,6 +821,28 @@ impl BooksServer {
|
||||
due: i.new.due.map(|d| d.to_string()),
|
||||
})
|
||||
.collect();
|
||||
// The projects under way, by name (rules.md B-37): at most 20.
|
||||
const START_PROJECTS: usize = 20;
|
||||
let mut projects: Vec<ProjectBriefOut> = ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.filter_map(|c| {
|
||||
let f = ledger.project_facts(c.id)?;
|
||||
matches!(
|
||||
f.status,
|
||||
ledger_core::ProjectStatus::Active | ledger_core::ProjectStatus::Paused
|
||||
)
|
||||
.then(|| ProjectBriefOut {
|
||||
id: c.id,
|
||||
name: c.name.clone(),
|
||||
status: f.status.key().to_string(),
|
||||
kind: f.kind.map(|k| k.key().to_string()),
|
||||
})
|
||||
})
|
||||
.collect();
|
||||
projects.sort_by_key(|a| (a.name.to_lowercase(), a.id));
|
||||
let mut p_cut = projects.len().saturating_sub(START_PROJECTS);
|
||||
projects.truncate(START_PROJECTS);
|
||||
let journaled = cfg.journaled;
|
||||
let focus = focus.unwrap_or("daily");
|
||||
let step = |tool: &str, args: Option<serde_json::Value>, why: &str| NextStepOut {
|
||||
@@ -919,6 +973,7 @@ impl BooksServer {
|
||||
settings_summary: Vec::new(),
|
||||
conventions: Vec::new(),
|
||||
open_items: Vec::new(),
|
||||
projects: Vec::new(),
|
||||
truncated: Vec::new(),
|
||||
handbook_sections: handbook::SECTIONS
|
||||
.iter()
|
||||
@@ -942,6 +997,20 @@ impl BooksServer {
|
||||
out.settings_summary = std::mem::take(&mut settings_summary);
|
||||
out.conventions = std::mem::take(&mut conventions);
|
||||
out.open_items = std::mem::take(&mut open_items);
|
||||
out.projects = std::mem::take(&mut projects);
|
||||
let cut_note = |c: usize, i: usize, s: usize, p: usize| -> Vec<String> {
|
||||
[
|
||||
("conventions", c, "books.conventions"),
|
||||
("open_items", i, "books.open_items"),
|
||||
("settings_summary", s, "books.settings"),
|
||||
("projects", p, "books.projects"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, n, _)| *n > 0)
|
||||
.map(|(what, n, tool)| format!("{what}: {n} more ({tool})"))
|
||||
.collect()
|
||||
};
|
||||
out.truncated = cut_note(0, 0, 0, p_cut);
|
||||
if size(&out) > START_MAX {
|
||||
for c in &mut out.conventions {
|
||||
c.summary.clear();
|
||||
@@ -955,6 +1024,9 @@ impl BooksServer {
|
||||
} else if out.open_items.len() > 10 {
|
||||
out.open_items.pop();
|
||||
i_cut += 1;
|
||||
} else if out.projects.len() > 5 {
|
||||
out.projects.pop();
|
||||
p_cut += 1;
|
||||
} else if !out.settings_summary.is_empty() {
|
||||
out.settings_summary.pop();
|
||||
s_cut += 1;
|
||||
@@ -964,18 +1036,13 @@ impl BooksServer {
|
||||
} else if !out.open_items.is_empty() {
|
||||
out.open_items.pop();
|
||||
i_cut += 1;
|
||||
} else if !out.projects.is_empty() {
|
||||
out.projects.pop();
|
||||
p_cut += 1;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
out.truncated = [
|
||||
("conventions", c_cut, "books.conventions"),
|
||||
("open_items", i_cut, "books.open_items"),
|
||||
("settings_summary", s_cut, "books.settings"),
|
||||
]
|
||||
.into_iter()
|
||||
.filter(|(_, n, _)| *n > 0)
|
||||
.map(|(what, n, tool)| format!("{what}: {n} more ({tool})"))
|
||||
.collect();
|
||||
out.truncated = cut_note(c_cut, i_cut, s_cut, p_cut);
|
||||
}
|
||||
out
|
||||
}
|
||||
@@ -1004,7 +1071,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.start",
|
||||
title = "Start here",
|
||||
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
|
||||
description = "Call first in every session. The books in brief: company, today in its time zone, fiscal year, the journal's head, whether they need onboarding, where settings come from, the rules in force (receipts, parties, approvals), every setting with who set it, this company's conventions (how it keeps its books: posting templates, policies, close steps), open items, the projects under way, the handbook's sections, a format upgrade the books wait for (only the owner can agree to it: tell them), and next steps. Read-only; at most 8 KB.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
pub(super) async fn start(
|
||||
@@ -1337,7 +1404,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.record_decision",
|
||||
title = "Record a decision",
|
||||
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source.",
|
||||
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1349,6 +1416,13 @@ impl BooksServer {
|
||||
params: Parameters<RecordDecisionParams>,
|
||||
) -> Result<Json<IdOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
// The projects resolved before anything is written.
|
||||
let projects = p
|
||||
.projects
|
||||
.iter()
|
||||
.map(|r| resolve_class(&store, r.trim()))
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
let d = NewDecision {
|
||||
subject: p.subject,
|
||||
decision_md: p.decision_md,
|
||||
@@ -1357,8 +1431,8 @@ impl BooksServer {
|
||||
evidence_attachment: p.evidence_attachment,
|
||||
supersedes: p.supersedes,
|
||||
citation: p.citation,
|
||||
projects,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let id = store.record_decision(&d).map_err(bad)?;
|
||||
Ok(Json(IdOut { id }))
|
||||
}
|
||||
@@ -1366,7 +1440,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.decisions",
|
||||
title = "Decisions",
|
||||
description = "The owners' decisions about the books, oldest first, each with who decided, when, what it rests on, and what replaced it.",
|
||||
description = "The owners' decisions about the books, oldest first, each with who decided, when, what it rests on, the projects it is about, and what replaced it. project filters to the decisions naming one.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn decisions(
|
||||
@@ -1374,13 +1448,20 @@ impl BooksServer {
|
||||
params: Parameters<DecisionsParams>,
|
||||
) -> Result<Json<DecisionsOut>, String> {
|
||||
let store = self.lock()?;
|
||||
let project = params
|
||||
.0
|
||||
.project
|
||||
.as_deref()
|
||||
.map(|r| resolve_class(&store, r.trim()))
|
||||
.transpose()?;
|
||||
let ledger = store.ledger();
|
||||
Ok(Json(DecisionsOut {
|
||||
decisions: store
|
||||
.ledger()
|
||||
decisions: ledger
|
||||
.conventions()
|
||||
.decisions()
|
||||
.filter(|d| params.0.include_superseded || d.superseded_by.is_none())
|
||||
.map(decision_out)
|
||||
.filter(|d| project.is_none_or(|p| d.new.projects.contains(&p)))
|
||||
.map(|d| decision_out(ledger, d))
|
||||
.collect(),
|
||||
}))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,390 @@
|
||||
//! Projects through the server: `books.add_project`,
|
||||
//! `books.update_project`, `books.projects` and `books.project` (rules.md
|
||||
//! B-37). A project is a class with its facts: `books.add_line`'s class
|
||||
//! tags a line with it, and every class tool keeps working.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: the agent records what each project is and links what belongs to
|
||||
//! it; anyone reads what it earned and cost from the same figures
|
||||
//! the viewer and bookspec use.
|
||||
//! SOURCE: docs/spec/rules.md B-37 (DECISION, owner, 2026-10-21; job
|
||||
//! costing; ASC 450-30-25-1 for on-spec work), B-30 (omitted
|
||||
//! dates), B-27 (journal feature `projects`).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every field is checked before anything is written; a refusal
|
||||
//! journals nothing.
|
||||
//! 2. Figures come only from ledger_core::project_figures,
|
||||
//! project_to_date and project_entries (never re-summed here).
|
||||
//! 3. books.update_project with nothing new journals nothing.
|
||||
//! 4. A project is named by id or name (ignoring capitals); a number
|
||||
//! that is one project's id and another's name is refused.
|
||||
//! ```
|
||||
|
||||
use super::kept::DecisionOut;
|
||||
use super::*;
|
||||
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use schemars::JsonSchema;
|
||||
use serde::Serialize;
|
||||
|
||||
/// Result of `books.project`.
|
||||
#[derive(Debug, Serialize, JsonSchema)]
|
||||
pub struct ProjectDetailOut {
|
||||
/// Its facts.
|
||||
pub project: ProjectOut,
|
||||
/// The period asked.
|
||||
pub period: ProjectPeriodOut,
|
||||
/// Every tagged line through the period's end.
|
||||
pub to_date: ToDateOut,
|
||||
/// Every entry with a line tagged to it (drafts included), newest first.
|
||||
pub entries: Vec<ProjectEntryOut>,
|
||||
/// The files linked to it (its contract, spec, pitch), not withdrawn.
|
||||
pub documents: Vec<ReceiptOut>,
|
||||
/// The decisions naming it, oldest first, superseded ones included.
|
||||
pub decisions: Vec<DecisionOut>,
|
||||
}
|
||||
|
||||
/// A status key, or why not.
|
||||
fn parse_status(s: &str) -> Result<ProjectStatus, String> {
|
||||
ProjectStatus::parse(s.trim())
|
||||
.ok_or_else(|| format!("status {s:?}: expected active, paused, finished, or abandoned"))
|
||||
}
|
||||
|
||||
/// A kind key, or why not.
|
||||
fn parse_kind(s: &str) -> Result<ProjectKind, String> {
|
||||
ProjectKind::parse(s.trim())
|
||||
.ok_or_else(|| format!("kind {s:?}: expected internal, client, on_spec, or other"))
|
||||
}
|
||||
|
||||
/// A project's facts and party for a tool's output.
|
||||
pub(super) fn project_out(ledger: &Ledger, id: ClassId) -> ProjectOut {
|
||||
let name = ledger
|
||||
.get_class(id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default();
|
||||
let f = ledger.project_facts(id).unwrap_or_default();
|
||||
ProjectOut {
|
||||
id,
|
||||
name,
|
||||
status: f.status.key().to_string(),
|
||||
kind: f.kind.map(|k| k.key().to_string()),
|
||||
description: f.description,
|
||||
party: f.party.map(|p| PartyRefOut {
|
||||
party_id: p,
|
||||
label: ledger
|
||||
.get_party(p)
|
||||
.map(|x| x.label().to_string())
|
||||
.unwrap_or_default(),
|
||||
}),
|
||||
started_on: f.started_on.map(|d| d.to_string()),
|
||||
ended_on: f.ended_on.map(|d| d.to_string()),
|
||||
links: f.links,
|
||||
notes: f.notes,
|
||||
}
|
||||
}
|
||||
|
||||
/// A project named by id and name.
|
||||
pub(super) fn project_ref(ledger: &Ledger, id: ClassId) -> ProjectRefOut {
|
||||
ProjectRefOut {
|
||||
id,
|
||||
name: ledger
|
||||
.get_class(id)
|
||||
.map(|c| c.name.clone())
|
||||
.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Figures to date for a tool's output.
|
||||
fn to_date_out(t: &ledger_core::ProjectToDate) -> ToDateOut {
|
||||
ToDateOut {
|
||||
through: t.through.to_string(),
|
||||
revenue: money(t.revenue),
|
||||
expenses: money(t.expenses),
|
||||
net: money(t.net()),
|
||||
unrecovered_cost: t.unrecovered_cost.map(money),
|
||||
}
|
||||
}
|
||||
|
||||
/// An optional date from a tool: "" clears it.
|
||||
fn optional_date(what: &str, s: &str) -> Result<Option<NaiveDate>, String> {
|
||||
match optional_text(s) {
|
||||
None => Ok(None),
|
||||
Some(t) => parse_date(&t).map(Some).map_err(|e| format!("{what}: {e}")),
|
||||
}
|
||||
}
|
||||
|
||||
/// The projects ordered by name, ignoring capitals (then id).
|
||||
fn by_name(ledger: &Ledger) -> Vec<ClassId> {
|
||||
let mut ids: Vec<(String, ClassId)> = ledger
|
||||
.list_classes()
|
||||
.into_iter()
|
||||
.map(|c| (c.name.to_lowercase(), c.id))
|
||||
.collect();
|
||||
ids.sort();
|
||||
ids.into_iter().map(|(_, id)| id).collect()
|
||||
}
|
||||
|
||||
/// The note `books.add_line` and `books.record_payout` give for a line tagged to a finished or
|
||||
/// abandoned project (rules.md B-37 item 5).
|
||||
pub(super) fn late_tag_note(ledger: &Ledger, id: ClassId) -> Option<String> {
|
||||
let f = ledger.project_facts(id)?;
|
||||
if !f.status.is_over() {
|
||||
return None;
|
||||
}
|
||||
let name = &ledger.get_class(id)?.name;
|
||||
let ended = f
|
||||
.ended_on
|
||||
.map(|d| format!(" (ended {d})"))
|
||||
.unwrap_or_default();
|
||||
Some(format!(
|
||||
"project {name} is {}{ended}: tag a late cost or invoice to it only if it belongs to it",
|
||||
f.status.key()
|
||||
))
|
||||
}
|
||||
|
||||
#[tool_router(router = projects_router, vis = "pub(super)")]
|
||||
impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.add_project",
|
||||
title = "Add a project",
|
||||
description = "Add a project: a class (books.add_line's class tags a line with it) with its facts. status: active (default), paused, finished, abandoned. kind: internal (the company's own product), client (work for a customer), on_spec (work done hoping to be paid: its costs are expenses, a repayment is income when received), other. party: who it is for or with (id or name). started_on, ended_on (finished or abandoned only), links (web addresses), description, notes. Names are unique. A name alone journals class.added; facts add project.set. Returns the project.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn add_project(
|
||||
&self,
|
||||
params: Parameters<AddProjectParams>,
|
||||
) -> Result<Json<ProjectOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let facts = ProjectFacts {
|
||||
description: p.description.as_deref().and_then(optional_text),
|
||||
status: p
|
||||
.status
|
||||
.as_deref()
|
||||
.map(parse_status)
|
||||
.transpose()?
|
||||
.unwrap_or_default(),
|
||||
kind: match p.kind.as_deref().and_then(optional_text) {
|
||||
Some(k) => Some(parse_kind(&k)?),
|
||||
None => None,
|
||||
},
|
||||
party: match p.party.as_deref().and_then(optional_text) {
|
||||
Some(r) => Some(resolve_party(&store, &r)?),
|
||||
None => None,
|
||||
},
|
||||
started_on: optional_date("started_on", p.started_on.as_deref().unwrap_or(""))?,
|
||||
ended_on: optional_date("ended_on", p.ended_on.as_deref().unwrap_or(""))?,
|
||||
links: p.links,
|
||||
notes: p.notes.as_deref().and_then(optional_text),
|
||||
};
|
||||
let id = store
|
||||
.add_project(&p.name, &facts)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(project_out(store.ledger(), id)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.update_project",
|
||||
title = "Update a project",
|
||||
description = "Change a project's facts (see books.add_project): give only the fields to change; \"\" clears description, kind, party, started_on, ended_on or notes, and links replaces the list ([] clears it). Every field is checked before anything is written, then the whole record is journaled as project.set; nothing new journals nothing. A rename changes the label every report prints, nothing else; a name another project has is refused. Returns the project.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
idempotent_hint = true,
|
||||
open_world_hint = false
|
||||
)
|
||||
)]
|
||||
async fn update_project(
|
||||
&self,
|
||||
params: Parameters<UpdateProjectParams>,
|
||||
) -> Result<Json<ProjectOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
let id = resolve_class(&store, &p.project)?;
|
||||
if p.name.is_none()
|
||||
&& p.description.is_none()
|
||||
&& p.status.is_none()
|
||||
&& p.kind.is_none()
|
||||
&& p.party.is_none()
|
||||
&& p.started_on.is_none()
|
||||
&& p.ended_on.is_none()
|
||||
&& p.links.is_none()
|
||||
&& p.notes.is_none()
|
||||
{
|
||||
return Err(
|
||||
"give at least one field to change: name, description, status, kind, party, started_on, ended_on, links, notes"
|
||||
.into(),
|
||||
);
|
||||
}
|
||||
let ledger = store.ledger();
|
||||
let mut f = ledger.project_facts(id).expect("resolved");
|
||||
let name = p
|
||||
.name
|
||||
.clone()
|
||||
.unwrap_or_else(|| ledger.get_class(id).expect("resolved").name.clone());
|
||||
if let Some(v) = &p.description {
|
||||
f.description = optional_text(v);
|
||||
}
|
||||
if let Some(v) = &p.status {
|
||||
f.status = parse_status(v)?;
|
||||
}
|
||||
if let Some(v) = &p.kind {
|
||||
f.kind = optional_text(v).map(|k| parse_kind(&k)).transpose()?;
|
||||
}
|
||||
if let Some(v) = &p.party {
|
||||
f.party = optional_text(v)
|
||||
.map(|r| resolve_party(&store, &r))
|
||||
.transpose()?;
|
||||
}
|
||||
if let Some(v) = &p.started_on {
|
||||
f.started_on = optional_date("started_on", v)?;
|
||||
}
|
||||
if let Some(v) = &p.ended_on {
|
||||
f.ended_on = optional_date("ended_on", v)?;
|
||||
}
|
||||
if let Some(v) = p.links {
|
||||
f.links = v;
|
||||
}
|
||||
if let Some(v) = &p.notes {
|
||||
f.notes = optional_text(v);
|
||||
}
|
||||
store
|
||||
.set_project(id, &name, &f)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(Json(project_out(store.ledger(), id)))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.projects",
|
||||
title = "Projects",
|
||||
description = "Every project (every class, with its facts), by name: status, kind, party, dates, and its figures to date (every posted line tagged to it through today in the company's time zone, closes left out): revenue, expenses, net, and for an on_spec project its unrecovered cost. status filters to one status.",
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn projects(
|
||||
&self,
|
||||
params: Parameters<ProjectsParams>,
|
||||
) -> Result<Json<ProjectsOut>, String> {
|
||||
let only = params.0.status.as_deref().map(parse_status).transpose()?;
|
||||
let through = today();
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let mut projects = Vec::new();
|
||||
for id in by_name(ledger) {
|
||||
let f = ledger.project_facts(id).expect("listed");
|
||||
if only.is_some_and(|s| s != f.status) {
|
||||
continue;
|
||||
}
|
||||
let t = ledger_core::project_to_date(ledger, id, through).map_err(|e| e.to_string())?;
|
||||
projects.push(ProjectRowOut {
|
||||
project: project_out(ledger, id),
|
||||
to_date: to_date_out(&t),
|
||||
});
|
||||
}
|
||||
Ok(Json(ProjectsOut {
|
||||
through: through.to_string(),
|
||||
projects,
|
||||
}))
|
||||
}
|
||||
|
||||
#[tool(
|
||||
name = "books.project",
|
||||
title = "Get a project",
|
||||
description = described(
|
||||
"One project, by id or name: its facts, party and decisions, the documents linked to it, and every entry with a line tagged to it (date, description, project amount, receipts). For the period: revenue, expenses, net, by account and by month. To date (every tagged line through end): the same totals and, for an on_spec project, its unrecovered cost, the 'if repaid' figure, never a receivable. Only revenue and expense lines count.",
|
||||
ledger_core::calendar::PeriodKind::Statement
|
||||
),
|
||||
annotations(read_only_hint = true, open_world_hint = false)
|
||||
)]
|
||||
async fn project(
|
||||
&self,
|
||||
params: Parameters<ProjectParams>,
|
||||
) -> Result<Json<ProjectDetailOut>, String> {
|
||||
let p = params.0;
|
||||
let (start, end) = self.statement_range(p.start.as_deref(), p.end.as_deref())?;
|
||||
let store = self.lock()?;
|
||||
let ledger = store.ledger();
|
||||
let id = resolve_class(&store, &p.project)?;
|
||||
let fig =
|
||||
ledger_core::project_figures(ledger, id, start, end).map_err(|e| e.to_string())?;
|
||||
let account = |l: &ledger_core::StatementLine, t: &str| ProjectAccountOut {
|
||||
code: l.code.clone(),
|
||||
name: l.name.clone(),
|
||||
account_type: t.to_string(),
|
||||
amount: money(l.amount),
|
||||
};
|
||||
let by_account = fig
|
||||
.period
|
||||
.revenue
|
||||
.iter()
|
||||
.map(|l| account(l, "revenue"))
|
||||
.chain(fig.period.expenses.iter().map(|l| account(l, "expense")))
|
||||
.collect();
|
||||
let by_month = fig
|
||||
.months
|
||||
.iter()
|
||||
.map(|m| ProjectMonthOut {
|
||||
month: m.start.format("%Y-%m").to_string(),
|
||||
start: m.start.to_string(),
|
||||
end: m.end.to_string(),
|
||||
revenue: money(m.revenue),
|
||||
expenses: money(m.expenses),
|
||||
net: money(m.net()),
|
||||
})
|
||||
.collect();
|
||||
let mut entries = Vec::new();
|
||||
for (eid, amount) in ledger_core::project_entries(ledger, id) {
|
||||
let e = ledger.get_entry(eid).expect("listed");
|
||||
let receipts = store
|
||||
.attachments_for(ledger_store::Subject::Entry(eid))
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|a| a.id)
|
||||
.collect();
|
||||
entries.push(ProjectEntryOut {
|
||||
entry_id: eid,
|
||||
date: e.date().to_string(),
|
||||
description: e.description().to_string(),
|
||||
status: match e.status() {
|
||||
ledger_core::EntryStatus::Draft => "draft",
|
||||
ledger_core::EntryStatus::Posted => "posted",
|
||||
}
|
||||
.to_string(),
|
||||
project_amount: money(amount),
|
||||
total: ReceiptPolicy::entry_debits(e).ok().map(money),
|
||||
reversal_of: e.reversal_of(),
|
||||
receipts,
|
||||
});
|
||||
}
|
||||
let documents = store
|
||||
.attachments_for(ledger_store::Subject::Project(u64::from(id)))
|
||||
.map_err(|e| e.to_string())?
|
||||
.into_iter()
|
||||
.map(|a| receipt_out(&store, a))
|
||||
.collect::<Result<_, _>>()?;
|
||||
let decisions = ledger
|
||||
.conventions()
|
||||
.decisions()
|
||||
.filter(|d| d.new.projects.contains(&id))
|
||||
.map(|d| super::kept::decision_out(ledger, d))
|
||||
.collect();
|
||||
Ok(Json(ProjectDetailOut {
|
||||
project: project_out(ledger, id),
|
||||
period: ProjectPeriodOut {
|
||||
start: start.to_string(),
|
||||
end: end.to_string(),
|
||||
revenue: money(fig.period.total_revenue),
|
||||
expenses: money(fig.period.total_expenses),
|
||||
net: money(fig.period.net_income()),
|
||||
by_account,
|
||||
by_month,
|
||||
},
|
||||
to_date: to_date_out(&fig.to_date),
|
||||
entries,
|
||||
documents,
|
||||
decisions,
|
||||
}))
|
||||
}
|
||||
}
|
||||
+274
-10
@@ -334,7 +334,7 @@ pub struct PayoutLineParam {
|
||||
pub account: String,
|
||||
/// Positive decimal string.
|
||||
pub amount: String,
|
||||
/// Optional class (name or id), e.g. the app or product line.
|
||||
/// Optional class (a project: name or id; see books.projects).
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
@@ -349,7 +349,7 @@ pub struct PayoutAdjustmentParam {
|
||||
pub side: String,
|
||||
/// Positive decimal string.
|
||||
pub amount: String,
|
||||
/// Optional class (name or id).
|
||||
/// Optional class (a project: name or id; see books.projects).
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
@@ -391,6 +391,10 @@ pub struct RecordPayoutOut {
|
||||
pub deposit: String,
|
||||
/// The entry's lines, e.g. "Debit 1000 1265.01".
|
||||
pub lines: Vec<String>,
|
||||
/// A note for each finished or abandoned project a line was tagged
|
||||
/// to, as books.add_line gives; left out when none.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
pub notes: Vec<String>,
|
||||
/// What to do next.
|
||||
pub next: String,
|
||||
}
|
||||
@@ -707,13 +711,15 @@ pub struct ShowParams {
|
||||
pub asset: Option<String>,
|
||||
/// A party, by id or name (as `books.party`).
|
||||
pub party: Option<String>,
|
||||
/// A project, by id or name (as `books.project`).
|
||||
pub project: Option<String>,
|
||||
/// A reconciliation (id from `books.reconciliations`): its account's
|
||||
/// page, with the reconciliation and its statement.
|
||||
pub reconciliation: Option<u64>,
|
||||
/// Search the transactions list (description, account, vendor, amount).
|
||||
pub search: Option<String>,
|
||||
/// A page: overview, cash, profit, transactions, reports, accounts,
|
||||
/// assets, parties, documents, company, journal.
|
||||
/// assets, parties, projects, documents, company, journal.
|
||||
pub page: Option<String>,
|
||||
/// For an account or a report over a period: its first day (see
|
||||
/// [`show_start_doc`]).
|
||||
@@ -1032,7 +1038,8 @@ pub struct ProfitAndLossParams {
|
||||
/// Period end ([`END_DOC`]).
|
||||
#[schemars(description = END_DOC)]
|
||||
pub end: Option<String>,
|
||||
/// Optional class name or id to filter to one division.
|
||||
/// Optional class (a project: name or id; see books.projects) to
|
||||
/// filter to.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub class: Option<String>,
|
||||
}
|
||||
@@ -1511,8 +1518,9 @@ pub struct AddLineParams {
|
||||
/// and foreign_amount. The base amount is foreign_amount * fx_rate.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub fx_rate: Option<String>,
|
||||
/// Optional class name or id to tag this line for divisional tracking.
|
||||
/// Applied after the line is added; only valid on draft entries.
|
||||
/// Optional class (a project: name or id, see books.projects) to tag
|
||||
/// this line with. Applied after the line is added; only valid on
|
||||
/// draft entries.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub class: Option<String>,
|
||||
}
|
||||
@@ -1522,6 +1530,10 @@ pub struct AddLineParams {
|
||||
pub struct AddLineOut {
|
||||
/// True when the line was added.
|
||||
pub ok: bool,
|
||||
/// Something to know about the line: it is tagged to a finished or
|
||||
/// abandoned project.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
/// One line of a posting preview.
|
||||
@@ -3303,6 +3315,256 @@ pub struct ListClassesOut {
|
||||
pub classes: Vec<ClassOut>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Projects (a class with its facts, rules.md B-37)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// A `project` reference is a class's id or its name (ignoring capitals),
|
||||
// as `books.add_line`'s class takes it.
|
||||
|
||||
/// Parameters for `books.add_project`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct AddProjectParams {
|
||||
/// Its name: one line, unique ignoring capitals, never a full TIN.
|
||||
pub name: String,
|
||||
/// What the project is (its scope of work).
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// active (default), paused, finished, or abandoned.
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
/// internal (the company's own product), client (work for a
|
||||
/// customer), on_spec (work done hoping to be paid), or other.
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
/// Who it is for or with: a party's id or name.
|
||||
#[serde(default)]
|
||||
pub party: Option<String>,
|
||||
/// When it started, YYYY-MM-DD.
|
||||
#[serde(default)]
|
||||
pub started_on: Option<String>,
|
||||
/// When it ended, YYYY-MM-DD (finished or abandoned only).
|
||||
#[serde(default)]
|
||||
pub ended_on: Option<String>,
|
||||
/// Web addresses (its site, store listing, repository): at most 8.
|
||||
#[serde(default)]
|
||||
pub links: Vec<String>,
|
||||
/// Anything else.
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.update_project`. Give at least one field to
|
||||
/// change; "" clears description, kind, party, started_on, ended_on or
|
||||
/// notes.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct UpdateProjectParams {
|
||||
/// The project: id or name.
|
||||
pub project: String,
|
||||
/// A new name (not blank, not another project's).
|
||||
#[serde(default)]
|
||||
pub name: Option<String>,
|
||||
/// What the project is; "" clears it.
|
||||
#[serde(default)]
|
||||
pub description: Option<String>,
|
||||
/// active, paused, finished, or abandoned.
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
/// internal, client, on_spec, or other; "" clears it (not recorded).
|
||||
#[serde(default)]
|
||||
pub kind: Option<String>,
|
||||
/// Who it is for or with (party id or name); "" clears it.
|
||||
#[serde(default)]
|
||||
pub party: Option<String>,
|
||||
/// When it started, YYYY-MM-DD; "" clears it.
|
||||
#[serde(default)]
|
||||
pub started_on: Option<String>,
|
||||
/// When it ended, YYYY-MM-DD (finished or abandoned only); "" clears it.
|
||||
#[serde(default)]
|
||||
pub ended_on: Option<String>,
|
||||
/// Replaces its links; [] clears them.
|
||||
#[serde(default)]
|
||||
pub links: Option<Vec<String>>,
|
||||
/// Notes; "" clears them.
|
||||
#[serde(default)]
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// A party named by a project.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct PartyRefOut {
|
||||
/// Party id.
|
||||
pub party_id: u64,
|
||||
/// Its name as shown.
|
||||
pub label: String,
|
||||
}
|
||||
|
||||
/// A project: a class with its facts.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ProjectOut {
|
||||
/// Its id (the class id: books.add_line's class takes it).
|
||||
pub id: u32,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
/// active, paused, finished, or abandoned.
|
||||
pub status: String,
|
||||
/// internal, client, on_spec, or other; null: not recorded.
|
||||
pub kind: Option<String>,
|
||||
/// What it is.
|
||||
pub description: Option<String>,
|
||||
/// Who it is for or with.
|
||||
pub party: Option<PartyRefOut>,
|
||||
/// When it started, YYYY-MM-DD.
|
||||
pub started_on: Option<String>,
|
||||
/// When it ended, YYYY-MM-DD.
|
||||
pub ended_on: Option<String>,
|
||||
/// Its web addresses (never opened by the books).
|
||||
pub links: Vec<String>,
|
||||
/// Notes.
|
||||
pub notes: Option<String>,
|
||||
}
|
||||
|
||||
/// A project's figures to date: every posted revenue and expense line
|
||||
/// tagged to it through `through`, closing entries left out, whatever its
|
||||
/// dates.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ToDateOut {
|
||||
/// The last day counted, YYYY-MM-DD.
|
||||
pub through: String,
|
||||
/// Tagged revenue, decimal string.
|
||||
pub revenue: String,
|
||||
/// Tagged expenses, decimal string.
|
||||
pub expenses: String,
|
||||
/// Revenue less expenses, decimal string.
|
||||
pub net: String,
|
||||
/// On spec only: expenses less revenue, never below zero (what it
|
||||
/// would take to be repaid in full; a figure, never a receivable).
|
||||
/// Null for any other kind.
|
||||
pub unrecovered_cost: Option<String>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.projects`.
|
||||
#[derive(Debug, Clone, Default, Deserialize, JsonSchema)]
|
||||
pub struct ProjectsParams {
|
||||
/// Only projects with this status: active, paused, finished, abandoned.
|
||||
#[serde(default)]
|
||||
pub status: Option<String>,
|
||||
}
|
||||
|
||||
/// One project in `books.projects`.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ProjectRowOut {
|
||||
/// Its facts.
|
||||
#[serde(flatten)]
|
||||
pub project: ProjectOut,
|
||||
/// Its figures to date.
|
||||
pub to_date: ToDateOut,
|
||||
}
|
||||
|
||||
/// Result of `books.projects`.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ProjectsOut {
|
||||
/// The day the figures run through (today in the company's time zone).
|
||||
pub through: String,
|
||||
/// The projects, by name (ignoring capitals).
|
||||
pub projects: Vec<ProjectRowOut>,
|
||||
}
|
||||
|
||||
/// Parameters for `books.project`.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct ProjectParams {
|
||||
/// The project: id or name.
|
||||
pub project: String,
|
||||
/// Period start ([`START_DOC`]).
|
||||
#[schemars(description = START_DOC)]
|
||||
pub start: Option<String>,
|
||||
/// Period end ([`END_DOC`]).
|
||||
#[schemars(description = END_DOC)]
|
||||
pub end: Option<String>,
|
||||
}
|
||||
|
||||
/// One account's tagged amount in a project's period.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ProjectAccountOut {
|
||||
/// Account code.
|
||||
pub code: String,
|
||||
/// Account name.
|
||||
pub name: String,
|
||||
/// "revenue" or "expense".
|
||||
#[serde(rename = "type")]
|
||||
pub account_type: String,
|
||||
/// On the account's normal side, decimal string.
|
||||
pub amount: String,
|
||||
}
|
||||
|
||||
/// One month of a project's period.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ProjectMonthOut {
|
||||
/// YYYY-MM.
|
||||
pub month: String,
|
||||
/// Its first day in the period, YYYY-MM-DD.
|
||||
pub start: String,
|
||||
/// Its last day in the period, YYYY-MM-DD.
|
||||
pub end: String,
|
||||
/// Tagged revenue, decimal string.
|
||||
pub revenue: String,
|
||||
/// Tagged expenses, decimal string.
|
||||
pub expenses: String,
|
||||
/// Revenue less expenses, decimal string.
|
||||
pub net: String,
|
||||
}
|
||||
|
||||
/// A project's figures for the period asked.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ProjectPeriodOut {
|
||||
/// First day, YYYY-MM-DD.
|
||||
pub start: String,
|
||||
/// Last day, YYYY-MM-DD.
|
||||
pub end: String,
|
||||
/// Tagged revenue, decimal string.
|
||||
pub revenue: String,
|
||||
/// Tagged expenses, decimal string.
|
||||
pub expenses: String,
|
||||
/// Revenue less expenses, decimal string.
|
||||
pub net: String,
|
||||
/// Revenue accounts, then expense accounts, by code.
|
||||
pub by_account: Vec<ProjectAccountOut>,
|
||||
/// By calendar month (the period's last 240 months at most).
|
||||
pub by_month: Vec<ProjectMonthOut>,
|
||||
}
|
||||
|
||||
/// An entry with a line tagged to a project.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ProjectEntryOut {
|
||||
/// Entry id.
|
||||
pub entry_id: u64,
|
||||
/// Entry date, YYYY-MM-DD.
|
||||
pub date: String,
|
||||
/// Description.
|
||||
pub description: String,
|
||||
/// "draft" or "posted".
|
||||
pub status: String,
|
||||
/// Its tagged revenue less its tagged expenses, decimal string (a
|
||||
/// tagged balance-sheet line counts zero).
|
||||
pub project_amount: String,
|
||||
/// The entry's total debits, decimal string.
|
||||
pub total: Option<String>,
|
||||
/// Id of the reversed entry, for reversals.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reversal_of: Option<u64>,
|
||||
/// Its receipts (attachment ids: filed on it or linked to it).
|
||||
pub receipts: Vec<u64>,
|
||||
}
|
||||
|
||||
/// A project named by a decision, a party or a list.
|
||||
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||
pub struct ProjectRefOut {
|
||||
/// Project (class) id.
|
||||
pub id: u32,
|
||||
/// Its name.
|
||||
pub name: String,
|
||||
}
|
||||
|
||||
/// One class's P&L for `books.profit_and_loss_by_class`.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||
pub struct ClassPnlOut {
|
||||
@@ -4008,6 +4270,8 @@ pub struct PartyDetailOut {
|
||||
pub bills: Vec<PartyDocumentOut>,
|
||||
/// Payroll lines naming it, by run.
|
||||
pub payroll: Vec<PartyPayrollOut>,
|
||||
/// The projects for or with it, by name.
|
||||
pub projects: Vec<ProjectRefOut>,
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -4027,12 +4291,12 @@ pub enum RefId {
|
||||
/// What an attachment is linked to.
|
||||
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||
pub struct SubjectParam {
|
||||
/// entry, account, asset, party, invoice, bill, period, payroll_run,
|
||||
/// schedule, reconciliation, or company.
|
||||
/// entry, account, asset, party, project, invoice, bill, period,
|
||||
/// payroll_run, schedule, reconciliation, or company.
|
||||
pub kind: String,
|
||||
/// Which one: an entry, invoice, bill, period, payroll run, schedule,
|
||||
/// or reconciliation id; an account's code (or id); an asset's tag (or
|
||||
/// id); a party's id or name. Omitted for the company.
|
||||
/// id); a party's or a project's id or name. Omitted for the company.
|
||||
pub id: Option<RefId>,
|
||||
}
|
||||
|
||||
@@ -4210,7 +4474,7 @@ pub struct ReconciliationsOut {
|
||||
/// Parameters for `books.handbook`.
|
||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||
pub struct HandbookParams {
|
||||
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
|
||||
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
|
||||
#[serde(default)]
|
||||
pub section: Option<String>,
|
||||
}
|
||||
|
||||
@@ -1366,6 +1366,7 @@ fn reads_of_old_books() -> Vec<(&'static str, Value)> {
|
||||
("books.list_receipts", json!({})),
|
||||
("books.list_vendors", json!({})),
|
||||
("books.list_classes", json!({})),
|
||||
("books.projects", json!({})),
|
||||
("books.parties", json!({})),
|
||||
("books.statements", json!({})),
|
||||
("books.reconciliations", json!({})),
|
||||
@@ -1715,3 +1716,20 @@ fn hostile_renames_ranges_and_categories_are_answered() {
|
||||
);
|
||||
reopens(m, &p, "P3's hostile inputs");
|
||||
}
|
||||
|
||||
/// books.project over the whole calendar answers, its months the last 240
|
||||
/// (each month is a statement: a hundred thousand of them would not).
|
||||
#[test]
|
||||
fn a_project_over_the_whole_calendar_answers_quickly() {
|
||||
let p = places("project-wide");
|
||||
let mut m = Mcp::start(&p);
|
||||
fixture(&mut m, &p);
|
||||
m.ok("books.add_project", json!({"name": "Harbor"}));
|
||||
let t = std::time::Instant::now();
|
||||
let p = m.ok(
|
||||
"books.project",
|
||||
json!({"project": "Harbor", "start": "0001-01-01", "end": "9999-12-31"}),
|
||||
);
|
||||
assert_eq!(p["period"]["by_month"].as_array().unwrap().len(), 240);
|
||||
assert!(t.elapsed() < std::time::Duration::from_secs(30));
|
||||
}
|
||||
@@ -178,7 +178,7 @@ fn mcp_end_to_end_over_stdio() {
|
||||
// Tool listing: the narrow tools; chart is read-only.
|
||||
let list = mcp.request("tools/list", json!({}));
|
||||
let tools = list["result"]["tools"].as_array().unwrap();
|
||||
assert_eq!(tools.len(), 112, "expected 112 tools, got {}", tools.len());
|
||||
assert_eq!(tools.len(), 116, "expected 116 tools, got {}", tools.len());
|
||||
// Files come in as tool input: books.begin_upload, never a local path.
|
||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||
|
||||
@@ -3259,6 +3259,16 @@ fn references_resolve_unless_truly_ambiguous() {
|
||||
let dir = test_dir("refs");
|
||||
let s = Store::create(&data_dir(&dir), Currency::USD).unwrap();
|
||||
drop(s);
|
||||
// Classes as an earlier version could write them (a name of digits
|
||||
// only can't be added now): 1 "placeholder-name", 2 named "1", 3
|
||||
// named "3".
|
||||
let mut w = ledger_store::JournalWriter::open(&data_dir(&dir).join(ledger_store::JOURNAL_FILE))
|
||||
.unwrap();
|
||||
for (i, n) in ["placeholder-name", "1", "3"].iter().enumerate() {
|
||||
w.append("class.added", json!({"id": i + 1, "name": n}))
|
||||
.unwrap();
|
||||
}
|
||||
drop(w);
|
||||
let mut m = Mcp::start(&dir, "1000000.00");
|
||||
// An account whose code is its own id.
|
||||
let first = m.ok(
|
||||
@@ -3286,24 +3296,16 @@ fn references_resolve_unless_truly_ambiguous() {
|
||||
json!({"entry_id": e, "account": first.to_string(), "side": "debit", "amount": "5.00"}),
|
||||
);
|
||||
|
||||
// A class named by its own id.
|
||||
let c = m.ok("books.add_class", json!({"name": "placeholder-name"}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let c2 = m.ok("books.add_class", json!({"name": c.to_string()}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
// Class 2 is named "1", class 1's id.
|
||||
let c = 1u64;
|
||||
// "c" is class c's id and class c2's name: ambiguous.
|
||||
m.refuse(
|
||||
"books.add_line",
|
||||
json!({"entry_id": e, "account": "4000", "side": "credit", "amount": "5.00",
|
||||
"class": c.to_string()}),
|
||||
);
|
||||
// c2 named by its own id: only one class is meant.
|
||||
let c3 = m.ok("books.add_class", json!({"name": (c2 + 1).to_string()}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_eq!(c3, c2 + 1, "classes number in order");
|
||||
// c3 named by its own id: only one class is meant.
|
||||
let c3 = 3u64;
|
||||
m.ok(
|
||||
"books.add_line",
|
||||
json!({"entry_id": e, "account": "4000", "side": "credit", "amount": "5.00",
|
||||
|
||||
@@ -775,6 +775,16 @@ fn happy_path(name: &str) -> Mcp {
|
||||
// Classes, parties, 1099.
|
||||
m.ok("books.add_class", json!({"name": "Rondo"}));
|
||||
m.ok("books.list_classes", json!({}));
|
||||
m.ok(
|
||||
"books.add_project",
|
||||
json!({"name": "Harbor app", "kind": "on_spec", "started_on": "2026-01-05"}),
|
||||
);
|
||||
m.ok(
|
||||
"books.update_project",
|
||||
json!({"project": "Rondo", "kind": "internal", "description": "The Rondo app"}),
|
||||
);
|
||||
m.ok("books.projects", json!({}));
|
||||
m.ok("books.project", json!({"project": "Rondo"}));
|
||||
let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -2194,15 +2204,12 @@ fn inv6_ambiguous_vendor_reference_refused() {
|
||||
fn inv6_ambiguous_class_reference_refused() {
|
||||
let dir = test_dir("inv6-class");
|
||||
let toml = books_toml(&dir, "1000000.00", "");
|
||||
old_classes(&dir, &["Alpha", "1"]);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(BASE_CHART);
|
||||
let alpha = m.ok("books.add_class", json!({"name": "Alpha"}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
let named = m.ok("books.add_class", json!({"name": alpha.to_string()}))["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
assert_ne!(alpha, named);
|
||||
let (alpha, named) = (1u64, 2u64);
|
||||
// No new name of digits only.
|
||||
m.refuse("books.add_class", json!({"name": "3"}));
|
||||
let e = m.draft("2026-06-01", "classy", &[]);
|
||||
// add_line with an ambiguous class is refused too, but writes the
|
||||
// line first: see add_line_bad_class_leaves_journal_unchanged.
|
||||
@@ -2220,6 +2227,19 @@ fn inv6_ambiguous_class_reference_refused() {
|
||||
m.ok("books.profit_and_loss", json!({"class": "Alpha"}));
|
||||
}
|
||||
|
||||
/// Books an earlier version wrote holding these classes, in id order: a
|
||||
/// name of digits only can't be added now (it would read as an id), so
|
||||
/// only old books hold the id/name pairs invariant 6 refuses.
|
||||
fn old_classes(dir: &Path, names: &[&str]) {
|
||||
let books = dir.join("books-data");
|
||||
drop(ledger_store::Store::create(&books, ledger_core::Currency::USD).unwrap());
|
||||
let mut w = ledger_store::JournalWriter::open(&books.join(ledger_store::JOURNAL_FILE)).unwrap();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
w.append("class.added", json!({"id": i + 1, "name": n}))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
/// Invariant 2: a refused add_line leaves the journal unchanged, here when
|
||||
/// the line's class doesn't resolve (unknown, or ambiguous per invariant
|
||||
/// 6).
|
||||
@@ -2227,10 +2247,9 @@ fn inv6_ambiguous_class_reference_refused() {
|
||||
fn add_line_bad_class_leaves_journal_unchanged() {
|
||||
let dir = test_dir("bug-class");
|
||||
let toml = books_toml(&dir, "25.00", "");
|
||||
old_classes(&dir, &["Alpha", "1"]);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(BASE_CHART);
|
||||
m.ok("books.add_class", json!({"name": "Alpha"}));
|
||||
m.ok("books.add_class", json!({"name": "1"}));
|
||||
let e = m.draft("2026-06-01", "classy", &[]);
|
||||
for class in ["Nope", "99", "1"] {
|
||||
// Mcp::tool asserts the journal is unchanged on refusal.
|
||||
@@ -2251,10 +2270,9 @@ fn add_line_bad_class_leaves_journal_unchanged() {
|
||||
fn profit_and_loss_by_class_validates_class() {
|
||||
let dir = test_dir("bug-pnl-class");
|
||||
let toml = books_toml(&dir, "1000000.00", "");
|
||||
old_classes(&dir, &["Alpha", "1"]);
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(BASE_CHART);
|
||||
m.ok("books.add_class", json!({"name": "Alpha"}));
|
||||
m.ok("books.add_class", json!({"name": "1"}));
|
||||
m.refuse("books.profit_and_loss_by_class", json!({"class": "Nope"}));
|
||||
m.refuse("books.profit_and_loss_by_class", json!({"class": "1"}));
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -2071,6 +2071,7 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
("6300", "Insurance", "expense"),
|
||||
]);
|
||||
m.add_party("Dana Employee", &["employee"]);
|
||||
m.ok("books.add_project", json!({"name": "Harbor"}));
|
||||
let entry = m.post(
|
||||
"2026-02-01",
|
||||
json!({}),
|
||||
@@ -2193,6 +2194,10 @@ fn idempotent_tools_change_nothing_when_repeated() {
|
||||
"books.set_fx_rate",
|
||||
json!({"currency": "EUR", "date": "2026-01-02", "rate": "1.10"}),
|
||||
),
|
||||
(
|
||||
"books.update_project",
|
||||
json!({"project": "Harbor", "kind": "client", "status": "paused"}),
|
||||
),
|
||||
];
|
||||
let list = m.rpc("tools/list", json!({}));
|
||||
let marked: std::collections::BTreeSet<String> = list["result"]["tools"]
|
||||
|
||||
@@ -59,6 +59,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
TAX_CATEGORIES_GAIN_EXEMPT,
|
||||
DISPOSAL_BEFORE_SERVICE,
|
||||
DEPRECIATION_CATCH_UP,
|
||||
PROJECTS,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -147,6 +148,13 @@ pub const SCHEDULE_CONVENTION: &str = "schedule_convention";
|
||||
/// with the value at contribution and the §704(c) method (P3, defect 13).
|
||||
pub const ASSET_BASIS: &str = "asset_basis";
|
||||
|
||||
/// Feature: projects (rules.md B-37): `project.set`, a new event type (a
|
||||
/// class's project facts); `attachment.linked` to the subject kind
|
||||
/// `project` (an older version would call it corrupt); and
|
||||
/// `decision.recorded` with `projects` (an older version would drop them
|
||||
/// silently).
|
||||
pub const PROJECTS: &str = "projects";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -209,6 +217,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"payroll.taxes_remitted",
|
||||
"period.closed",
|
||||
"period.created",
|
||||
"project.set",
|
||||
"receipt.attached",
|
||||
"receipt.linked",
|
||||
"receipt.unlinked",
|
||||
|
||||
@@ -17,6 +17,7 @@ use crate::error::StoreError;
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use serde_json::{Map, Value, json};
|
||||
|
||||
/// The journal format these events need: an older reader stops at the
|
||||
@@ -170,7 +171,9 @@ pub fn convention_retired_of(p: &Value) -> Result<(String, String, Source), Stor
|
||||
}
|
||||
|
||||
/// `decision.recorded {id, subject, decision_md, decided_by, decided_on,
|
||||
/// evidence_attachment?, supersedes?, citation?}`.
|
||||
/// evidence_attachment?, supersedes?, citation?, projects?}`: `projects`
|
||||
/// only when it names any (journal feature `projects`), so a decision
|
||||
/// without them writes the bytes it always did.
|
||||
pub fn decision_recorded(id: u64, d: &NewDecision) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(id));
|
||||
@@ -181,6 +184,9 @@ pub fn decision_recorded(id: u64, d: &NewDecision) -> Value {
|
||||
put(&mut o, "evidence_attachment", d.evidence_attachment);
|
||||
put(&mut o, "supersedes", d.supersedes);
|
||||
put(&mut o, "citation", d.citation.clone());
|
||||
if !d.projects.is_empty() {
|
||||
o.insert("projects".into(), json!(d.projects));
|
||||
}
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
@@ -196,6 +202,17 @@ pub fn decision_recorded_of(p: &Value) -> Result<(u64, NewDecision), StoreError>
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.ok_or_else(|| corrupt(E, "decided_by"))?;
|
||||
let projects = match p.get("projects") {
|
||||
None | Some(Value::Null) => vec![],
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| {
|
||||
a.iter()
|
||||
.map(|x| x.as_u64().and_then(|n| ClassId::try_from(n).ok()))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.ok_or_else(|| corrupt(E, "projects"))?,
|
||||
};
|
||||
Ok((
|
||||
u64_of(p, E, "id")?,
|
||||
NewDecision {
|
||||
@@ -206,6 +223,74 @@ pub fn decision_recorded_of(p: &Value) -> Result<(u64, NewDecision), StoreError>
|
||||
evidence_attachment: opt_u64(p, E, "evidence_attachment")?,
|
||||
supersedes: opt_u64(p, E, "supersedes")?,
|
||||
citation: opt_str(p, E, "citation")?,
|
||||
projects,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
/// `project.set {id, name, status, kind?, description?, party_id?,
|
||||
/// started_on?, ended_on?, links?, notes?}`: a class's whole project
|
||||
/// record (rules.md B-37; journal feature `projects`). Every optional key
|
||||
/// is left out when empty, so the payload is canonical.
|
||||
pub fn project_set(id: ClassId, name: &str, f: &ProjectFacts) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(id));
|
||||
o.insert("name".into(), json!(name));
|
||||
o.insert("status".into(), json!(f.status.key()));
|
||||
put(&mut o, "kind", f.kind.map(ProjectKind::key));
|
||||
put(&mut o, "description", f.description.clone());
|
||||
put(&mut o, "party_id", f.party);
|
||||
put(&mut o, "started_on", f.started_on.map(|d| d.to_string()));
|
||||
put(&mut o, "ended_on", f.ended_on.map(|d| d.to_string()));
|
||||
if !f.links.is_empty() {
|
||||
o.insert("links".into(), json!(f.links));
|
||||
}
|
||||
put(&mut o, "notes", f.notes.clone());
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read a `project.set`: `(id, name, facts)`. A missing or mistyped key is
|
||||
/// corrupt; a status or kind this version doesn't know is newer (a newer
|
||||
/// version wrote it, rules.md B-27).
|
||||
pub fn project_set_of(
|
||||
line: &crate::journal::JournalLine,
|
||||
) -> Result<(ClassId, String, ProjectFacts), StoreError> {
|
||||
const E: &str = "project.set";
|
||||
let p = &line.payload;
|
||||
let id = ClassId::try_from(u64_of(p, E, "id")?).map_err(|_| corrupt(E, "id"))?;
|
||||
let name = str_of(p, E, "name")?;
|
||||
let key = str_of(p, E, "status")?;
|
||||
let status = ProjectStatus::parse(&key)
|
||||
.ok_or_else(|| crate::format::newer_content(line, format!("the project status {key:?}")))?;
|
||||
let kind = match opt_str(p, E, "kind")? {
|
||||
None => None,
|
||||
Some(k) => Some(ProjectKind::parse(&k).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the project kind {k:?}"))
|
||||
})?),
|
||||
};
|
||||
let links = match p.get("links") {
|
||||
None | Some(Value::Null) => vec![],
|
||||
Some(v) => v
|
||||
.as_array()
|
||||
.and_then(|a| {
|
||||
a.iter()
|
||||
.map(|x| x.as_str().map(str::to_string))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.ok_or_else(|| corrupt(E, "links"))?,
|
||||
};
|
||||
Ok((
|
||||
id,
|
||||
name,
|
||||
ProjectFacts {
|
||||
description: opt_str(p, E, "description")?,
|
||||
status,
|
||||
kind,
|
||||
party: opt_u64(p, E, "party_id")?,
|
||||
started_on: opt_date(p, E, "started_on")?,
|
||||
ended_on: opt_date(p, E, "ended_on")?,
|
||||
links,
|
||||
notes: opt_str(p, E, "notes")?,
|
||||
},
|
||||
))
|
||||
}
|
||||
@@ -328,6 +413,7 @@ mod tests {
|
||||
evidence_attachment: Some(7),
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
};
|
||||
assert_eq!(
|
||||
decision_recorded_of(&decision_recorded(3, &d)).unwrap(),
|
||||
|
||||
@@ -63,7 +63,7 @@ pub fn report_columns(
|
||||
#[serde(tag = "show", rename_all = "snake_case")]
|
||||
pub enum ViewerTarget {
|
||||
/// A top-level page: overview, cash, profit, transactions, reports,
|
||||
/// accounts, assets, parties, documents, company, journal.
|
||||
/// accounts, assets, parties, projects, documents, company, journal.
|
||||
Page {
|
||||
/// Page name.
|
||||
page: String,
|
||||
@@ -120,6 +120,12 @@ pub enum ViewerTarget {
|
||||
/// Party id.
|
||||
party_id: u64,
|
||||
},
|
||||
/// One project (a class with its facts): its figures, entries,
|
||||
/// documents and decisions.
|
||||
Project {
|
||||
/// Class id.
|
||||
class_id: u32,
|
||||
},
|
||||
/// One reconciliation: its account, with the account's statements and
|
||||
/// reconciliations.
|
||||
Reconciliation {
|
||||
|
||||
@@ -950,6 +950,9 @@ impl Store {
|
||||
return Err(StoreError::AttachmentWithdrawn(a));
|
||||
}
|
||||
self.ensure_format()?;
|
||||
if !d.projects.is_empty() {
|
||||
self.ensure_feature(crate::format::PROJECTS)?;
|
||||
}
|
||||
let line =
|
||||
self.append_journal("decision.recorded", crate::meta::decision_recorded(id, &d))?;
|
||||
self.ledger.apply_decision(id, d, line.seq)?;
|
||||
@@ -3365,15 +3368,19 @@ impl Store {
|
||||
// Classes
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// Add a class (division) for line-level P&L tracking. Journaled as
|
||||
/// `class.added`.
|
||||
/// Add a class (division) for line-level P&L tracking: a project with
|
||||
/// only its name. Journaled as `class.added`.
|
||||
pub fn add_class(&mut self, name: &str) -> Result<ClassId, StoreError> {
|
||||
// Rehearsed on a copy: the name rules (blank, length, a name
|
||||
// another class has) refuse here, so the journal write can't be
|
||||
// followed by a failed mutation.
|
||||
self.rehearse(|l| l.add_class(name))?;
|
||||
// The engine's only failure mode is an empty name; replicate it so
|
||||
// the journal write cannot be followed by a failed mutation.
|
||||
if name.trim().is_empty() {
|
||||
return Err(LedgerError::EmptyClassName.into());
|
||||
}
|
||||
self.append_class(name)
|
||||
}
|
||||
|
||||
/// `class.added` for a name already rehearsed.
|
||||
fn append_class(&mut self, name: &str) -> Result<ClassId, StoreError> {
|
||||
let name = name.trim();
|
||||
let id = self.ledger.next_class_id();
|
||||
let line = self.append_journal("class.added", json!({ "id": id, "name": name }))?;
|
||||
let live = self.ledger.add_class(name)?;
|
||||
@@ -3382,6 +3389,83 @@ impl Store {
|
||||
Ok(live)
|
||||
}
|
||||
|
||||
/// Add a project (rules.md B-37): `class.added`, then `project.set`
|
||||
/// when `facts` say more than the defaults. Returns its id.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: a project in one command, journaled as two events only when
|
||||
/// it has facts, so a name-only project is a plain class.
|
||||
/// SOURCE: docs/spec/rules.md B-37 item 1; B-27 (journal feature
|
||||
/// `projects`).
|
||||
/// INVARIANTS:
|
||||
/// 1. A refusal journals nothing: the whole record is rehearsed, and
|
||||
/// the feature asked for, before the first line.
|
||||
/// 2. Facts equal to the defaults journal `class.added` alone (books
|
||||
/// waiting for their format upgrade can still add one).
|
||||
/// ```
|
||||
pub fn add_project(
|
||||
&mut self,
|
||||
name: &str,
|
||||
facts: &ledger_core::ProjectFacts,
|
||||
) -> Result<ClassId, StoreError> {
|
||||
self.rehearse(|l| {
|
||||
let id = l.add_class(name)?;
|
||||
if !facts.is_default() {
|
||||
l.set_project(id, name, facts.clone())?;
|
||||
}
|
||||
Ok(())
|
||||
})?;
|
||||
let with_facts = !facts.is_default();
|
||||
if with_facts {
|
||||
self.ensure_feature(crate::format::PROJECTS)?;
|
||||
}
|
||||
let id = self.append_class(name)?;
|
||||
if with_facts {
|
||||
let (name, facts) = self.ledger.check_set_project(id, name, facts)?;
|
||||
self.append_project(id, name, facts)?;
|
||||
}
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
/// Set a project's whole record (`project.set`): its name and facts.
|
||||
/// Unchanged (the same name and facts once checked): journals nothing
|
||||
/// and returns false.
|
||||
pub fn set_project(
|
||||
&mut self,
|
||||
id: ClassId,
|
||||
name: &str,
|
||||
facts: &ledger_core::ProjectFacts,
|
||||
) -> Result<bool, StoreError> {
|
||||
let (name, facts) = self.ledger.check_set_project(id, name, facts)?;
|
||||
let same_name = self.ledger.get_class(id).is_some_and(|c| c.name == name);
|
||||
if same_name && self.ledger.project_facts(id).as_ref() == Some(&facts) {
|
||||
return Ok(false);
|
||||
}
|
||||
self.ensure_feature(crate::format::PROJECTS)?;
|
||||
self.append_project(id, name, facts)?;
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
/// `project.set` for a record already checked.
|
||||
fn append_project(
|
||||
&mut self,
|
||||
id: ClassId,
|
||||
name: String,
|
||||
facts: ledger_core::ProjectFacts,
|
||||
) -> Result<(), StoreError> {
|
||||
let line =
|
||||
self.append_journal("project.set", crate::meta::project_set(id, &name, &facts))?;
|
||||
self.ledger.set_project(id, &name, facts)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// A class's project facts (the defaults for a class with none);
|
||||
/// `None` for no such class.
|
||||
pub fn project_facts(&self, id: ClassId) -> Option<ledger_core::ProjectFacts> {
|
||||
self.ledger.project_facts(id)
|
||||
}
|
||||
|
||||
/// All classes, in id order.
|
||||
pub fn list_classes(&self) -> Vec<Class> {
|
||||
self.ledger.list_classes().into_iter().cloned().collect()
|
||||
@@ -4157,6 +4241,17 @@ impl Store {
|
||||
// Attachment links, statements, reconciliations
|
||||
// ------------------------------------------------------------------
|
||||
|
||||
/// The journal feature a link to `subject` needs, if any: a subject
|
||||
/// kind an older version doesn't know (a project, rules.md B-37).
|
||||
/// Callers filing a file with links ask it before filing, so a refusal
|
||||
/// leaves nothing half-filed.
|
||||
pub fn subject_feature(subject: Subject) -> Option<&'static str> {
|
||||
match subject {
|
||||
Subject::Project(_) => Some(crate::format::PROJECTS),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether `subject` exists in the books (what [`Store::link_attachment`]
|
||||
/// checks, so callers can validate several subjects before linking any).
|
||||
pub fn subject_exists(&self, subject: Subject) -> Result<bool, StoreError> {
|
||||
@@ -4166,6 +4261,7 @@ impl Store {
|
||||
Subject::Account(i) => l.chart().get(i).is_some(),
|
||||
Subject::Asset(i) => l.get_asset(i).is_some(),
|
||||
Subject::Party(i) => l.get_party(i).is_some(),
|
||||
Subject::Project(i) => u32::try_from(i).ok().and_then(|c| l.get_class(c)).is_some(),
|
||||
Subject::Invoice(i) => l.get_invoice(i).is_some(),
|
||||
Subject::Bill(i) => l.get_bill(i).is_some(),
|
||||
Subject::Period(i) => l.list_periods().iter().any(|p| p.id == i),
|
||||
@@ -4227,8 +4323,8 @@ impl Store {
|
||||
}
|
||||
|
||||
/// Link a stored attachment to a subject: another entry, an account, an
|
||||
/// asset, a party, an invoice, a bill, a period, a payroll run, a
|
||||
/// schedule, a reconciliation, or the company.
|
||||
/// asset, a party, a project, an invoice, a bill, a period, a payroll
|
||||
/// run, a schedule, a reconciliation, or the company.
|
||||
///
|
||||
/// ```text
|
||||
/// GOAL: one file, linked wherever it's evidence, without refiling.
|
||||
@@ -4262,6 +4358,9 @@ impl Store {
|
||||
{
|
||||
return Err(StoreError::AlreadyLinked(attachment_id, subject.describe()));
|
||||
}
|
||||
if let Some(f) = Self::subject_feature(subject) {
|
||||
self.ensure_feature(f)?;
|
||||
}
|
||||
let line = self.append_journal(
|
||||
"attachment.linked",
|
||||
json!({"attachment_id": attachment_id, "subject": subject}),
|
||||
@@ -6105,6 +6204,10 @@ fn replay_line(
|
||||
"class.added" => {
|
||||
ledger.replay_class(get_u64(p, "id")? as u32, get_str(p, "name")?.to_string())?;
|
||||
}
|
||||
"project.set" => {
|
||||
let (id, name, facts) = crate::meta::project_set_of(line)?;
|
||||
ledger.replay_set_project(id, name, facts)?;
|
||||
}
|
||||
"entry.line_class_set" => {
|
||||
// Journaled while the entry was a draft, so at replay time the
|
||||
// draft exists and takes the class. Missing/null class_id
|
||||
@@ -7140,6 +7243,14 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
params![get_u64(p, "id")? as i64, get_str(p, "name")?],
|
||||
)?;
|
||||
}
|
||||
"project.set" => {
|
||||
// Facts live in the ledger (as parties'); the projection keeps
|
||||
// the name, so verify's class check stays exact after a rename.
|
||||
conn.execute(
|
||||
"UPDATE classes SET name = ? WHERE id = ?",
|
||||
params![get_str(p, "name")?, get_u64(p, "id")? as i64],
|
||||
)?;
|
||||
}
|
||||
"entry.line_class_set" => {
|
||||
// Journaled while the entry is a draft, so the projection
|
||||
// usually has no lines row yet (drafts never touch the
|
||||
@@ -7440,6 +7551,9 @@ pub enum Subject {
|
||||
Schedule(u64),
|
||||
/// A reconciliation (its statement).
|
||||
Reconciliation(u64),
|
||||
/// A project (its contract, spec, pitch, correspondence; rules.md
|
||||
/// B-37). Journal feature `projects`.
|
||||
Project(u64),
|
||||
/// The company itself (formation documents).
|
||||
Company,
|
||||
}
|
||||
@@ -7458,6 +7572,7 @@ impl Subject {
|
||||
Subject::PayrollRun(_) => "payroll_run",
|
||||
Subject::Schedule(_) => "schedule",
|
||||
Subject::Reconciliation(_) => "reconciliation",
|
||||
Subject::Project(_) => "project",
|
||||
Subject::Company => "company",
|
||||
}
|
||||
}
|
||||
@@ -7474,7 +7589,8 @@ impl Subject {
|
||||
| Subject::Period(i)
|
||||
| Subject::PayrollRun(i)
|
||||
| Subject::Schedule(i)
|
||||
| Subject::Reconciliation(i) => i,
|
||||
| Subject::Reconciliation(i)
|
||||
| Subject::Project(i) => i,
|
||||
Subject::Company => 0,
|
||||
}
|
||||
}
|
||||
@@ -7492,6 +7608,7 @@ impl Subject {
|
||||
"payroll_run" => Subject::PayrollRun(id),
|
||||
"schedule" => Subject::Schedule(id),
|
||||
"reconciliation" => Subject::Reconciliation(id),
|
||||
"project" => Subject::Project(id),
|
||||
"company" => Subject::Company,
|
||||
_ => return None,
|
||||
})
|
||||
|
||||
@@ -897,7 +897,7 @@ fn read_journal_and_format_gives_every_line_and_the_format() {
|
||||
/// declaration as newer, never at the line, and never calls it corrupt.
|
||||
#[test]
|
||||
fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
for f in ["account_renamed", "require_tax_category"] {
|
||||
for f in ["account_renamed", "require_tax_category", "projects"] {
|
||||
assert!(KNOWN_FEATURES.contains(&f), "{f}");
|
||||
}
|
||||
let dir = test_dir("p3-features");
|
||||
@@ -927,11 +927,20 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
store.change_settings(&[policy()], &source).map(|_| ()),
|
||||
"the journal feature require_tax_category",
|
||||
);
|
||||
let facts = ledger_core::ProjectFacts {
|
||||
kind: Some(ledger_core::ProjectKind::OnSpec),
|
||||
..Default::default()
|
||||
};
|
||||
waits(
|
||||
store.add_project("Harbor", &facts).map(|_| ()),
|
||||
"the journal feature projects",
|
||||
);
|
||||
assert_eq!(store.head(), head, "refused whole");
|
||||
assert!(store.upgrade_format().unwrap().is_some());
|
||||
store.rename_account(1, "Business Checking", None).unwrap();
|
||||
store.rename_account(1, "Checking", None).unwrap();
|
||||
store.change_settings(&[policy()], &source).unwrap();
|
||||
store.add_project("Harbor", &facts).unwrap();
|
||||
assert_eq!(*store.journal_format(), ledger_store::format::current());
|
||||
drop(store);
|
||||
let text = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
|
||||
@@ -942,7 +951,12 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
let kinds: Vec<(&str, serde_json::Value)> = lines
|
||||
.iter()
|
||||
.map(|l| (l["type"].as_str().unwrap(), l["payload"].clone()))
|
||||
.filter(|(t, _)| matches!(*t, FORMAT_SET | "account.renamed" | "settings.set"))
|
||||
.filter(|(t, _)| {
|
||||
matches!(
|
||||
*t,
|
||||
FORMAT_SET | "account.renamed" | "settings.set" | "project.set"
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let current = ledger_store::format::current();
|
||||
assert_eq!(kinds[0].0, FORMAT_SET);
|
||||
@@ -953,7 +967,8 @@ fn p3_features_wait_for_the_upgrade_and_are_declared_once() {
|
||||
assert_eq!(kinds[1].0, "account.renamed");
|
||||
assert_eq!(kinds[2].0, "account.renamed");
|
||||
assert_eq!(kinds[3].0, "settings.set");
|
||||
assert_eq!(kinds.len(), 4);
|
||||
assert_eq!(kinds[4].0, "project.set");
|
||||
assert_eq!(kinds.len(), 5);
|
||||
// An earlier reader knows no feature (P2: KNOWN_FEATURES was empty),
|
||||
// so the gate stops it at the declaration, before the rename: the
|
||||
// gate's refusal of a feature it doesn't know is pinned by
|
||||
|
||||
@@ -0,0 +1,513 @@
|
||||
//! Projects in the journal (rules.md B-37): `class.added`, then the whole
|
||||
//! record as `project.set` (journal feature `projects`); links to a
|
||||
//! project and decisions naming one wait for the feature too.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: a project's record is journaled once, whole, replays exactly,
|
||||
//! and books waiting for their format upgrade can still add
|
||||
//! projects by name but write nothing an older reader would misread.
|
||||
//! SOURCE: docs/spec/rules.md B-37, B-27; D-39 (the owner's upgrade).
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::NewDecision;
|
||||
use ledger_core::{Currency, ProjectFacts, ProjectKind, ProjectStatus};
|
||||
use ledger_store::{
|
||||
JOURNAL_FILE, JournalFormat, JournalWriter, Snapshot, Store, StoreError, Subject,
|
||||
};
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!(
|
||||
"wanderbooks-projects-{name}-{}",
|
||||
std::process::id()
|
||||
));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn facts() -> ProjectFacts {
|
||||
ProjectFacts {
|
||||
description: Some("An app for the harbor".into()),
|
||||
kind: Some(ProjectKind::OnSpec),
|
||||
started_on: Some(d(2026, 8, 1)),
|
||||
links: vec!["https://harbor.example/app".into()],
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every journal line, as (type, payload).
|
||||
fn lines(dir: &Path) -> Vec<(String, Value)> {
|
||||
std::fs::read_to_string(dir.join(JOURNAL_FILE))
|
||||
.unwrap()
|
||||
.lines()
|
||||
.map(|l| {
|
||||
let v: Value = serde_json::from_str(l).unwrap();
|
||||
(
|
||||
v["type"].as_str().unwrap().to_string(),
|
||||
v["payload"].clone(),
|
||||
)
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn decision(projects: Vec<u32>) -> NewDecision {
|
||||
NewDecision {
|
||||
subject: "Harbor".into(),
|
||||
decision_md: "Build it on spec.".into(),
|
||||
decided_by: vec!["Tobin Harlow".into()],
|
||||
decided_on: d(2026, 8, 1),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn add_project_journals_class_added_then_project_set() {
|
||||
let dir = test_dir("add");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let id = s.add_project(" Harbor app ", &facts()).unwrap();
|
||||
let l = lines(&dir.0);
|
||||
let n = l.len();
|
||||
assert_eq!(
|
||||
l[n - 2],
|
||||
(
|
||||
"class.added".into(),
|
||||
json!({"id": id, "name": "Harbor app"})
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
l[n - 1],
|
||||
(
|
||||
"project.set".into(),
|
||||
json!({"id": id, "name": "Harbor app", "status": "active", "kind": "on_spec",
|
||||
"description": "An app for the harbor", "started_on": "2026-08-01",
|
||||
"links": ["https://harbor.example/app"]})
|
||||
)
|
||||
);
|
||||
assert_eq!(s.project_facts(id), Some(facts()));
|
||||
// A refused add journals nothing: a taken name.
|
||||
let head = s.head();
|
||||
assert!(s.add_project("HARBOR APP", &facts()).is_err());
|
||||
assert!(
|
||||
s.add_project(
|
||||
"Dock",
|
||||
&ProjectFacts {
|
||||
ended_on: Some(d(2026, 1, 1)),
|
||||
..Default::default()
|
||||
}
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(s.head(), head);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_name_only_project_journals_class_added_alone() {
|
||||
let dir = test_dir("name-only");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let before = lines(&dir.0).len();
|
||||
let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap();
|
||||
let l = lines(&dir.0);
|
||||
assert_eq!(l.len(), before + 1);
|
||||
assert_eq!(l[before].0, "class.added");
|
||||
assert_eq!(s.project_facts(id), Some(ProjectFacts::default()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_writes_wait_for_the_upgrade_and_journal_nothing() {
|
||||
let dir = test_dir("upgrade");
|
||||
let mut s = Store::create_in_format(&dir.0, Currency::USD, &JournalFormat::default()).unwrap();
|
||||
// A name alone is a plain class: accepted.
|
||||
let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap();
|
||||
let (att, _, _) = s
|
||||
.file_document("other", "pitch.pdf", "application/pdf", b"%PDF pitch", None)
|
||||
.unwrap();
|
||||
let head = s.head();
|
||||
let waits = |r: Result<(), StoreError>| match r {
|
||||
Err(StoreError::FormatUpgradePending { need }) => need,
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
};
|
||||
let projects = "the journal feature projects";
|
||||
assert_eq!(waits(s.add_project("Dock", &facts()).map(|_| ())), projects);
|
||||
assert_eq!(
|
||||
waits(s.set_project(id, "Harbor", &facts()).map(|_| ())),
|
||||
projects
|
||||
);
|
||||
assert_eq!(
|
||||
waits(s.link_attachment(att, Subject::Project(u64::from(id)))),
|
||||
projects
|
||||
);
|
||||
// A decision waits for format 2 itself on these books; with
|
||||
// projects it waits for that feature too.
|
||||
waits(s.record_decision(&decision(vec![id])).map(|_| ()));
|
||||
assert_eq!(s.head(), head, "nothing journaled");
|
||||
assert!(s.upgrade_format().unwrap().is_some());
|
||||
s.add_project("Dock", &facts()).unwrap();
|
||||
assert!(s.set_project(id, "Harbor", &facts()).unwrap());
|
||||
s.link_attachment(att, Subject::Project(u64::from(id)))
|
||||
.unwrap();
|
||||
s.record_decision(&decision(vec![id])).unwrap();
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// The live books after this version is installed: format 2 with every
|
||||
/// earlier feature but not `projects`. The format check passes; only the
|
||||
/// feature check refuses, and nothing is written.
|
||||
#[test]
|
||||
fn format_2_books_without_the_feature_wait_for_it_alone() {
|
||||
let dir = test_dir("no-feature");
|
||||
let mut format = ledger_store::format::current();
|
||||
format
|
||||
.features
|
||||
.retain(|f| f != ledger_store::format::PROJECTS);
|
||||
assert_eq!(format.format, 2);
|
||||
let mut s = Store::create_in_format(&dir.0, Currency::USD, &format).unwrap();
|
||||
let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap();
|
||||
let (att, _, _) = s
|
||||
.file_document("other", "pitch.pdf", "application/pdf", b"%PDF pitch", None)
|
||||
.unwrap();
|
||||
// A decision naming no project is format 2's: accepted.
|
||||
s.record_decision(&decision(vec![])).unwrap();
|
||||
let head = s.head();
|
||||
let waits = |r: Result<(), StoreError>| match r {
|
||||
Err(StoreError::FormatUpgradePending { need }) => need,
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
};
|
||||
let projects = "the journal feature projects";
|
||||
assert_eq!(waits(s.add_project("Dock", &facts()).map(|_| ())), projects);
|
||||
assert_eq!(
|
||||
waits(s.set_project(id, "Harbor", &facts()).map(|_| ())),
|
||||
projects
|
||||
);
|
||||
assert_eq!(
|
||||
waits(s.link_attachment(att, Subject::Project(u64::from(id)))),
|
||||
projects
|
||||
);
|
||||
assert_eq!(
|
||||
waits(s.record_decision(&decision(vec![id])).map(|_| ())),
|
||||
projects
|
||||
);
|
||||
assert_eq!(s.head(), head, "nothing journaled");
|
||||
// A name alone is still a plain class.
|
||||
s.add_project("Dock", &ProjectFacts::default()).unwrap();
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// `class.added` lines appended as an earlier version wrote them: no
|
||||
/// uniqueness and no trimming.
|
||||
fn old_classes(dir: &Path, names: &[&str]) {
|
||||
let s = Store::create(dir, Currency::USD).unwrap();
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
for (i, n) in names.iter().enumerate() {
|
||||
w.append("class.added", json!({"id": i + 1, "name": n}))
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_books_with_a_duplicate_name_change_either_project() {
|
||||
let dir = test_dir("dup");
|
||||
old_classes(&dir.0, &["Alpha", "alpha", "Harbor"]);
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
let paused = ProjectFacts {
|
||||
status: ProjectStatus::Paused,
|
||||
..Default::default()
|
||||
};
|
||||
// A fact-only change to each keeps its name.
|
||||
assert!(s.set_project(1, "Alpha", &paused).unwrap());
|
||||
assert!(s.set_project(2, "alpha", &paused).unwrap());
|
||||
// Its own name up to capitals and space is still its own.
|
||||
assert!(s.set_project(2, " ALPHA ", &paused).unwrap());
|
||||
assert_eq!(s.list_classes()[1].name, "ALPHA");
|
||||
// A rename onto another project's name is still refused.
|
||||
let head = s.head();
|
||||
assert!(matches!(
|
||||
s.set_project(1, "harbor", &paused),
|
||||
Err(StoreError::Ledger(
|
||||
ledger_core::LedgerError::DuplicateClassName(_)
|
||||
))
|
||||
));
|
||||
assert_eq!(s.head(), head);
|
||||
// A rename away from the duplicate is accepted.
|
||||
assert!(s.set_project(2, "Alpha two", &paused).unwrap());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_fact_only_change_never_renames_an_untrimmed_old_name() {
|
||||
let dir = test_dir("untrimmed");
|
||||
old_classes(&dir.0, &["Retail "]);
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
let head = s.head();
|
||||
// Its own name, exactly: unchanged, nothing journaled.
|
||||
assert!(
|
||||
!s.set_project(1, "Retail ", &ProjectFacts::default())
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(s.head(), head);
|
||||
let paused = ProjectFacts {
|
||||
status: ProjectStatus::Paused,
|
||||
..Default::default()
|
||||
};
|
||||
assert!(s.set_project(1, "Retail ", &paused).unwrap());
|
||||
let l = lines(&dir.0);
|
||||
assert_eq!(
|
||||
l.last().unwrap().1["name"],
|
||||
"Retail ",
|
||||
"no rename journaled"
|
||||
);
|
||||
assert_eq!(s.list_classes()[0].name, "Retail ");
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_with_nothing_new_journals_nothing() {
|
||||
let dir = test_dir("same");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let id = s.add_project("Harbor", &facts()).unwrap();
|
||||
let head = s.head();
|
||||
assert!(!s.set_project(id, " Harbor ", &facts()).unwrap());
|
||||
assert_eq!(s.head(), head);
|
||||
let mut f = facts();
|
||||
f.status = ProjectStatus::Paused;
|
||||
assert!(s.set_project(id, "Harbor", &f).unwrap());
|
||||
assert_ne!(s.head(), head);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_set_replays_verifies_and_reloads() {
|
||||
let dir = test_dir("replay");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let party = s.add_party("Harbor Co", &[]).unwrap();
|
||||
let id = s.add_project("Harbor", &facts()).unwrap();
|
||||
let mut f = facts();
|
||||
f.party = Some(party);
|
||||
f.status = ProjectStatus::Finished;
|
||||
f.ended_on = Some(d(2026, 9, 30));
|
||||
s.set_project(id, "Harbor app", &f).unwrap();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir.0).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert_eq!(s.project_facts(id), Some(f.clone()));
|
||||
assert_eq!(s.list_classes()[0].name, "Harbor app");
|
||||
drop(s);
|
||||
let snap = Snapshot::load(&dir.0).unwrap();
|
||||
assert_eq!(snap.ledger().project_facts(id), Some(f));
|
||||
assert_eq!(snap.ledger().projects_of_party(party), vec![id]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unknown_status_or_kind_is_newer_a_malformed_record_corrupt() {
|
||||
for (case, payload, newer) in [
|
||||
(
|
||||
"status",
|
||||
json!({"id": 1, "name": "Harbor", "status": "on_hold"}),
|
||||
true,
|
||||
),
|
||||
(
|
||||
"kind",
|
||||
json!({"id": 1, "name": "Harbor", "status": "active", "kind": "grant"}),
|
||||
true,
|
||||
),
|
||||
("no-status", json!({"id": 1, "name": "Harbor"}), false),
|
||||
(
|
||||
"links",
|
||||
json!({"id": 1, "name": "Harbor", "status": "active", "links": "x"}),
|
||||
false,
|
||||
),
|
||||
(
|
||||
"date",
|
||||
json!({"id": 1, "name": "Harbor", "status": "active", "started_on": "soon"}),
|
||||
false,
|
||||
),
|
||||
] {
|
||||
let dir = test_dir(&format!("bad-{case}"));
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
s.add_class("Harbor").unwrap();
|
||||
drop(s);
|
||||
let mut w = JournalWriter::open(&dir.0.join(JOURNAL_FILE)).unwrap();
|
||||
w.append("project.set", payload).unwrap();
|
||||
drop(w);
|
||||
match Store::open(&dir.0) {
|
||||
Err(StoreError::NewerJournal { .. }) if newer => {}
|
||||
Err(StoreError::JournalCorrupt(_)) if !newer => {}
|
||||
Err(e) => panic!("{case}: {e:?}"),
|
||||
Ok(_) => panic!("{case}: opened"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_decision_without_projects_writes_the_bytes_it_always_did() {
|
||||
let d0 = decision(vec![]);
|
||||
let v = ledger_store::meta::decision_recorded(1, &d0);
|
||||
assert!(v.get("projects").is_none());
|
||||
assert_eq!(
|
||||
v,
|
||||
json!({"id": 1, "subject": "Harbor", "decision_md": "Build it on spec.",
|
||||
"decided_by": ["Tobin Harlow"], "decided_on": "2026-08-01"})
|
||||
);
|
||||
let (_, back) = ledger_store::meta::decision_recorded_of(&v).unwrap();
|
||||
assert_eq!(back, d0);
|
||||
let with = ledger_store::meta::decision_recorded(1, &decision(vec![3, 1]));
|
||||
assert_eq!(with["projects"], json!([3, 1]));
|
||||
assert_eq!(
|
||||
ledger_store::meta::decision_recorded_of(&with)
|
||||
.unwrap()
|
||||
.1
|
||||
.projects,
|
||||
vec![3, 1]
|
||||
);
|
||||
let mut bad = with.clone();
|
||||
bad["projects"] = json!(["x"]);
|
||||
assert!(ledger_store::meta::decision_recorded_of(&bad).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attachments_link_to_a_project_and_unlink() {
|
||||
let dir = test_dir("links");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let id = s.add_project("Harbor", &ProjectFacts::default()).unwrap();
|
||||
let (att, _, _) = s
|
||||
.file_document(
|
||||
"contract",
|
||||
"spec.pdf",
|
||||
"application/pdf",
|
||||
b"%PDF spec",
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
let sub = Subject::Project(u64::from(id));
|
||||
assert_eq!(Store::subject_feature(sub), Some("projects"));
|
||||
assert_eq!(Store::subject_feature(Subject::Party(1)), None);
|
||||
assert!(s.subject_exists(sub).unwrap());
|
||||
assert!(!s.subject_exists(Subject::Project(99)).unwrap());
|
||||
assert!(!s.subject_exists(Subject::Project(u64::MAX)).unwrap());
|
||||
assert!(s.link_attachment(att, Subject::Project(99)).is_err());
|
||||
s.link_attachment(att, sub).unwrap();
|
||||
assert_eq!(s.attachments_for(sub).unwrap()[0].id, att);
|
||||
assert!(s.attachment_links(att).unwrap().contains(&sub));
|
||||
drop(s);
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
s.verify().unwrap();
|
||||
s.unlink_attachment(att, sub, "wrong project").unwrap();
|
||||
assert!(s.attachments_for(sub).unwrap().is_empty());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_books_open_to_their_figures_with_projects_known() {
|
||||
let root = Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books");
|
||||
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path())
|
||||
.filter(|p| p.is_dir())
|
||||
.collect();
|
||||
dirs.sort();
|
||||
assert!(!dirs.is_empty());
|
||||
for fixture in dirs {
|
||||
let name = fixture.file_name().unwrap().to_string_lossy().to_string();
|
||||
let dir = test_dir(&format!("old-{name}"));
|
||||
copy(&fixture, &dir.0);
|
||||
let journal = std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap();
|
||||
let expected: Value =
|
||||
serde_json::from_slice(&std::fs::read(fixture.join("expected.json")).unwrap()).unwrap();
|
||||
let s = Store::open(&dir.0).unwrap_or_else(|e| panic!("{name}: refused: {e}"));
|
||||
// The figures that version computed, unchanged by knowing projects.
|
||||
assert_eq!(
|
||||
json!(ledger_core::reports::snapshot(s.ledger()).map_err(|e| e.to_string())),
|
||||
expected["snapshot"],
|
||||
"{name}: figures"
|
||||
);
|
||||
assert_eq!(
|
||||
json!(s.verify().map(|_| ()).map_err(|e| e.to_string())),
|
||||
expected["verify"],
|
||||
"{name}: verify"
|
||||
);
|
||||
for c in s.list_classes() {
|
||||
assert_eq!(
|
||||
s.project_facts(c.id),
|
||||
Some(ProjectFacts::default()),
|
||||
"{name}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
!lines(&dir.0).iter().any(|(t, _)| t == "project.set"),
|
||||
"{name}"
|
||||
);
|
||||
drop(s);
|
||||
assert_eq!(
|
||||
std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap(),
|
||||
journal,
|
||||
"{name}: opening journals nothing"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fn copy(from: &Path, to: &Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for e in std::fs::read_dir(from).unwrap() {
|
||||
let e = e.unwrap();
|
||||
if e.file_name() == "expected.json" || e.file_name() == "record.json" {
|
||||
continue;
|
||||
}
|
||||
if e.file_type().unwrap().is_dir() {
|
||||
copy(&e.path(), &to.join(e.file_name()));
|
||||
} else {
|
||||
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
proptest::proptest! {
|
||||
/// Any record a project can hold reads back as written.
|
||||
#[test]
|
||||
fn prop_project_set_round_trips(
|
||||
status in 0usize..4,
|
||||
kind in proptest::option::of(0usize..4),
|
||||
party in proptest::option::of(1u64..1000),
|
||||
desc in proptest::option::of("[a-z ]{1,20}"),
|
||||
start in proptest::option::of(0u32..3000),
|
||||
links in proptest::collection::vec("[a-z]{1,10}", 0..8),
|
||||
) {
|
||||
let f = ProjectFacts {
|
||||
description: desc,
|
||||
status: ProjectStatus::ALL[status],
|
||||
kind: kind.map(|k| ProjectKind::ALL[k]),
|
||||
party,
|
||||
started_on: start.map(|n| d(2020, 1, 1) + chrono::Days::new(u64::from(n))),
|
||||
ended_on: None,
|
||||
links,
|
||||
notes: None,
|
||||
};
|
||||
let payload = ledger_store::meta::project_set(7, "Harbor", &f);
|
||||
let line = ledger_store::journal::JournalLine {
|
||||
seq: 1,
|
||||
ts: String::new(),
|
||||
line_type: "project.set".into(),
|
||||
payload,
|
||||
prev: String::new(),
|
||||
};
|
||||
let back = ledger_store::meta::project_set_of(&line).unwrap();
|
||||
proptest::prop_assert_eq!(back, (7, "Harbor".to_string(), f));
|
||||
}
|
||||
}
|
||||
@@ -609,6 +609,7 @@ fn decisions_and_open_items() {
|
||||
evidence_attachment: evidence,
|
||||
supersedes,
|
||||
citation: Some("Treas. Reg. 1.263(a)-1(f)".into()),
|
||||
projects: vec![],
|
||||
};
|
||||
assert_eq!(s.record_decision(&decision(None, Some(doc))).unwrap(), 1);
|
||||
assert_eq!(s.record_decision(&decision(Some(1), None)).unwrap(), 2);
|
||||
@@ -756,6 +757,7 @@ fn run_op(s: &mut Store, op: &Op) {
|
||||
evidence_attachment: None,
|
||||
supersedes: *sup,
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.map(|_| ()),
|
||||
Op::Raise => s
|
||||
@@ -978,6 +980,7 @@ fn write_p1_fixture() {
|
||||
evidence_attachment: Some(doc),
|
||||
supersedes: None,
|
||||
citation: Some("Treas. Reg. 1.263(a)-1(f)".into()),
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
@@ -988,6 +991,7 @@ fn write_p1_fixture() {
|
||||
evidence_attachment: None,
|
||||
supersedes: Some(1),
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.raise_open_item(&NewOpenItem {
|
||||
|
||||
Reference in new issue
Block a user