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
@@ -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"]);
|
||||
}
|
||||
Reference in new issue
Block a user