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

A project is a class with facts (description, status, kind, party,
dates, links, notes) in one project.set event behind the format
feature projects. Four tools (books.add_project, update_project,
projects, project); documents and decisions link to projects; B-37:
on-spec costs are expensed as incurred, repayment is revenue, and the
unrecovered cost is never below zero (Kani). Viewer: a Projects list
and a project page with its figures, by month and by account. The
sidebar measures its footer instead of reserving a fixed 172 px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-22 03:50:52 -06:00
1 parent 0495726001
commit 321b94562a
71 files changed
+7446 -217

No files matched your search

+1
View File
@@ -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"),
+5 -1
View File
@@ -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),
+83 -22
View File
@@ -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,
+100 -19
View File
@@ -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(),
}))
}
+390
View File
@@ -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
View File
@@ -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>,
}