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