feat: invoices and bills take a project, line by line (B-37 item 8)

A client project's invoiced revenue and a contractor's billed cost could
never count toward the project: books.create_invoice and books.create_bill
posted at once with untagged lines, and only a draft's line takes a class.
Job costing (B-37 item 3) collects a job's billings and the supplier
invoices for its direct costs on its cost sheet, so each document line
now takes an optional project.

The owner decided it on 2026-10-22 (plan D-43, rules.md B-37 item 8):
each line takes one project or none; a document spanning N projects is N
lines, each with its own amount; no percentage split, so every figure is
exact; a cost truly shared among projects names none on the document
and reaches projects only as entries of their own under the company's
monthly allocation convention, when books.conventions has one (otherwise
it stays untagged and the agent asks the human). The reasoning, stated
in B-37: job costing charges a job the costs traceable to it, whole, and
reaches shared costs only by allocation on a stated basis. ASC
340-40-25-7 counts as directly related both the costs traceable to a
contract (25-7(a), (b), (d), (e)) and allocations of costs that relate
directly to it (25-7(c)); 25-8 expenses general and administrative cost.
The books carry the first whole on the document's line and the second as
the allocation convention's own entries; neither is a percentage on the
document (sources/fasb-asc.md, 25-7 and 25-8 added). The handbook's
projects rule and the line's class parameter say the same, and no
handbook sentence lets a shared cost be split across a document's lines.

The accounting: the document's entry posts one revenue (an invoice's) or
expense (a bill's) line per project its lines name, in the order each is
first named, each the sum of that project's lines, and the untagged
lines' sum as one more line (arap::document_parts, conservation checked
by a proptest). A document naming no project therefore posts exactly the
one line every document posted before. The receivable or payable line is
never tagged: it is a balance-sheet line, which item 3 counts nothing;
the payments (receive_payment, pay_bill) move cash against it and take no
project either. Nothing but the projects moves: the P&L, the 1099
worksheet (payments made, B-19) and party totals (what moved the money,
B-35) read the same with or without the tags (twin-books test); the
projects with the untagged lines still partition the P&L.

- ledger-core: DocumentLine.class_id (serde-omitted when none, so old
  documents serialize as before); create_invoice / create_bill split the
  revenue or expense side by project and tag it; an unknown project is
  refused whole (atomically).
- ledger-store: a document line's project is journaled as class_id on
  invoice.created / bill.created, and the entry's line as
  entry.line_class_set right after its entry.line_added (the entry.posted
  lines carry class_id, as a hand-built entry's do). Behind a NEW journal
  feature, document_line_projects (KNOWN_FEATURES, declared by new books,
  asked for before the first journaled line, not even the control
  accounts written on refusal); not `projects`, which the live books
  already declared: the owner consents again at the next upgrade. A
  malformed class_id is corrupt; null or absent is none. Untagged
  documents journal byte for byte as before; old journals replay
  unchanged.
- ledger-mcp: each line takes class (id or name, resolved like
  add_line's, every one before anything is written); the result gains
  notes for a finished or abandoned project, and on an invoice for an
  on-spec project (is there now an agreement? on spec a hoped-for
  payment is no receivable, B-37 item 4), tagged anyway; books lacking
  the feature refuse with format_upgrade_pending. books.get_entry shows a
  line's class_id and project. The handbook's "an invoice or bill takes no
  project yet" caveat now depends on the books: gone where the feature is
  declared, an "until their format upgrade" note where it isn't.
- ledger-gui: an invoice's or bill's page (its entry's) lists the
  document's lines, each project a link that opens it; the demo's client
  invoices are tagged to "Client work", whose snapshot is now its revenue
  less its contractors. Looking at the page in a narrow window found a
  tagged line's tag pushing the amounts past the pane (any tagged entry):
  the name is now cut to leave the tag room, the tag itself at most half
  the cell.
- bookspec: an `invoice` event, and `lines` with `project` on a bill or
  an invoice; example projects-invoice-and-bill-lines-count-by-project,
  worked by hand, both projects client work (Zeta 3,500.00 - 1,200.00 =
  2,300.00; Harbor 200.00;
  untagged -1,750.00; together the P&L's 750.00; Dana's 1099 payments
  1,250.00).
- fuzz: fuzz_store_replay can declare journal features.
- Tests: core, store, MCP, viewer, the P2-served cutover fixture and a
  viewer/server parity test (its month lists the same length, not
  empty); a tagged document's reversal nets its projects to zero, live
  and replayed; books.get_entry's class_id; the handbook served as a
  resource both ways; each fix broken to confirm its test fails
  (docs/testing.md). Docs: rules.md B-27 and B-37 items 3 and 8,
  sources/fasb-asc.md, plan/served.md D-43,
  mcp-interface.md, testing.md, parity.md, plan/STATUS.md.

No proof changed (no kernel arithmetic: document_parts sums with the
existing document_total).

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 19:28:40 -06:00
1 parent e19df28bf2
commit 528c070605
51 files changed
+3201 -166

No files matched your search

+28 -14
View File
@@ -64,9 +64,9 @@ operating = "0.00"
``` ```
Events count from 1: the first `[[events]]` is event 1. An event is an Events count from 1: the first `[[events]]` is event 1. An event is an
entry (`lines`), a reversal (`reverses`), a close (`close`, below) or a entry (`lines`), a reversal (`reverses`), a close (`close`, below), a
bill (`bill`, below), each posting one entry; or one of the books' bill or an invoice (`bill`, `invoice`, below), each posting one entry;
commands that post what is due (`run_depreciation`, `dispose_asset`, or one of the books' commands that post what is due (`run_depreciation`, `dispose_asset`,
`run_schedules`, below), which post as many entries as are due, none `run_schedules`, below), which post as many entries as are due, none
included; or a schedule's registration (`register_schedule`), which included; or a schedule's registration (`register_schedule`), which
posts none: exactly one of them. A key that names an event (`reverses = posts none: exactly one of them. A key that names an event (`reverses =
@@ -77,10 +77,11 @@ posts one entry, event N's is entry #N. A change that posts no entry (a
rename, a policy) is a `[[changes]]` entry instead (below). A key the rename, a policy) is a `[[changes]]` entry instead (below). A key the
shape doesn't have is refused too, never ignored. shape doesn't have is refused too, never ignored.
An entry, a reversal, a close, a bill and a disposal are dated (`date`); An entry, a reversal, a close, a bill, an invoice and a disposal are
the other commands date their own entries (a run its month's end) and dated (`date`); the other commands date their own entries (a run its
take no date. An entry, a reversal, a close and a bill are described month's end) and take no date. An entry, a reversal, a close, a bill and
(`description`); a command describes its own entries and takes none. an invoice are described (`description`); a command describes its own
entries and takes none.
The books are the engine's own (`ledger_store::Store`, in a directory of The books are the engine's own (`ledger_store::Store`, in a directory of
their own): each event is the command the books take it by, journaled as their own): each event is the command the books take it by, journaled as
@@ -238,7 +239,8 @@ the side gives the direction.
original's tags. original's tags.
- `payee` on an event: the payee (by name, one of `setup.payees`) the - `payee` on an event: the payee (by name, one of `setup.payees`) the
entry paid. Only on an entry (`lines`): a reversal takes its entry paid. Only on an entry (`lines`): a reversal takes its
original's, a bill names its vendor, a close and a command have none. original's, a bill names its vendor, an invoice its customer, a close
and a command have none.
- `parties = [{ party, amount? }, { party, percent? }, ...]` on an event: - `parties = [{ party, amount? }, { party, percent? }, ...]` on an event:
the parties (by name, in `setup.parties`) the entry is with, each with the parties (by name, in `setup.parties`) the entry is with, each with
its share: an `amount` (an exact decimal string), a `percent`, or its share: an `amount` (an exact decimal string), a `percent`, or
@@ -258,12 +260,24 @@ the side gives the direction.
splits that close's net on its own instead (books from an engine that splits that close's net on its own instead (books from an engine that
rounded each close by itself). A close must have something to close: it rounded each close by itself). A close must have something to close: it
posts one entry like any other event. posts one entry like any other event.
- `bill = { vendor, amount }` as an event: a bill from `vendor` (a name; - `bill = { vendor, amount }` or `bill = { vendor, lines = [{ amount,
a payee's record if one has it) for `amount`, dated and due on the description?, project? }, ...] }` as an event: a bill from `vendor` (a
event's date. It posts one entry: General Expenses (6999) debited and name; a payee's record if one has it) for `amount` (one line, described
Accounts Payable (2000) credited, each account created with that fixed as the event) or its `lines` (exactly one of them; a line's description
name when first needed, as the books create a control role's account is the event's when left out), dated and due on the event's date. It
(B-32). posts one entry: General Expenses (6999) debited and Accounts Payable
(2000) credited, each account created with that fixed name when first
needed, as the books create a control role's account (B-32). A line's
`project` (a `setup.projects` name) puts its cost on that project: the
entry's expense side is one line per project named, the untagged lines'
as one, the payable line never tagged (B-37 item 8).
- `invoice = { customer, amount }` or `invoice = { customer, lines = [...]
}` as an event: an invoice to `customer` (a name, its record added when
first named), as a bill is (the same `lines`), dated and due on the
event's date. It posts one entry: Accounts Receivable (1200) debited and
Revenue (4000) credited, each created with that fixed name when first
needed; a line's `project` puts its revenue on that project, the
receivable line never tagged (B-37 item 8).
- `run_depreciation = { asset, period }` as an event: the asset's run for - `run_depreciation = { asset, period }` as an event: the asset's run for
`period` (`"YYYY-MM"`), which posts what the schedule says is due `period` (`"YYYY-MM"`), which posts what the schedule says is due
through the month's end and the books don't carry, dated then: a through the month's end and the books don't carry, dated then: a
@@ -0,0 +1,182 @@
# An invoice's or bill's lines count toward the projects they name.
#
# wanderbooks: rules.md B-37 item 8 (journal feature
# `document_line_projects`); regression tests in ledger-core
# `tests/spec_document_projects.rs`, ledger-store
# `tests/document_projects.rs`, ledger-mcp `tests/spec_projects.rs`
# (`invoice_and_bill_lines_count_toward_their_projects`).
#
# Expected values worked by hand. Invoice 1 bills 3,000.00 + 500.00 of
# Zeta's design (3,500.00), 200.00 of Harbor's hosting and an untagged
# 100.00 rush fee: 3,800.00, all Revenue (4000), the receivable untagged.
# Bill 1 is 1,200.00 of Zeta's illustration and 50.00 of untagged
# postage: 1,250.00 of General Expenses (6999). Bill 2, 300.00, names no
# project. The customer's payment and the bill's payment move cash and
# the receivable or payable only: no project figure. So Zeta nets
# 3,500.00 - 1,200.00 = 2,300.00; Harbor 200.00 (both are client work,
# so neither has an unrecovered cost: on spec, a hoped-for payment is no
# receivable, B-37 item 4); the untagged lines 100.00 - 50.00 - 300.00
# - 1,500.00 (rent) = -1,750.00; together 750.00, the P&L's net
# (3,800.00 - 1,550.00 - 1,500.00). Dana was paid 1,250.00 from the bank,
# whatever the bill's projects; the second bill is still owed.
id = "projects-invoice-and-bill-lines-count-by-project"
title = "An invoice's or bill's lines count toward the projects they name"
rule = "Each line of an invoice or bill MAY name a project; its revenue (an invoice's) or cost (a bill's) MUST then count toward that project's figures exactly as a tagged line of a hand-built entry would, the receivable, the payable and their payments MUST count toward none, and every figure that is not a project's (the P&L, a payee's 1099 payments) MUST be what it would be with no project named."
status = "CONVENTION"
sources = [
"Job-order costing: a job cost sheet accumulates the revenue and the direct costs identified with one job, a supplier's invoice and the job's billing among them (management-accounting convention; wanderbooks docs/spec/rules.md B-37 items 3 and 8)",
"wanderbooks docs/spec/rules.md B-11 and B-14: a period's net income is its revenue less its expenses",
"Treas. Reg. §1.6041-1(a)(1)(iv) (docs/spec/sources/irs-1099-payment-method.md): a 1099 counts payments made, not how the books classify them",
]
tags = ["projects", "classes", "job-costing", "accrual", "invoices", "bills"]
[setup]
currency = "USD"
fiscal_year_start = "01-01"
basis = "accrual"
rounding = "half-even"
cash_accounts = ["1000"]
[[setup.accounts]]
code = "1000"
name = "Bank"
type = "asset"
[[setup.accounts]]
code = "1200"
name = "Accounts Receivable"
type = "asset"
[[setup.accounts]]
code = "2000"
name = "Accounts Payable"
type = "liability"
[[setup.accounts]]
code = "4000"
name = "Revenue"
type = "revenue"
[[setup.accounts]]
code = "6200"
name = "Rent"
type = "expense"
[[setup.accounts]]
code = "6999"
name = "General Expenses"
type = "expense"
[[setup.projects]]
name = "Zeta"
kind = "client"
[[setup.projects]]
name = "Harbor"
kind = "client"
[[setup.payees]]
name = "Dana Illustration"
form = "1099-NEC"
[[events]]
date = "2026-03-02"
description = "Invoice to Cedar"
invoice = { customer = "Cedar Client", lines = [
{ description = "Design", amount = "3000.00", project = "Zeta" },
{ description = "Hosting", amount = "200.00", project = "Harbor" },
{ description = "Rush fee", amount = "100.00" },
{ description = "Revisions", amount = "500.00", project = "Zeta" },
] }
[[events]]
date = "2026-03-09"
description = "Dana's bill"
bill = { vendor = "Dana Illustration", lines = [
{ description = "Illustration", amount = "1200.00", project = "Zeta" },
{ description = "Postage", amount = "50.00" },
] }
[[events]]
date = "2026-03-20"
description = "Cedar pays invoice 1"
lines = [
{ account = "1000", side = "debit", amount = "3800.00" },
{ account = "1200", side = "credit", amount = "3800.00" },
]
[[events]]
date = "2026-03-30"
description = "Pay Dana's bill"
payee = "Dana Illustration"
lines = [
{ account = "2000", side = "debit", amount = "1250.00" },
{ account = "1000", side = "credit", amount = "1250.00" },
]
[[events]]
date = "2026-03-31"
description = "Studio rent"
lines = [
{ account = "6200", side = "debit", amount = "1500.00" },
{ account = "1000", side = "credit", amount = "1500.00" },
]
[[events]]
date = "2026-04-02"
description = "Dana's second bill"
bill = { vendor = "Dana Illustration", amount = "300.00" }
[[expect]]
query = "entry"
entry = 1
[expect.values]
"1200.debit" = "3800.00"
"4000.credit" = "3800.00"
[[expect]]
query = "entry"
entry = 2
[expect.values]
"6999.debit" = "1250.00"
"2000.credit" = "1250.00"
[[expect]]
query = "project"
project = "Zeta"
from = "2026-01-01"
to = "2026-12-31"
[expect.values]
revenue = "3500.00"
expenses = "1200.00"
net = "2300.00"
"to_date.revenue" = "3500.00"
"to_date.expenses" = "1200.00"
"to_date.net" = "2300.00"
unrecovered_cost = "none"
[[expect]]
query = "project"
project = "Harbor"
from = "2026-01-01"
to = "2026-12-31"
[expect.values]
revenue = "200.00"
expenses = "0.00"
net = "200.00"
"to_date.revenue" = "200.00"
"to_date.expenses" = "0.00"
"to_date.net" = "200.00"
unrecovered_cost = "none"
[[expect]]
query = "profit_and_loss"
from = "2026-01-01"
to = "2026-12-31"
[expect.values]
"4000" = "3800.00"
"6200" = "1500.00"
"6999" = "1550.00"
net_income = "750.00"
[[expect]]
query = "vendor_1099_paid"
from = "2026-01-01"
to = "2026-12-31"
[expect.values]
"Dana Illustration" = "1250.00"
+17 -1
View File
@@ -643,7 +643,8 @@ fn copy_private(from: &Path, to: &Path) {
/// database is never written; what P2 can read is written, and every /// database is never written; what P2 can read is written, and every
/// write P2 would refuse or misread is refused, journaling nothing /// write P2 would refuse or misread is refused, journaling nothing
/// (a setting key, a tax category, a depreciation catch-up, a members /// (a setting key, a tax category, a depreciation catch-up, a members
/// close counted from the count's start). /// close counted from the count's start, an invoice's or bill's line
/// naming its project).
/// 2. The consent backup holds the books exactly as they were, keys and /// 2. The consent backup holds the books exactly as they were, keys and
/// vault too; the upgrade is one line after P2's, byte for byte. /// vault too; the upgrade is one line after P2's, byte for byte.
/// 3. After it: the same figures, and every refused write is made; the /// 3. After it: the same figures, and every refused write is made; the
@@ -699,8 +700,23 @@ fn books_p2_served_cut_over_whole() {
) )
.unwrap()["period_id"] .unwrap()["period_id"]
.clone(); .clone();
// A project by name alone is a plain class, which P2 reads.
a.call("books.add_project", json!({"name": "Lighthouse"}))
.unwrap();
let waiting = journal(&dir); let waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![ let refused: Vec<(&str, Value)> = vec![
// An invoice's or bill's line naming its project
// (document_line_projects, rules.md B-37 item 8).
(
"books.create_invoice",
json!({"party_id": 1, "date": "2026-02-10", "due_date": "2026-03-12",
"lines": [{"description": "Design", "amount": "500.00", "class": "Lighthouse"}]}),
),
(
"books.create_bill",
json!({"party_id": 1, "date": "2026-02-11", "due_date": "2026-03-13",
"lines": [{"description": "Drafting", "amount": "120.00", "class": "Lighthouse"}]}),
),
( (
"books.set_setting", "books.set_setting",
json!({"decided_by": "Avery Quill", json!({"decided_by": "Avery Quill",
+137
View File
@@ -257,3 +257,140 @@ fn a_projects_snapshot_is_the_same_in_the_viewer_and_from_the_server() {
); );
assert_eq!(period["net"], "-722.22", "a refund and costs, no sale"); assert_eq!(period["net"], "-722.22", "a refund and costs, no sale");
} }
/// rules.md B-37 item 8: a project's invoiced revenue and billed costs,
/// made through the books server's own tools, read the same in the viewer
/// (its snapshot, and the invoice's and bill's pages: each line's
/// project) and from the server (books.project, books.get_entry).
#[test]
fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_server() {
let dir = tempfile::Builder::new()
.prefix("wb-projects-parity-docs-")
.tempdir()
.unwrap();
let data = dir.path().join("books-data");
std::fs::write(
dir.path().join("wanderbooks.toml"),
"[company]\nname = \"Fernhill Labs\"\nfiscal_year_start = \"01-01\"\n\
[books]\nreceipt_threshold = \"1000000.00\"\ncash_accounts = [\"1000\"]\n",
)
.unwrap();
let today = d(2026, 10, 15);
{
let mut st = ledger_store::Store::create(&data, Currency::USD).unwrap();
st.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
}
let mut server = Server::start(&data);
server.tool(
"books.add_project",
json!({"name": "Kestrel", "kind": "client", "started_on": "2026-01-05"}),
);
server.tool("books.add_project", json!({"name": "Heron"}));
let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone();
let doc = |party: &Value, date: &str, lines: Value| json!({"party_id": party, "date": date, "due_date": date, "lines": lines});
let inv = server.tool(
"books.create_invoice",
doc(
&cedar,
"2026-02-02",
json!([
{"description": "Discovery", "amount": "1500.00", "class": "Kestrel"},
{"description": "Heron sketches", "amount": "250.00", "class": "Heron"},
{"description": "Travel", "amount": "80.00"},
]),
),
);
let inv2 = server.tool(
"books.create_invoice",
doc(
&cedar,
"2026-05-04",
json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]),
),
);
let bill = server.tool(
"books.create_bill",
doc(
&dana,
"2026-03-09",
json!([
{"description": "Illustration", "amount": "987.65", "class": "Kestrel"},
{"description": "Postage", "amount": "12.00"},
]),
),
);
server.tool(
"books.receive_payment",
json!({"document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}),
);
let b = data::load_once(&Source {
data_dir: data.clone(),
today: Some(today),
})
.unwrap();
let id = b.ledger.find_class_by_name("Kestrel").unwrap().id;
let page = page_figures(&b, id, Some(d(2026, 1, 5)), false);
let t = page.to_date.as_ref().unwrap();
let snap = Snap::to_date(t);
let p = server.tool(
"books.project",
json!({"project": "Kestrel", "start": "2026-01-01", "end": today.to_string()}),
);
let to = &p["to_date"];
assert_eq!(to["revenue"], s(snap.revenue));
assert_eq!(to["expenses"], s(snap.expenses));
assert_eq!(to["net"], s(snap.contribution));
assert_eq!(
to["margin_pct"],
snap.margin_pct
.map_or(Value::Null, |m| json!(m.to_string()))
);
// By hand: 1,500.00 + 4,321.09 invoiced, 987.65 billed.
assert_eq!(
(
to["revenue"].as_str(),
to["expenses"].as_str(),
to["net"].as_str()
),
(Some("5821.09"), Some("987.65"), Some("4833.44"))
);
let ((_, _), months) = page.months.as_ref().unwrap();
let by_month = p["period"]["by_month"].as_array().unwrap();
assert!(!by_month.is_empty(), "{p}");
assert_eq!(by_month.len(), months.months.len());
for (m, ours) in by_month.iter().zip(&months.months) {
assert_eq!(m["month"], ours.start.format("%Y-%m").to_string());
assert_eq!(m["revenue"], s(ours.revenue), "{m}");
assert_eq!(m["expenses"], s(ours.expenses), "{m}");
}
// Each document's lines: the viewer's page names the project the
// server's entry line carries.
for out in [&inv, &inv2, &bill] {
let entry = out["entry_id"].as_u64().unwrap();
let doc = ledger_gui::pages::entry::document_of(&b, entry).unwrap();
let viewer: Vec<Option<String>> = {
let mut seen: Vec<Option<String>> = Vec::new();
for l in doc.lines {
let name = l
.class_id
.and_then(|c| b.ledger.get_class(c))
.map(|c| c.name.clone());
if !seen.contains(&name) {
seen.push(name);
}
}
seen
};
let served = server.tool("books.get_entry", json!({"entry_id": entry}));
let served: Vec<Option<String>> = served["lines"]
.as_array()
.unwrap()
.iter()
.filter(|l| l["code"] == "4000" || l["code"] == "6999")
.map(|l| l["project"].as_str().map(str::to_string))
.collect();
assert_eq!(viewer, served, "entry {entry}");
}
}
+51
View File
@@ -89,6 +89,13 @@ pub struct DocumentLine {
pub description: String, pub description: String,
/// Line amount (positive). /// Line amount (positive).
pub amount: Decimal, pub amount: Decimal,
/// The project (a class) the line's revenue or cost belongs to, if
/// any (rules.md B-37): the posted entry's revenue or expense line for
/// it carries the tag; the receivable or payable line never does.
/// Documents journaled before the `document_line_projects` feature
/// have none.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class_id: Option<crate::ClassId>,
} }
/// A sales invoice: we billed a customer. /// A sales invoice: we billed a customer.
@@ -123,6 +130,44 @@ pub fn document_total(lines: &[DocumentLine], what: &str) -> Result<Decimal, Led
.ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s total"))) .ok_or_else(|| LedgerError::TooLargeToCompute(format!("{what}'s total")))
} }
/// The revenue (an invoice's) or expense (a bill's) side of a document's
/// entry: one part per project the lines name, in the order each first
/// appears, the untagged lines together as one part (`None`), each the
/// sum of its lines ([`document_total`]).
///
/// ```text
/// GOAL: a document's lines count toward their projects, and a document
/// whose lines name none posts exactly as before.
/// SOURCE: docs/spec/rules.md B-37 item 3 (job costing: the revenue and
/// direct costs identified with one job) and item 8 (an
/// invoice's or bill's line names its project).
/// INVARIANTS:
/// 1. The parts sum to the document's total (conservation), and each
/// line is in exactly one part.
/// 2. No two parts name the same project; no part is empty.
/// 3. Lines naming no project make one part, so an untagged document is
/// one part: the one line every document posted before projects.
/// 4. NEVER panics: TooLargeToCompute past exact arithmetic.
/// ```
pub fn document_parts(
lines: &[DocumentLine],
what: &str,
) -> Result<Vec<(Option<crate::ClassId>, Decimal)>, LedgerError> {
let mut keys: Vec<Option<crate::ClassId>> = Vec::new();
for l in lines {
if !keys.contains(&l.class_id) {
keys.push(l.class_id);
}
}
keys.into_iter()
.map(|k| {
let part: Vec<DocumentLine> =
lines.iter().filter(|l| l.class_id == k).cloned().collect();
Ok((k, document_total(&part, what)?))
})
.collect()
}
/// What is still owed on a document: its total less what was paid. /// What is still owed on a document: its total less what was paid.
fn document_balance(total: Decimal, paid: Decimal, what: &str) -> Result<Decimal, LedgerError> { fn document_balance(total: Decimal, paid: Decimal, what: &str) -> Result<Decimal, LedgerError> {
total total
@@ -350,6 +395,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "Widgets".to_string(), description: "Widgets".to_string(),
amount: "1000.00".parse().unwrap(), amount: "1000.00".parse().unwrap(),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -399,6 +445,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "Paper".to_string(), description: "Paper".to_string(),
amount: "250.00".parse().unwrap(), amount: "250.00".parse().unwrap(),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -440,6 +487,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "X".to_string(), description: "X".to_string(),
amount: "100.00".parse().unwrap(), amount: "100.00".parse().unwrap(),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -517,6 +565,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "A".to_string(), description: "A".to_string(),
amount: "100.00".parse().unwrap(), amount: "100.00".parse().unwrap(),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -528,6 +577,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "B".to_string(), description: "B".to_string(),
amount: "200.00".parse().unwrap(), amount: "200.00".parse().unwrap(),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -557,6 +607,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "X".to_string(), description: "X".to_string(),
amount: "100.00".parse().unwrap(), amount: "100.00".parse().unwrap(),
class_id: None,
}], }],
); );
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _)))); assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
+53 -12
View File
@@ -4900,6 +4900,30 @@ impl Ledger {
self.add_control_account(c).expect("fresh code cannot fail") self.add_control_account(c).expect("fresh code cannot fail")
} }
/// A document's revenue or expense side on its draft `entry`: one line
/// on `account` per part ([`arap::document_parts`]), each tagged with
/// its project (a project the books don't have: UnknownClass, and the
/// caller's `atomically` leaves the books unchanged).
fn add_document_parts(
&mut self,
entry: EntryId,
account: AccountId,
side: Side,
parts: &[(Option<ClassId>, Decimal)],
) -> Result<(), LedgerError> {
for &(class, amount) in parts {
self.add_line(entry, account, side, Money::new(amount, self.base_currency))?;
if class.is_some() {
let last = self
.get_entry(entry)
.map_or(0, |e| e.lines().len())
.saturating_sub(1);
self.set_line_class(entry, last, class)?;
}
}
Ok(())
}
/// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable, /// Create a sales invoice. Auto-posts: Dr `1200` Accounts Receivable,
/// Cr `4000` Revenue. The invoice date must not fall in a closed period. /// Cr `4000` Revenue. The invoice date must not fall in a closed period.
/// ///
@@ -4910,13 +4934,17 @@ impl Ledger {
/// control accounts: Ledger::check_controls. /// control accounts: Ledger::check_controls.
/// INVARIANTS: /// INVARIANTS:
/// 1. MUST post one balanced entry: Dr 1200 receivables, Cr 4000 revenue, /// 1. MUST post one balanced entry: Dr 1200 receivables, Cr 4000 revenue,
/// for the sum of the lines, dated the invoice date. /// for the sum of the lines, dated the invoice date: one revenue
/// line per project the lines name, the untagged lines' as one
/// (arap::document_parts), each tagged with its project; the
/// receivable line never (rules.md B-37 item 8).
/// 2. The invoice MUST start open, with nothing paid. /// 2. The invoice MUST start open, with nothing paid.
/// 3. A refusal MUST leave the books unchanged. /// 3. A refusal MUST leave the books unchanged.
/// SCENARIOS: /// SCENARIOS:
/// 1. IF the customer is unknown, a line amount isn't positive, the due /// 1. IF the customer is unknown, a line amount isn't positive, the due
/// date is before the invoice date, or the date is in a closed /// date is before the invoice date, a line names a project the
/// period: SHOULD refuse. /// books don't have, or the date is in a closed period: SHOULD
/// refuse.
/// ``` /// ```
pub fn create_invoice( pub fn create_invoice(
&mut self, &mut self,
@@ -4945,13 +4973,18 @@ impl Ledger {
return Err(LedgerError::ClosedPeriod(pid, date.to_string())); return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
} }
let total: Decimal = lines.iter().map(|l| l.amount).sum(); let total: Decimal = lines.iter().map(|l| l.amount).sum();
let parts = arap::document_parts(&lines, "the invoice")?;
self.check_controls(&[RECEIVABLES, REVENUE])?; self.check_controls(&[RECEIVABLES, REVENUE])?;
let ar = self.ensure_control(&RECEIVABLES); let ar = self.ensure_control(&RECEIVABLES);
let revenue = self.ensure_control(&REVENUE); let revenue = self.ensure_control(&REVENUE);
let entry = self.create_entry(date, &format!("Invoice {}", self.next_invoice_id)); let entry = self.create_entry(date, &format!("Invoice {}", self.next_invoice_id));
let money = Money::new(total, self.base_currency); self.add_line(
self.add_line(entry, ar, Side::Debit, money)?; entry,
self.add_line(entry, revenue, Side::Credit, money)?; ar,
Side::Debit,
Money::new(total, self.base_currency),
)?;
self.add_document_parts(entry, revenue, Side::Credit, &parts)?;
self.post_entry(entry)?; self.post_entry(entry)?;
let id = self.next_invoice_id; let id = self.next_invoice_id;
self.next_invoice_id += 1; self.next_invoice_id += 1;
@@ -4983,12 +5016,15 @@ impl Ledger {
/// INVARIANTS: /// INVARIANTS:
/// 1. MUST post one balanced entry: Dr 6999 general expenses, Cr 2000 /// 1. MUST post one balanced entry: Dr 6999 general expenses, Cr 2000
/// payables, for the sum of the lines, dated the bill date, tagged /// payables, for the sum of the lines, dated the bill date, tagged
/// with the vendor (1099s). /// with the vendor (1099s): one expense line per project the lines
/// name, the untagged lines' as one (arap::document_parts), each
/// tagged with its project; the payable line never (rules.md B-37
/// item 8).
/// 2. A refusal MUST leave the books unchanged. /// 2. A refusal MUST leave the books unchanged.
/// SCENARIOS: /// SCENARIOS:
/// 1. IF the vendor is unknown, a line amount isn't positive, the due date /// 1. IF the vendor is unknown, a line amount isn't positive, the due date
/// is before the bill date, or the date is in a closed period: SHOULD /// is before the bill date, a line names a project the books don't
/// refuse. /// have, or the date is in a closed period: SHOULD refuse.
/// ``` /// ```
pub fn create_bill( pub fn create_bill(
&mut self, &mut self,
@@ -5017,13 +5053,18 @@ impl Ledger {
return Err(LedgerError::ClosedPeriod(pid, date.to_string())); return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
} }
let total: Decimal = lines.iter().map(|l| l.amount).sum(); let total: Decimal = lines.iter().map(|l| l.amount).sum();
let parts = arap::document_parts(&lines, "the bill")?;
self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?; self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?;
let expense = self.ensure_control(&GENERAL_EXPENSES); let expense = self.ensure_control(&GENERAL_EXPENSES);
let ap = self.ensure_control(&PAYABLES); let ap = self.ensure_control(&PAYABLES);
let entry = self.create_entry(date, &format!("Bill {}", self.next_bill_id)); let entry = self.create_entry(date, &format!("Bill {}", self.next_bill_id));
let money = Money::new(total, self.base_currency); self.add_document_parts(entry, expense, Side::Debit, &parts)?;
self.add_line(entry, expense, Side::Debit, money)?; self.add_line(
self.add_line(entry, ap, Side::Credit, money)?; entry,
ap,
Side::Credit,
Money::new(total, self.base_currency),
)?;
self.post_entry(entry)?; self.post_entry(entry)?;
let id = self.next_bill_id; let id = self.next_bill_id;
self.next_bill_id += 1; self.next_bill_id += 1;
+143 -13
View File
@@ -358,6 +358,8 @@ struct Event {
#[serde(default)] #[serde(default)]
bill: Option<BillSpec>, bill: Option<BillSpec>,
#[serde(default)] #[serde(default)]
invoice: Option<InvoiceSpec>,
#[serde(default)]
run_depreciation: Option<RunSpec>, run_depreciation: Option<RunSpec>,
#[serde(default)] #[serde(default)]
dispose_asset: Option<DisposeSpec>, dispose_asset: Option<DisposeSpec>,
@@ -411,15 +413,48 @@ struct RunSchedulesSpec {
through: String, through: String,
} }
/// A bill from `vendor` for `amount`, dated and due on the event's date: /// A bill from `vendor`, dated and due on the event's date, for `amount`
/// Dr General Expenses (6999), Cr Accounts Payable (2000), each created /// (one line, the event's description) or its `lines`, exactly one of
/// with its fixed name when first needed, as the engine creates a control /// them: Dr General Expenses (6999), Cr Accounts Payable (2000), each
/// role's account (rules.md B-32). /// created with its fixed name when first needed, as the engine creates a
/// control role's account (rules.md B-32); a line naming a project puts
/// its cost on that project's expense line (B-37 item 8).
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct BillSpec { struct BillSpec {
vendor: String, vendor: String,
#[serde(default)]
amount: Option<String>,
#[serde(default)]
lines: Vec<DocumentLineSpec>,
}
/// An invoice to `customer` (a name; added when first named), dated and
/// due on the event's date, for `amount` or its `lines`, exactly one of
/// them: Dr Accounts Receivable (1200), Cr Revenue (4000), each created
/// with its fixed name when first needed (B-32); a line naming a project
/// puts its revenue on that project's revenue line (B-37 item 8).
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct InvoiceSpec {
customer: String,
#[serde(default)]
amount: Option<String>,
#[serde(default)]
lines: Vec<DocumentLineSpec>,
}
/// One line of an invoice or a bill: its amount, what it is (the event's
/// description when left out) and the project (a `setup.projects` name)
/// it belongs to.
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct DocumentLineSpec {
amount: String, amount: String,
#[serde(default)]
description: Option<String>,
#[serde(default)]
project: Option<String>,
} }
/// A change to the books that posts no entry, made right after event /// A change to the books that posts no entry, made right after event
@@ -731,6 +766,8 @@ struct Books<'a> {
payees: BTreeMap<String, VendorId>, payees: BTreeMap<String, VendorId>,
/// The vendors bills came from, by name (a payee's record if one). /// The vendors bills came from, by name (a payee's record if one).
vendors: BTreeMap<String, VendorId>, vendors: BTreeMap<String, VendorId>,
/// The customers invoices went to, by name.
customers: BTreeMap<String, u64>,
/// The payment methods, by label. /// The payment methods, by label.
instruments: BTreeMap<String, InstrumentId>, instruments: BTreeMap<String, InstrumentId>,
/// The fixed assets of `setup.assets`, by id. /// The fixed assets of `setup.assets`, by id.
@@ -1181,6 +1218,7 @@ impl<'a> Books<'a> {
party_members, party_members,
parties, parties,
vendors: payees.clone(), vendors: payees.clone(),
customers: BTreeMap::new(),
payees, payees,
instruments, instruments,
assets, assets,
@@ -1241,6 +1279,7 @@ impl<'a> Books<'a> {
("reverses", e.reverses.is_some()), ("reverses", e.reverses.is_some()),
("close", e.close.is_some()), ("close", e.close.is_some()),
("bill", e.bill.is_some()), ("bill", e.bill.is_some()),
("invoice", e.invoice.is_some()),
("run_depreciation", e.run_depreciation.is_some()), ("run_depreciation", e.run_depreciation.is_some()),
("dispose_asset", e.dispose_asset.is_some()), ("dispose_asset", e.dispose_asset.is_some()),
("run_schedules", e.run_schedules.is_some()), ("run_schedules", e.run_schedules.is_some()),
@@ -1249,13 +1288,13 @@ impl<'a> Books<'a> {
let kind = match kinds.iter().filter(|(_, given)| *given).collect::<Vec<_>>()[..] { let kind = match kinds.iter().filter(|(_, given)| *given).collect::<Vec<_>>()[..] {
[(kind, _)] => *kind, [(kind, _)] => *kind,
_ => panic!( _ => panic!(
"{ex} event {n}: an event is one of lines, reverses, close, bill, run_depreciation, dispose_asset, run_schedules or register_schedule" "{ex} event {n}: an event is one of lines, reverses, close, bill, invoice, run_depreciation, dispose_asset, run_schedules or register_schedule"
), ),
}; };
// An entry of the example's own words: dated and described. A // An entry of the example's own words: dated and described. A
// disposal is dated; the other commands date their own entries, // disposal is dated; the other commands date their own entries,
// and every command describes its own. // and every command describes its own.
let own = matches!(kind, "lines" | "reverses" | "close" | "bill"); let own = matches!(kind, "lines" | "reverses" | "close" | "bill" | "invoice");
assert_eq!( assert_eq!(
e.date.is_some(), e.date.is_some(),
own || kind == "dispose_asset", own || kind == "dispose_asset",
@@ -1278,7 +1317,7 @@ impl<'a> Books<'a> {
); );
assert!( assert!(
e.payee.is_none() || kind == "lines", e.payee.is_none() || kind == "lines",
"{ex} event {n}: a payee is an entry's: a reversal takes its original's, a bill names its vendor, a close and a command have none" "{ex} event {n}: a payee is an entry's: a reversal takes its original's, a bill names its vendor, an invoice its customer, a close and a command have none"
); );
assert!( assert!(
(e.parties.is_empty() && e.instrument.is_none()) || kind != "reverses", (e.parties.is_empty() && e.instrument.is_none()) || kind != "reverses",
@@ -1289,7 +1328,7 @@ impl<'a> Books<'a> {
"{ex} event {n}: {kind} is tagged with no parties or payment method" "{ex} event {n}: {kind} is tagged with no parties or payment method"
); );
assert!( assert!(
e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill"), e.refused.is_none() || !matches!(kind, "lines" | "close" | "bill" | "invoice"),
"{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all" "{ex} event {n}: refused is for a reversal or a command, which the books take whole or not at all"
); );
let what = format!("{ex} event {n}"); let what = format!("{ex} event {n}");
@@ -1376,6 +1415,43 @@ impl<'a> Books<'a> {
} }
} }
/// An invoice's or bill's lines: `amount` (one line, described as the
/// event) or `lines`, exactly one; each project a `setup.projects`
/// name.
fn document_lines(
&self,
amount: &Option<String>,
lines: &[DocumentLineSpec],
description: &str,
what: &str,
) -> Vec<DocumentLine> {
let positive = |s: &str| {
let a = exact(s, what);
assert!(a > Decimal::ZERO, "{what}: amounts are positive");
a
};
match (amount, lines) {
(Some(a), []) => vec![DocumentLine {
description: description.to_string(),
amount: positive(a),
class_id: None,
}],
(None, [_, ..]) => lines
.iter()
.map(|l| DocumentLine {
description: l.description.clone().unwrap_or_else(|| description.into()),
amount: positive(&l.amount),
class_id: l.project.as_ref().map(|p| {
*self.projects.get(p).unwrap_or_else(|| {
panic!("{what}: project {p:?} not in setup.projects")
})
}),
})
.collect(),
_ => panic!("{what}: a document has an amount or lines, exactly one"),
}
}
/// Event `n`'s command, given to the books (`kind`, as [`Self::apply`] /// Event `n`'s command, given to the books (`kind`, as [`Self::apply`]
/// found it): whatever it posts is posted, or the books refuse it. /// found it): whatever it posts is posted, or the books refuse it.
fn take( fn take(
@@ -1459,7 +1535,7 @@ impl<'a> Books<'a> {
} }
"bill" => { "bill" => {
let b = e.bill.as_ref().expect("checked: a bill"); let b = e.bill.as_ref().expect("checked: a bill");
let amount = money(&b.amount); let lines = self.document_lines(&b.amount, &b.lines, description, what);
let vendor = match self.vendors.get(&b.vendor) { let vendor = match self.vendors.get(&b.vendor) {
Some(&v) => v, Some(&v) => v,
None => { None => {
@@ -1468,14 +1544,25 @@ impl<'a> Books<'a> {
v v
} }
}; };
let lines = vec![DocumentLine {
description: description.to_string(),
amount,
}];
self.store self.store
.create_bill(vendor, day(), day(), lines) .create_bill(vendor, day(), day(), lines)
.map(|_| ()) .map(|_| ())
} }
"invoice" => {
let i = e.invoice.as_ref().expect("checked: an invoice");
let lines = self.document_lines(&i.amount, &i.lines, description, what);
let customer = match self.customers.get(&i.customer) {
Some(&c) => c,
None => {
let c = self.store.add_customer(&i.customer)?;
self.customers.insert(i.customer.clone(), c);
c
}
};
self.store
.create_invoice(customer, day(), day(), lines)
.map(|_| ())
}
"run_depreciation" => { "run_depreciation" => {
let r = e.run_depreciation.as_ref().expect("checked: a run"); let r = e.run_depreciation.as_ref().expect("checked: a run");
let asset = self.asset(&r.asset, what); let asset = self.asset(&r.asset, what);
@@ -3751,3 +3838,46 @@ fn a_project_query_leaving_a_value_out_is_refused() {
t.replacen("unrecovered_cost = \"1460.00\"\n", "", 1) t.replacen("unrecovered_cost = \"1460.00\"\n", "", 1)
}); });
} }
/// An invoice's line naming a project the setup doesn't have is refused.
#[test]
#[should_panic(expected = "project \"Gamma\" not in setup.projects")]
fn a_document_line_naming_an_unknown_project_is_refused() {
run_edited(
"projects-invoice-and-bill-lines-count-by-project.toml",
|t| t.replacen("project = \"Harbor\" }", "project = \"Gamma\" }", 1),
);
}
/// A document has an amount or lines, exactly one.
#[test]
#[should_panic(expected = "a document has an amount or lines, exactly one")]
fn a_document_with_an_amount_and_lines_is_refused() {
run_edited(
"projects-invoice-and-bill-lines-count-by-project.toml",
|t| {
t.replacen(
"invoice = { customer = \"Cedar Client\", lines",
"invoice = { customer = \"Cedar Client\", amount = \"1.00\", lines",
1,
)
},
);
}
/// An invoice's projects count: the example fails when a line's project
/// moves (Zeta's figures are no longer the ones worked by hand).
#[test]
#[should_panic(expected = "projects-invoice-and-bill-lines-count-by-project project \"Zeta\"")]
fn an_invoice_lines_project_is_what_the_project_counts() {
run_edited(
"projects-invoice-and-bill-lines-count-by-project.toml",
|t| {
t.replacen(
"{ description = \"Revisions\", amount = \"500.00\", project = \"Zeta\" }",
"{ description = \"Revisions\", amount = \"500.00\", project = \"Harbor\" }",
1,
)
},
);
}
+1
View File
@@ -154,6 +154,7 @@ fn books() -> Ledger {
vec![DocumentLine { vec![DocumentLine {
description: "Design work".into(), description: "Design work".into(),
amount: dec("1500"), amount: dec("1500"),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
+1
View File
@@ -978,6 +978,7 @@ fn doc(amount: u64) -> Vec<ledger_core::DocumentLine> {
vec![ledger_core::DocumentLine { vec![ledger_core::DocumentLine {
description: "work".into(), description: "work".into(),
amount: Decimal::from(amount), amount: Decimal::from(amount),
class_id: None,
}] }]
} }
+3 -1
View File
@@ -36,6 +36,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
DocumentLine { DocumentLine {
description: desc.to_string(), description: desc.to_string(),
amount: dec(amount), amount: dec(amount),
class_id: None,
} }
} }
@@ -380,7 +381,7 @@ proptest! {
) { ) {
let lines: Vec<DocumentLine> = amts let lines: Vec<DocumentLine> = amts
.iter() .iter()
.map(|c| DocumentLine { description: "l".into(), amount: cents(*c) }) .map(|c| DocumentLine { description: "l".into(), amount: cents(*c), class_id: None })
.collect(); .collect();
let ok = !amts.is_empty() && amts.iter().all(|c| *c > 0); let ok = !amts.is_empty() && amts.iter().all(|c| *c > 0);
prop_assert_eq!(validate_lines(&lines).is_ok(), ok); prop_assert_eq!(validate_lines(&lines).is_ok(), ok);
@@ -1083,6 +1084,7 @@ fn doc_lines(doc: &Doc) -> Vec<DocumentLine> {
.map(|c| DocumentLine { .map(|c| DocumentLine {
description: "item".into(), description: "item".into(),
amount: cents(*c), amount: cents(*c),
class_id: None,
}) })
.collect() .collect()
} }
+5
View File
@@ -141,6 +141,7 @@ fn dates_outside_the_calendar_are_refused_everywhere() {
let line = vec![DocumentLine { let line = vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: Decimal::ONE, amount: Decimal::ONE,
class_id: None,
}]; }];
assert_eq!( assert_eq!(
b.ledger b.ledger
@@ -219,6 +220,7 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
let line = vec![DocumentLine { let line = vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: Decimal::ONE, amount: Decimal::ONE,
class_id: None,
}]; }];
b.ledger b.ledger
.replay_invoice(1, customer, NaiveDate::MIN, NaiveDate::MAX, line.clone(), 1) .replay_invoice(1, customer, NaiveDate::MIN, NaiveDate::MAX, line.clone(), 1)
@@ -326,10 +328,12 @@ fn amounts_larger_than_a_line_are_refused_everywhere() {
DocumentLine { DocumentLine {
description: "a".into(), description: "a".into(),
amount: a, amount: a,
class_id: None,
}, },
DocumentLine { DocumentLine {
description: "b".into(), description: "b".into(),
amount: Decimal::ONE, amount: Decimal::ONE,
class_id: None,
}, },
] ]
}; };
@@ -617,6 +621,7 @@ fn replayed_payments_are_as_journaled() {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: Decimal::new(10000, 2), amount: Decimal::new(10000, 2),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -2085,6 +2085,7 @@ fn a_bill_payment_carries_its_bills_split() {
vec![ledger_core::DocumentLine { vec![ledger_core::DocumentLine {
description: "Equipment and supplies".into(), description: "Equipment and supplies".into(),
amount: x("1700.00"), amount: x("1700.00"),
class_id: None,
}], }],
entry, entry,
) )
@@ -2509,6 +2510,7 @@ fn a_reversed_bill_payment_leaves_the_purchase_unpaid() {
vec![ledger_core::DocumentLine { vec![ledger_core::DocumentLine {
description: "Equipment".into(), description: "Equipment".into(),
amount: x("1000.00"), amount: x("1000.00"),
class_id: None,
}], }],
entry, entry,
) )
@@ -0,0 +1,7 @@
# Seeds for failure cases proptest has generated in the past. It is
# automatically read and these particular cases re-run before any
# novel cases are generated.
#
# It is recommended to check this file in to source control so that
# everyone who runs the test benefits from these saved cases.
cc cdb5206c6d8016c327396d8037b466e9bccd1d04e86be907372108cbbf668f88 # shrinks to raw = [(1, Some(0)), (1, Some(0))]
@@ -0,0 +1,465 @@
//! An invoice's or bill's lines name their projects (rules.md B-37 item
//! 8): the posted entry's revenue or expense side is one line per project,
//! the untagged lines' as one; the receivable or payable line, and the
//! payments, never take one.
//!
//! ```text
//! GOAL: a client project's invoiced revenue and a contractor's billed
//! cost count toward the project, every other figure as before.
//! SOURCE: docs/spec/rules.md B-37 items 3 and 8 (job costing), B-14 (the
//! projects with the untagged lines partition the P&L), B-19 and
//! B-35 (1099 and party figures follow the money, not the tags).
//! ```
use chrono::NaiveDate;
use ledger_core::arap::{DocumentLine, document_parts};
use ledger_core::form1099::{PaymentAccounts, vendor_1099_report};
use ledger_core::parties::{MoneyAccounts, activity};
use ledger_core::reports::profit_and_loss_by_class;
use ledger_core::{
AccountType, ClassId, Currency, Ledger, LedgerError, ProjectFacts, ProjectStatus, Side,
project_entries, project_to_date,
};
use proptest::prelude::*;
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 line(what: &str, c: i64, class: Option<ClassId>) -> DocumentLine {
DocumentLine {
description: what.into(),
amount: cents(c),
class_id: class,
}
}
/// Cash 1000 and two projects, Zeta (client) and Harbor.
struct Books {
l: Ledger,
cash: u64,
zeta: ClassId,
harbor: ClassId,
}
fn books() -> Books {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let zeta = l.add_class("Zeta").unwrap();
let harbor = l.add_class("Harbor").unwrap();
Books {
l,
cash,
zeta,
harbor,
}
}
/// Each line of `entry`: (code, side, amount, class).
fn lines_of(l: &Ledger, entry: u64) -> Vec<(String, Side, Decimal, Option<ClassId>)> {
l.get_entry(entry)
.unwrap()
.lines()
.iter()
.map(|x| {
(
l.chart().get(x.account).unwrap().code.clone(),
x.side,
x.amount.amount,
x.class_id,
)
})
.collect()
}
#[test]
fn an_invoices_revenue_is_one_line_per_project_and_the_receivable_none() {
let mut b = books();
let (z, h) = (b.zeta, b.harbor);
let c = b.l.add_customer("Cedar").unwrap();
let inv =
b.l.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![
line("Design", 300_000, Some(z)),
line("Hosting", 20_000, Some(h)),
line("Rush fee", 10_000, None),
line("Revisions", 50_000, Some(z)),
],
)
.unwrap();
let e = b.l.get_invoice(inv).unwrap().entry_id;
assert_eq!(
lines_of(&b.l, e),
vec![
("1200".into(), Side::Debit, cents(380_000), None),
("4000".into(), Side::Credit, cents(350_000), Some(z)),
("4000".into(), Side::Credit, cents(20_000), Some(h)),
("4000".into(), Side::Credit, cents(10_000), None),
]
);
// The invoice keeps its lines as given, projects and all.
let kept = &b.l.get_invoice(inv).unwrap().lines;
assert_eq!(kept[3].class_id, Some(z));
assert_eq!(
b.l.get_invoice(inv).unwrap().total().unwrap(),
cents(380_000)
);
// Its revenue counts toward each project, to the cent.
let zt = project_to_date(&b.l, z, d(2026, 12, 31)).unwrap();
assert_eq!((zt.revenue, zt.expenses), (cents(350_000), Decimal::ZERO));
assert_eq!(
project_to_date(&b.l, h, d(2026, 12, 31)).unwrap().revenue,
cents(20_000)
);
assert_eq!(project_entries(&b.l, z), vec![(e, cents(350_000))]);
// The payment is balance-sheet only: no project, no project figure.
let paid =
b.l.receive_payment(inv, d(2026, 3, 20), cents(380_000), b.cash)
.unwrap();
assert!(lines_of(&b.l, paid).iter().all(|l| l.3.is_none()));
assert_eq!(project_to_date(&b.l, z, d(2026, 12, 31)).unwrap(), zt);
}
#[test]
fn a_bills_cost_is_one_line_per_project_and_the_payable_none() {
let mut b = books();
let (z, h) = (b.zeta, b.harbor);
let v = b.l.add_vendor("Dana Illustration").unwrap();
let bill =
b.l.create_bill(
v,
d(2026, 3, 9),
d(2026, 4, 8),
vec![
line("Office supplies", 5_000, None),
line("Illustration", 120_000, Some(z)),
line("Icons", 30_000, Some(h)),
],
)
.unwrap();
let e = b.l.get_bill(bill).unwrap().entry_id;
assert_eq!(
lines_of(&b.l, e),
vec![
("6999".into(), Side::Debit, cents(5_000), None),
("6999".into(), Side::Debit, cents(120_000), Some(z)),
("6999".into(), Side::Debit, cents(30_000), Some(h)),
("2000".into(), Side::Credit, cents(155_000), None),
]
);
let zt = project_to_date(&b.l, z, d(2026, 12, 31)).unwrap();
assert_eq!((zt.revenue, zt.expenses), (Decimal::ZERO, cents(120_000)));
let paid =
b.l.pay_bill(bill, d(2026, 3, 30), cents(155_000), b.cash)
.unwrap();
assert!(lines_of(&b.l, paid).iter().all(|l| l.3.is_none()));
assert_eq!(project_to_date(&b.l, z, d(2026, 12, 31)).unwrap(), zt);
}
/// Reversing a tagged invoice's or bill's entry carries each line's
/// project to the reversal (B-37 item 3: each line on its normal side), so
/// every project nets back to zero; a partial payment, cash against the
/// receivable, moves no project figure.
#[test]
fn reversing_a_tagged_document_takes_its_projects_to_zero() {
let mut b = books();
let (z, h) = (b.zeta, b.harbor);
let c = b.l.add_customer("Cedar").unwrap();
let v = b.l.add_vendor("Dana").unwrap();
let inv =
b.l.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![
line("Design", 300_000, Some(z)),
line("Hosting", 20_000, Some(h)),
line("Fee", 10_000, None),
],
)
.unwrap();
let bill =
b.l.create_bill(
v,
d(2026, 3, 9),
d(2026, 4, 8),
vec![line("Illustration", 120_000, Some(z))],
)
.unwrap();
let to_date = |l: &Ledger, p| {
let t = project_to_date(l, p, d(2026, 12, 31)).unwrap();
(t.revenue, t.expenses)
};
assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000)));
b.l.receive_payment(inv, d(2026, 3, 20), cents(100_000), b.cash)
.unwrap();
assert_eq!(to_date(&b.l, z), (cents(300_000), cents(120_000)));
assert_eq!(to_date(&b.l, h), (cents(20_000), Decimal::ZERO));
for e in [
b.l.get_invoice(inv).unwrap().entry_id,
b.l.get_bill(bill).unwrap().entry_id,
] {
let r = b.l.reverse_entry(e, d(2026, 3, 31), "Reverse").unwrap();
let original: Vec<_> = lines_of(&b.l, e).into_iter().map(|l| l.3).collect();
let reversed: Vec<_> = lines_of(&b.l, r).into_iter().map(|l| l.3).collect();
assert_eq!(reversed, original, "entry {e}");
}
// Each line on its normal side: a reversed revenue line is a debit to
// revenue, so the project's revenue and expenses are both zero.
assert_eq!(to_date(&b.l, z), (Decimal::ZERO, Decimal::ZERO));
assert_eq!(to_date(&b.l, h), (Decimal::ZERO, Decimal::ZERO));
let by_class = profit_and_loss_by_class(&b.l).unwrap();
assert!(!by_class.is_empty());
for r in &by_class {
assert_eq!(r.profit_and_loss.net_income(), Decimal::ZERO, "{r:?}");
}
}
/// A document naming no project posts exactly what every document posted
/// before projects: one revenue (or expense) line for its total.
#[test]
fn an_untagged_document_posts_as_before() {
let mut b = books();
let c = b.l.add_customer("Cedar").unwrap();
let v = b.l.add_vendor("Dana").unwrap();
let inv =
b.l.create_invoice(
c,
d(2026, 1, 5),
d(2026, 2, 4),
vec![line("A", 10_000, None), line("B", 2_550, None)],
)
.unwrap();
let bill =
b.l.create_bill(v, d(2026, 1, 6), d(2026, 2, 5), vec![line("C", 999, None)])
.unwrap();
assert_eq!(
lines_of(&b.l, b.l.get_invoice(inv).unwrap().entry_id),
vec![
("1200".into(), Side::Debit, cents(12_550), None),
("4000".into(), Side::Credit, cents(12_550), None),
]
);
assert_eq!(
lines_of(&b.l, b.l.get_bill(bill).unwrap().entry_id),
vec![
("6999".into(), Side::Debit, cents(999), None),
("2000".into(), Side::Credit, cents(999), None),
]
);
}
#[test]
fn a_line_naming_an_unknown_project_is_refused_whole() {
let mut b = books();
let c = b.l.add_customer("Cedar").unwrap();
let v = b.l.add_vendor("Dana").unwrap();
let before = b.l.clone();
assert_eq!(
b.l.create_invoice(
c,
d(2026, 1, 5),
d(2026, 2, 4),
vec![line("A", 100, Some(b.zeta)), line("B", 100, Some(77))],
),
Err(LedgerError::UnknownClass(77))
);
assert_eq!(
b.l.create_bill(
v,
d(2026, 1, 5),
d(2026, 2, 4),
vec![line("A", 100, Some(78))]
),
Err(LedgerError::UnknownClass(78))
);
assert!(b.l.differs_from(&before).is_empty(), "nothing changed");
}
/// Finished and abandoned projects still take a document's lines (B-37
/// item 5: a late cost, a final invoice).
#[test]
fn a_finished_project_still_takes_an_invoice() {
let mut b = books();
b.l.set_project(
b.zeta,
"Zeta",
ProjectFacts {
status: ProjectStatus::Finished,
started_on: Some(d(2026, 1, 1)),
ended_on: Some(d(2026, 2, 28)),
..Default::default()
},
)
.unwrap();
let c = b.l.add_customer("Cedar").unwrap();
b.l.create_invoice(
c,
d(2026, 3, 15),
d(2026, 4, 14),
vec![line("Final", 40_000, Some(b.zeta))],
)
.unwrap();
assert_eq!(
project_to_date(&b.l, b.zeta, d(2026, 12, 31))
.unwrap()
.revenue,
cents(40_000)
);
}
/// The same books twice, one with its documents' lines tagged: every
/// figure that isn't a project's is the same (the P&L, the 1099
/// worksheet, a party's totals), and the tagged books' projects with the
/// untagged lines partition the P&L.
#[test]
fn tags_move_only_the_project_figures() {
let build = |tagged: bool| {
let mut b = books();
let tag = |c: ClassId| tagged.then_some(c);
let (z, h, cash) = (b.zeta, b.harbor, b.cash);
let c = b.l.add_customer("Cedar").unwrap();
let v = b.l.add_vendor("Dana").unwrap();
b.l.set_vendor_1099(
v,
ledger_core::form1099::Dated1099::from_beginning(
ledger_core::form1099::Decision1099::Files(ledger_core::form1099::Form1099::Nec),
None,
),
false,
)
.unwrap();
let inv =
b.l.create_invoice(
c,
d(2026, 2, 1),
d(2026, 3, 3),
vec![
line("Design", 500_000, tag(z)),
line("App", 80_000, tag(h)),
line("Fee", 1_000, None),
],
)
.unwrap();
b.l.receive_payment(inv, d(2026, 2, 20), cents(581_000), cash)
.unwrap();
let bill =
b.l.create_bill(
v,
d(2026, 2, 5),
d(2026, 3, 7),
vec![
line("Illustration", 180_000, tag(z)),
line("Misc", 2_500, None),
],
)
.unwrap();
b.l.pay_bill(bill, d(2026, 2, 25), cents(182_500), cash)
.unwrap();
(b, c, v)
};
let (plain, _, v) = build(false);
let (tagged, _, _) = build(true);
let year = (d(2026, 1, 1), d(2026, 12, 31));
let pnl = |l: &Ledger| ledger_core::profit_and_loss_for_period(l, year.0, year.1, None);
assert_eq!(
format!("{:?}", pnl(&plain.l).unwrap()),
format!("{:?}", pnl(&tagged.l).unwrap())
);
let sources = PaymentAccounts {
cash: vec![plain.cash],
financing: vec![],
};
let w = |l: &Ledger| {
format!(
"{:?}",
vendor_1099_report(l, 2026, None, &sources, &[], None)
)
};
assert_eq!(w(&plain.l), w(&tagged.l));
assert!(w(&tagged.l).contains("1825.00"), "{}", w(&tagged.l));
let money = MoneyAccounts {
cash: [plain.cash].into_iter().collect(),
liabilities: Default::default(),
};
let party = plain.l.party_of_vendor(v).unwrap();
let a = |l: &Ledger| format!("{:?}", activity(l, party, year, &money));
assert_eq!(a(&plain.l), a(&tagged.l));
// The tagged books: Zeta 5,000.00 - 1,800.00, Harbor 800.00, and the
// untagged lines 10.00 - 25.00; together the P&L's net.
let rows = profit_and_loss_by_class(&tagged.l).unwrap();
let net = |name: Option<&str>| {
let r = rows
.iter()
.find(|r| r.class.as_ref().map(|c| c.name.as_str()) == name)
.unwrap();
r.profit_and_loss.net_income()
};
assert_eq!(net(Some("Zeta")), cents(320_000));
assert_eq!(net(Some("Harbor")), cents(80_000));
assert_eq!(net(None), cents(-1_500));
let whole = pnl(&tagged.l).unwrap();
assert_eq!(cents(320_000 + 80_000 - 1_500), whole.net_income());
let z = project_to_date(&tagged.l, tagged.zeta, year.1).unwrap();
assert_eq!((z.revenue, z.expenses), (cents(500_000), cents(180_000)));
let none = project_to_date(&plain.l, plain.zeta, year.1).unwrap();
assert!(!none.any_tagged());
}
proptest! {
/// arap::document_parts INVARIANTS 1-3: the parts sum to the total,
/// each project once and in the order first named, the untagged lines
/// one part, and a document naming none one part.
#[test]
fn prop_document_parts_partition_the_total(
raw in prop::collection::vec((1i64..=10_000_000, prop::option::of(0u32..4)), 1..12)
) {
let lines: Vec<DocumentLine> = raw
.iter()
.map(|&(c, k)| line("x", c, k))
.collect();
let parts = document_parts(&lines, "doc").unwrap();
let total: Decimal = lines.iter().map(|l| l.amount).sum();
prop_assert_eq!(parts.iter().map(|p| p.1).sum::<Decimal>(), total);
let mut first: Vec<Option<ClassId>> = Vec::new();
for l in &lines {
if !first.contains(&l.class_id) {
first.push(l.class_id);
}
}
prop_assert_eq!(parts.iter().map(|p| p.0).collect::<Vec<_>>(), first);
for (k, amount) in &parts {
let sum: Decimal = lines.iter().filter(|l| l.class_id == *k).map(|l| l.amount).sum();
prop_assert_eq!(*amount, sum);
prop_assert!(*amount > Decimal::ZERO);
}
if lines.iter().all(|l| l.class_id.is_none()) {
prop_assert_eq!(parts, vec![(None, total)]);
}
}
}
/// document_parts INVARIANT 4: a sum past exact arithmetic is refused,
/// never a panic.
#[test]
fn document_parts_refuse_past_exact_arithmetic() {
let big = DocumentLine {
description: "x".into(),
amount: Decimal::MAX,
class_id: Some(1),
};
assert!(matches!(
document_parts(&[big.clone(), big], "doc"),
Err(LedgerError::TooLargeToCompute(_))
));
}
+2
View File
@@ -1913,6 +1913,7 @@ fn one_line(amount: &str) -> Vec<DocumentLine> {
vec![DocumentLine { vec![DocumentLine {
description: "consulting".into(), description: "consulting".into(),
amount: dec(amount), amount: dec(amount),
class_id: None,
}] }]
} }
@@ -2796,6 +2797,7 @@ fn a_new_account_named_like_a_sibling_or_holding_a_tin_is_refused() {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: dec("100.00"), amount: dec("100.00"),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -465,6 +465,7 @@ fn audit() -> Audit {
vec![DocumentLine { vec![DocumentLine {
description: "filing".into(), description: "filing".into(),
amount: d("80.00"), amount: d("80.00"),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -482,6 +483,7 @@ fn audit() -> Audit {
vec![DocumentLine { vec![DocumentLine {
description: "consulting".into(), description: "consulting".into(),
amount: d("120.00"), amount: d("120.00"),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -292,6 +292,7 @@ fn documents_and_aging_are_exact_or_refused() {
let line = |a: Decimal| DocumentLine { let line = |a: Decimal| DocumentLine {
description: "x".into(), description: "x".into(),
amount: a, amount: a,
class_id: None,
}; };
let fine = vec![ let fine = vec![
line(Decimal::new(1, 11)), line(Decimal::new(1, 11)),
+1
View File
@@ -41,6 +41,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
DocumentLine { DocumentLine {
description: desc.to_string(), description: desc.to_string(),
amount: dec(amount), amount: dec(amount),
class_id: None,
} }
} }
+5 -8
View File
@@ -246,11 +246,11 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
let client_class = s.add_project( let client_class = s.add_project(
"Client work", "Client work",
&ledger_core::ProjectFacts { &ledger_core::ProjectFacts {
// Its invoices can't be tagged to it: an invoice posts one // Its invoices' lines and the contractors it pays are tagged
// untagged revenue line (Ledger::create_invoice takes no // to it (rules.md B-37 item 8), so its snapshot is its
// class), so only the contractors it pays show here, said so. // revenue less its direct costs.
description: Some( description: Some(
"Design work for the studio's clients. Only its contractors are tagged to it: an invoice can't carry a project yet, so what the clients pay shows on the profit and loss, not here." "Design work for the studio's clients: every client invoice, and the contractors who work on it."
.into(), .into(),
), ),
kind: Some(ledger_core::ProjectKind::Client), kind: Some(ledger_core::ProjectKind::Client),
@@ -763,12 +763,9 @@ pub fn build(dir: &Path, today: NaiveDate) -> Result<(), StoreError> {
][rng.pick(5) as usize] ][rng.pick(5) as usize]
.into(), .into(),
amount: amt, amount: amt,
class_id: Some(client_class),
}], }],
)?; )?;
let entry = s.ledger().get_invoice(inv).map(|i| i.entry_id);
if let Some(e) = entry {
let _ = e;
}
open_invoices.push((inv, date, amt)); open_invoices.push((inv, date, amt));
} }
// Collect invoices 20-50 days out; leave the latest ones open and one overdue. // Collect invoices 20-50 days out; leave the latest ones open and one overdue.
+1
View File
@@ -663,6 +663,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: cents(c), amount: cents(c),
class_id: None,
}] }]
}; };
let acme = b.s.add_customer("Acme").unwrap(); let acme = b.s.add_customer("Acme").unwrap();
+1
View File
@@ -802,6 +802,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: cents(c), amount: cents(c),
class_id: None,
}] }]
}; };
let acme = b.s.add_customer("Acme").unwrap(); let acme = b.s.add_customer("Acme").unwrap();
+216 -18
View File
@@ -10,6 +10,7 @@ use crate::theme::{self, pal};
use crate::widgets; use crate::widgets;
use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2}; use egui::{CornerRadius, Margin, RichText, Stroke, Ui, vec2};
use egui_phosphor::regular as icon; use egui_phosphor::regular as icon;
use ledger_core::arap::DocumentLine;
use ledger_core::{EntryStatus, Side}; use ledger_core::{EntryStatus, Side};
use ledger_store::Attachment; use ledger_store::Attachment;
use rust_decimal::Decimal; use rust_decimal::Decimal;
@@ -231,26 +232,18 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
ui.allocate_ui(vec2(w, 22.0), |ui| { ui.allocate_ui(vec2(w, 22.0), |ui| {
ui.set_width(w); ui.set_width(w);
ui.horizontal(|ui| { ui.horizontal(|ui| {
let r = widgets::link_truncated( let tag = project_tag(line.class_id, &class_names);
ui, let (r, project) = name_and_tag(ui, tag.as_ref(), |ui| {
RichText::new(b.account_label(line.account)) widgets::link_truncated(
.font(theme::regular(13.5)), ui,
); RichText::new(b.account_label(line.account))
.font(theme::regular(13.5)),
)
});
if r.clicked() { if r.clicked() {
shell.go(Route::Account(line.account)); shell.go(Route::Account(line.account));
} }
if let Some((cid, c)) = line if let Some(cid) = project {
.class_id
.and_then(|c| class_names.get(&c).map(|n| (c, n)))
&& widgets::link(
ui,
RichText::new(format!("{} {c}", icon::TAG))
.font(theme::regular(11.5))
.color(p.muted),
)
.on_hover_text("Open this project")
.clicked()
{
shell.go(Route::Project(cid)); shell.go(Route::Project(cid));
} }
if let (Some(cur), Some(fa)) = if let (Some(cur), Some(fa)) =
@@ -308,6 +301,14 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
}); });
ui.add_space(18.0); ui.add_space(18.0);
// The invoice or bill it records: its lines as the document
// gives them, each project a link (rules.md B-37 item 8).
if let Some(doc) = document_of(b, e.id) {
section(ui, &doc.title);
document_lines(ui, &doc, &class_names, total_w, amt_w, shell);
ui.add_space(18.0);
}
// Receipts and documents: filed on it, linked to it, or linked // Receipts and documents: filed on it, linked to it, or linked
// to the invoice, bill, or payroll run it records; a withdrawn // to the invoice, bill, or payroll run it records; a withdrawn
// receipt filed on it stays, marked. // receipt filed on it stays, marked.
@@ -406,6 +407,144 @@ pub fn detail(ui: &mut Ui, b: &Books, e: &EntryInfo, shell: &mut Shell<'_>) {
}); });
} }
/// The invoice or bill an entry records: its name and due date, and its
/// lines.
pub struct DocumentOf<'a> {
/// "Invoice 3, due Oct 1, 2026".
pub title: String,
/// Its lines, as journaled.
pub lines: &'a [DocumentLine],
}
/// The invoice or bill whose entry is `entry`, if it records one.
pub fn document_of(b: &Books, entry: ledger_core::EntryId) -> Option<DocumentOf<'_>> {
let due = |what: &str, id: u64, due: chrono::NaiveDate| {
format!("{what} {id}, due {}", fmt::date(due))
};
b.ledger
.invoices()
.into_iter()
.find(|i| i.entry_id == entry)
.map(|i| DocumentOf {
title: due("Invoice", i.id, i.due_date),
lines: &i.lines,
})
.or_else(|| {
b.ledger
.bills()
.into_iter()
.find(|x| x.entry_id == entry)
.map(|x| DocumentOf {
title: due("Bill", x.id, x.due_date),
lines: &x.lines,
})
})
}
/// A document's lines: each description, its project (a link that opens
/// it), and its amount.
fn document_lines(
ui: &mut Ui,
doc: &DocumentOf<'_>,
class_names: &std::collections::HashMap<u32, String>,
total_w: f32,
amt_w: f32,
shell: &mut Shell<'_>,
) {
let p = pal(ui.ctx());
egui::Frame::new()
.stroke(Stroke::new(1.0, p.border))
.corner_radius(CornerRadius::same(10))
.inner_margin(Margin::symmetric(12, 8))
.show(ui, |ui| {
ui.set_width(total_w - 26.0);
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w - 8.0;
let (r, _) = ui.allocate_exact_size(vec2(w, 18.0), egui::Sense::hover());
ui.painter().text(
r.left_center(),
egui::Align2::LEFT_CENTER,
"Line",
theme::regular(12.0),
p.muted,
);
widgets::amount_cell(ui, "Amount", amt_w, p.muted, false, false);
});
for line in doc.lines {
ui.horizontal(|ui| {
let w = ui.available_width() - amt_w - 8.0;
ui.allocate_ui(vec2(w, 22.0), |ui| {
ui.set_width(w);
ui.horizontal(|ui| {
let tag = project_tag(line.class_id, class_names);
let (_, project) = name_and_tag(ui, tag.as_ref(), |ui| {
ui.add(
egui::Label::new(
RichText::new(&line.description).font(theme::regular(13.5)),
)
.truncate(),
)
});
if let Some(cid) = project {
shell.go(Route::Project(cid));
}
});
});
widgets::amount_cell(ui, &fmt::money(line.amount), amt_w, p.text, false, false);
});
}
});
}
/// A line's project tag: its id and the text it shows.
fn project_tag(
class: Option<u32>,
class_names: &std::collections::HashMap<u32, String>,
) -> Option<(u32, String)> {
let c = class?;
class_names
.get(&c)
.map(|n| (c, format!("{} {n}", icon::TAG)))
}
/// `name` (a truncating label), then `tag` as a link that opens its
/// project: the project, when clicked. The tag's width (at most half the
/// cell, cut with an ellipsis past it) is kept back from the name, so a
/// tagged line never grows past its cell.
fn name_and_tag(
ui: &mut Ui,
tag: Option<&(u32, String)>,
name: impl FnOnce(&mut Ui) -> egui::Response,
) -> (egui::Response, Option<u32>) {
let p = pal(ui.ctx());
let avail = ui.available_width();
let gap = ui.spacing().item_spacing.x;
let tag_w = tag.map_or(0.0, |(_, t)| {
ui.painter()
.layout_no_wrap(t.clone(), theme::regular(11.5), p.muted)
.size()
.x
.min(avail / 2.0)
});
let room = if tag.is_some() { tag_w + gap } else { 0.0 };
let r = ui
.allocate_ui(vec2((avail - room).max(0.0), 22.0), name)
.inner;
let go = tag.and_then(|(cid, t)| {
ui.allocate_ui(vec2(tag_w, 22.0), |ui| {
widgets::link_truncated(
ui,
RichText::new(t).font(theme::regular(11.5)).color(p.muted),
)
})
.inner
.on_hover_text("Open this project")
.clicked()
.then_some(*cid)
});
(r, go)
}
pub fn section(ui: &mut Ui, title: &str) { pub fn section(ui: &mut Ui, title: &str) {
let p = pal(ui.ctx()); let p = pal(ui.ctx());
ui.label( ui.label(
@@ -588,7 +727,66 @@ pub fn share_text(
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
use crate::test_support::{Build, d, find, old_books, render}; use crate::test_support::{Build, d, find, old_books, render, render_at};
/// A tagged line keeps its project tag inside its cell, in a narrow
/// pane too: the name is cut to leave the tag room, so the amounts
/// stay in their columns (an invoice's revenue line and its document
/// line, each tagged; rules.md B-37 item 8).
#[test]
fn a_tagged_line_keeps_its_amounts_in_their_column() {
use ledger_core::arap::DocumentLine;
let mut b = Build::new("entry-tag-room");
let project =
b.s.add_project(
"A project with a rather long name indeed",
&ledger_core::ProjectFacts::default(),
)
.unwrap();
b.s.add_account(
"4000",
"Revenue from design services and consulting",
ledger_core::AccountType::Revenue,
None,
false,
)
.unwrap();
let c = b.s.add_customer("Cedar").unwrap();
let inv =
b.s.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![DocumentLine {
description: "Brand identity, website and a long tail of revisions".into(),
amount: Decimal::new(123_456, 2),
class_id: Some(project),
}],
)
.unwrap();
let entry = b.s.ledger().get_invoice(inv).unwrap().entry_id;
let (_dir, books) = b.books();
let texts = render_at(books, Route::Entry(entry), 1000.0);
// The amount: the receivable's debit, the revenue's credit, both
// totals, the document line's; every one ends at a column's edge,
// the credits at one, the debits at another.
let pane = find(&texts, "LINES").pos.x;
let mut edges: Vec<i32> = texts
.iter()
.filter(|t| t.text == fmt::money(Decimal::new(123_456, 2)) && t.pos.x > pane)
.map(|t| (t.pos.x + t.width).round() as i32)
.collect();
assert_eq!(edges.len(), 5, "{edges:?}");
edges.sort_unstable();
edges.dedup();
assert_eq!(
edges.len(),
2,
"the debit column and the credit column: {edges:?}"
);
let tag = format!("{} A project with a rather long name indeed", icon::TAG);
assert_eq!(texts.iter().filter(|t| t.text == tag).count(), 2);
}
/// An entry's totals line: "Balanced" when its debits equal its /// An entry's totals line: "Balanced" when its debits equal its
/// credits, "Out of balance" when they don't, "Too large to total" /// credits, "Out of balance" when they don't, "Too large to total"
+1
View File
@@ -1709,6 +1709,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: cents(c), amount: cents(c),
class_id: None,
}] }]
}; };
// Owed, not yet due; owed, due today; paid, past due; owed, past // Owed, not yet due; owed, due today; paid, past due; owed, past
+1
View File
@@ -448,6 +448,7 @@ mod tests {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: cents(c), amount: cents(c),
class_id: None,
}] }]
}; };
let acme = b.s.add_customer("Acme").unwrap(); let acme = b.s.add_customer("Acme").unwrap();
+13
View File
@@ -197,6 +197,19 @@ pub(crate) fn render(
(texts, p) (texts, p)
} }
/// [`render`] in a window `width` points wide (a narrow one squeezes the
/// detail pane).
pub(crate) fn render_at(books: Books, route: Route, width: f32) -> Vec<Painted> {
let mut h = Harness::builder()
.with_size(egui::vec2(width, 2400.0))
.build_eframe(|cc| App::with_books(&cc.egui_ctx, books, false, route));
h.run_steps(4);
let ctx = h.ctx.clone();
h.state_mut().settle(&ctx);
h.run_steps(4);
painted(h.output())
}
/// [`render`], with the filled rectangles too. /// [`render`], with the filled rectangles too.
pub(crate) fn render_all( pub(crate) fn render_all(
books: Books, books: Books,
+1
View File
@@ -74,6 +74,7 @@ fn doc(c: i64) -> Vec<DocumentLine> {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: cents(c), amount: cents(c),
class_id: None,
}] }]
} }
+163
View File
@@ -1252,3 +1252,166 @@ proptest::proptest! {
proptest::prop_assert_eq!(s.standing, want); proptest::prop_assert_eq!(s.standing, want);
} }
} }
/// rules.md B-37 item 8: an invoice's or bill's page (its entry's) lists
/// the document's lines, each project a link that opens it; an untagged
/// line has none. The entry's own lines show the tagged revenue or cost
/// line too.
#[test]
fn an_invoice_and_a_bill_show_each_lines_project() {
use ledger_core::arap::DocumentLine;
let dir = TempDir(std::env::temp_dir().join(format!(
"ledger-gui-projects-documents-{}",
std::process::id()
)));
let _ = std::fs::remove_dir_all(&dir.0);
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Cash", AccountType::Asset, None, false)
.unwrap();
let zeta = s
.add_project(
"Zeta site",
&ProjectFacts {
kind: Some(ProjectKind::Client),
..Default::default()
},
)
.unwrap();
let harbor = s.add_class("Harbor app").unwrap();
let c = s.add_customer("Cedar Client").unwrap();
let v = s.add_vendor("Dana Illustration").unwrap();
let line = |what: &str, c: i64, class| DocumentLine {
description: what.into(),
amount: cents(c),
class_id: class,
};
let inv = s
.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![
line("Design", 300_000, Some(zeta)),
line("Rush fee", 10_000, None),
],
)
.unwrap();
let bill = s
.create_bill(
v,
d(2026, 3, 9),
d(2026, 4, 8),
vec![
line("Illustration", 120_000, Some(harbor)),
line("Postage", 5_000, None),
],
)
.unwrap();
let inv_entry = s.ledger().get_invoice(inv).unwrap().entry_id;
let bill_entry = s.ledger().get_bill(bill).unwrap().entry_id;
drop(s);
let tag = |name: &str| format!("{} {name}", egui_phosphor::regular::TAG);
for (entry, title, kept, untagged, project, id, shot_name) in [
(
inv_entry,
"INVOICE 1, DUE APR 1, 2026",
"Design",
"Rush fee",
"Zeta site",
zeta,
"invoice-lines",
),
(
bill_entry,
"BILL 1, DUE APR 8, 2026",
"Illustration",
"Postage",
"Harbor app",
harbor,
"bill-lines",
),
] {
let books = load(&dir);
let mut h = Harness::builder()
.with_size(egui::vec2(1440.0, 1400.0))
.build_eframe(move |cc| {
App::with_books(&cc.egui_ctx, books, false, Route::Entry(entry))
});
h.run_steps(3);
shot(&mut h, shot_name);
h.get_by_label(title);
h.get_by_label(kept);
h.get_by_label(untagged);
// The entry's tagged line and the document's line: two links, one
// project; the untagged lines, none.
assert_eq!(h.query_all_by_label(&tag(project)).count(), 2, "{title}");
// The two links and the history's "tagged project" event: nothing
// else wears a tag, so the untagged lines have none.
assert_eq!(
h.query_all_by_label_contains(egui_phosphor::regular::TAG)
.count(),
3,
"{title}"
);
assert_eq!(
h.query_all_by_label_contains(&format!("tagged project {project}"))
.count(),
1,
"{title}"
);
// The document's line, the lower of the two, opens the project.
h.query_all_by_label(&tag(project))
.max_by(|a, b| a.rect().top().total_cmp(&b.rect().top()))
.unwrap()
.click();
h.run_steps(3);
assert_eq!(h.state_mut().route(), &Route::Project(id), "{title}");
}
}
/// The demo's "Client work" project carries every client invoice (rules.md
/// B-37 item 8): its revenue to date is the invoices' total, its direct
/// costs the contractors tagged to it.
#[test]
fn the_demos_client_work_counts_every_client_invoice() {
let dir = TempDir(
std::env::temp_dir().join(format!("ledger-gui-projects-demo-{}", std::process::id())),
);
let _ = std::fs::remove_dir_all(&dir.0);
let today = d(2026, 10, 12);
ledger_gui::demo::build(&dir.0, today).unwrap();
let b = data::load_once(&Source {
data_dir: dir.0.join("books-data"),
today: Some(today),
})
.unwrap();
let l = &b.ledger;
let client = l.find_class_by_name("Client work").unwrap().id;
let invoices = l.invoices();
assert!(!invoices.is_empty());
assert!(
invoices
.iter()
.all(|i| i.lines.iter().all(|x| x.class_id == Some(client))),
"every client invoice is tagged"
);
let invoiced: Decimal = invoices.iter().map(|i| i.total().unwrap()).sum();
let t = ledger_core::project_to_date(l, client, today).unwrap();
assert_eq!(t.revenue, invoiced);
let tagged_costs: Decimal = l
.posted_entries()
.flat_map(|e| e.lines().iter())
.filter(|x| x.class_id == Some(client))
.filter(|x| {
l.chart()
.get(x.account)
.is_some_and(|a| a.account_type == AccountType::Expense)
})
.map(|x| match x.side {
Side::Debit => x.amount.amount,
Side::Credit => -x.amount.amount,
})
.sum();
assert_eq!(t.expenses, tagged_costs);
assert!(t.expenses > Decimal::ZERO && t.net() > Decimal::ZERO);
}
+12 -1
View File
@@ -106,8 +106,17 @@ fn screenshots() {
.unwrap_or_else(|| project_named("Tidepool app")); .unwrap_or_else(|| project_named("Tidepool app"));
let project_client = project_named("Client work"); let project_client = project_named("Client work");
let project_templates = project_named("Templates"); let project_templates = project_named("Templates");
// An invoice whose lines name a project (else the first), and the
// first bill: their entries' pages show the document's lines.
let invoices = loaded.ledger.invoices();
let invoice = invoices
.iter()
.find(|i| i.lines.iter().any(|l| l.class_id.is_some()))
.or(invoices.first())
.map(|i| i.entry_id);
let bill = loaded.ledger.bills().first().map(|b| b.entry_id);
drop(loaded); drop(loaded);
let routes: Vec<(&str, Route)> = vec![ let mut routes: Vec<(&str, Route)> = vec![
("overview", Route::Overview), ("overview", Route::Overview),
("transactions", Route::Transactions), ("transactions", Route::Transactions),
( (
@@ -152,6 +161,8 @@ fn screenshots() {
("company", Route::Company), ("company", Route::Company),
("journal", Route::Journal), ("journal", Route::Journal),
]; ];
routes.extend(invoice.map(|e| ("invoice", Route::Entry(e))));
routes.extend(bill.map(|e| ("bill", Route::Entry(e))));
for (name, route) in routes { for (name, route) in routes {
if only if only
.as_deref() .as_deref()
+2
View File
@@ -256,6 +256,7 @@ fn origins_and_parties() {
vec![DocumentLine { vec![DocumentLine {
description: "x".into(), description: "x".into(),
amount: Decimal::new(a, 2), amount: Decimal::new(a, 2),
class_id: None,
}] }]
}; };
let inv = s let inv = s
@@ -637,6 +638,7 @@ fn more_origins_and_parties() {
vec![DocumentLine { vec![DocumentLine {
description: "x".into(), description: "x".into(),
amount: Decimal::new(a, 2), amount: Decimal::new(a, 2),
class_id: None,
}] }]
}; };
s.create_invoice(cust, today, today + Days::new(30), line(10_000)) s.create_invoice(cust, today, today + Days::new(30), line(10_000))
+1 -1
View File
@@ -1,7 +1,7 @@
# Projects # Projects
- A project is a class with its facts: books.add_project, books.update_project, books.projects, books.project (its figures, entries, documents, decisions). books.start lists the ones under way (active and paused). - A project is a class with its facts: books.add_project, books.update_project, books.projects, books.project (its figures, entries, documents, decisions). books.start lists the ones under way (active and paused).
- When the company's conventions ask for it (books.conventions: a rule to link things to projects), link whatever belongs to a project as you record it: tag each of its revenue and expense lines (books.add_line class), link its documents (books.link_attachment subject kind project, or link_to when filing), and name it in decisions about it (books.record_decision projects). An invoice or a bill takes no project yet (books.create_invoice and books.create_bill post at once, untagged): link its document to the project, and when its lines must count, book it as a hand-built entry instead (books.draft_entry, books.add_line class), which then is no invoice or bill in the agings: ask the human which matters more. Ask the human when it isn't clear which project; leave company-wide costs (rent, insurance, bookkeeping) untagged, and split a shared cost across lines only by a basis the human gives. - When the company's conventions ask for it (books.conventions: a rule to link things to projects), link whatever belongs to a project as you record it: tag each of its revenue and expense lines (books.add_line class), link its documents (books.link_attachment subject kind project, or link_to when filing), and name it in decisions about it (books.record_decision projects). {document_projects} Ask the human when it isn't clear which project; leave company-wide costs (rent, insurance, bookkeeping) untagged. A cost shared among projects is never split on an invoice, a bill or an entry by guesswork: it reaches projects only as the allocation convention's own entries, on its stated basis; when books.conventions has none, leave it untagged and ask the human.
- Keep the facts current: status when it changes, ended_on when it is finished or abandoned, the party it is for. - Keep the facts current: status when it changes, ended_on when it is finished or abandoned, the party it is for.
- On spec: its costs are expenses when incurred. A hoped-for payment is not recorded (no receivable, no income) until it is received; then book it as revenue tagged to the project. books.project shows the unrecovered cost. - On spec: its costs are expenses when incurred. A hoped-for payment is not recorded (no receivable, no income) until it is received; then book it as revenue tagged to the project. books.project shows the unrecovered cost.
- Only revenue and expense lines make a project's figures; a tagged asset purchase is listed with its entries but counts nothing until it is depreciated or expensed. Show a project to the human with books.show project. - Only revenue and expense lines make a project's figures; a tagged asset purchase is listed with its entries but counts nothing until it is depreciated or expensed. Show a project to the human with books.show project.
+77 -9
View File
@@ -30,6 +30,10 @@
//! control or format characters, no line breaks), cut on a //! control or format characters, no line breaks), cut on a
//! character; a company with no name (empty, or the settings' //! character; a company with no name (empty, or the settings'
//! default name) is "a new company" (plan/served.md 4.1). //! default name) is "a new company" (plan/served.md 4.1).
//! 6. A section says an invoice's or bill's lines take a project only
//! for books that declared the journal feature
//! `document_line_projects`; for the rest, that they wait for their
//! format upgrade (rules.md B-27, B-37 item 8).
//! ``` //! ```
use crate::settings::Settings; use crate::settings::Settings;
@@ -119,12 +123,22 @@ pub fn title(markdown: &str) -> &str {
/// reading order. /// reading order.
pub const ALL: &str = "all"; pub const ALL: &str = "all";
/// The section `id` (or [`ALL`]), rendered for these settings and the /// The section `id` (or [`ALL`]), rendered for these settings, the
/// books' base currency (None: no such section). /// books' base currency, and whether they declared the journal feature
pub fn section(id: &str, settings: &Settings, currency: &str) -> Option<String> { /// `document_line_projects` (None: no such section).
pub fn section(
id: &str,
settings: &Settings,
currency: &str,
document_line_projects: bool,
) -> Option<String> {
let render = |md: &str| { let render = |md: &str| {
md.replace("{evidence_rule}", &evidence_rule(settings, currency)) md.replace("{evidence_rule}", &evidence_rule(settings, currency))
.replace("{period_rule}", &period_rule()) .replace("{period_rule}", &period_rule())
.replace(
"{document_projects}",
document_projects_rule(document_line_projects),
)
}; };
if id == ALL { if id == ALL {
let parts: Vec<String> = SECTIONS.iter().map(|(_, md)| render(md)).collect(); let parts: Vec<String> = SECTIONS.iter().map(|(_, md)| render(md)).collect();
@@ -147,6 +161,17 @@ pub fn period_rule() -> String {
) )
} }
/// How an invoice's or bill's lines reach a project (rules.md B-37 item
/// 8), for books that declared the journal feature
/// `document_line_projects` or not yet (INVARIANT 6).
pub fn document_projects_rule(declared: bool) -> &'static str {
if declared {
"Tag an invoice's or bill's lines the same way (books.create_invoice and books.create_bill take class on each line): its revenue or cost counts toward the project, and it stays an invoice or bill in the agings; its payments take no project. Each line names one project or none: a document spanning several projects is one line per project, each with its own amount, never a percentage; a cost truly shared among projects names none on the document and reaches them only as entries of their own under the company's monthly allocation convention, when books.conventions has one. An invoice line naming an on-spec project returns a note: ask the human whether there is now an agreement, since a hoped-for payment is no receivable."
} else {
"An invoice or a bill takes no project in these books until their format upgrade (books.start's format_upgrade): link its document to the project, and when its lines must count, book it as a hand-built entry instead (books.draft_entry, books.add_line class), which then is no invoice or bill in the agings: ask the human which matters more."
}
}
/// The receipt rule in force (INVARIANT 2). /// The receipt rule in force (INVARIANT 2).
pub fn evidence_rule(settings: &Settings, currency: &str) -> String { pub fn evidence_rule(settings: &Settings, currency: &str) -> String {
format!( format!(
@@ -478,22 +503,65 @@ mod tests {
assert!(ids.insert(*id), "duplicate section {id}"); assert!(ids.insert(*id), "duplicate section {id}");
assert!(md.starts_with("# "), "{id} has no title"); assert!(md.starts_with("# "), "{id} has no title");
assert!(!title(md).is_empty()); assert!(!title(md).is_empty());
let text = section(id, &s, "USD").unwrap(); let text = section(id, &s, "USD", true).unwrap();
assert!(!text.contains("{evidence_rule}"), "{id}"); assert!(!text.contains("{evidence_rule}"), "{id}");
assert!(!text.contains("{period_rule}"), "{id}"); assert!(!text.contains("{period_rule}"), "{id}");
assert!(!text.contains("{document_projects}"), "{id}");
} }
// The default period, in the descriptions' own words (B-30). // The default period, in the descriptions' own words (B-30).
assert!( assert!(
section("tracing-figures", &s, "USD") section("tracing-figures", &s, "USD", true)
.unwrap() .unwrap()
.contains(ledger_core::calendar::PeriodKind::Statement.describe()) .contains(ledger_core::calendar::PeriodKind::Statement.describe())
); );
assert!( assert!(
section("evidence-and-receipts", &s, "USD") section("evidence-and-receipts", &s, "USD", true)
.unwrap() .unwrap()
.contains("every entry needs at least one receipt") .contains("every entry needs at least one receipt")
); );
assert!(section("nope", &s, "USD").is_none()); assert!(section("nope", &s, "USD", true).is_none());
}
/// INVARIANT 6: the projects section tells books that declared
/// document_line_projects to tag an invoice's or bill's lines, and the
/// rest that it waits for their format upgrade; no other section
/// changes.
#[test]
fn document_projects_follow_the_books_feature() {
let s = settings("Co", Decimal::ZERO);
let with = section("projects", &s, "USD", true).unwrap();
let without = section("projects", &s, "USD", false).unwrap();
assert!(
with.contains("books.create_invoice and books.create_bill take class on each line")
);
assert!(!with.contains("takes no project"));
// The owner's decision (B-37 item 8, D-43): one project or none a
// line, no percentage split, a shared cost by the allocation
// convention.
assert!(with.contains("Each line names one project or none"));
assert!(with.contains("never a percentage"));
assert!(with.contains("monthly allocation convention, when books.conventions has one"));
assert!(with.contains("ask the human whether there is now an agreement"));
// No sentence may let the agent split a shared cost across a
// document's lines (D-43).
for text in [&with, &without] {
assert!(!text.contains("split a shared cost across lines"), "{text}");
assert!(text.contains("is never split on an invoice, a bill or an entry"));
assert!(
text.contains(
"when books.conventions has none, leave it untagged and ask the human"
)
);
}
assert!(without.contains("takes no project in these books until their format upgrade"));
assert!(!without.contains("take class on each line"));
for (id, _) in SECTIONS.iter().filter(|(id, _)| *id != "projects") {
assert_eq!(
section(id, &s, "USD", true),
section(id, &s, "USD", false),
"{id}"
);
}
} }
/// A section's title is its heading's text, as the index and the /// A section's title is its heading's text, as the index and the
@@ -514,10 +582,10 @@ mod tests {
#[test] #[test]
fn the_whole_handbook_is_every_section_in_order() { fn the_whole_handbook_is_every_section_in_order() {
let s = settings("Co", Decimal::new(2500, 2)); let s = settings("Co", Decimal::new(2500, 2));
let all = section(ALL, &s, "USD").unwrap(); let all = section(ALL, &s, "USD", true).unwrap();
let mut at = 0; let mut at = 0;
for (id, _) in SECTIONS { for (id, _) in SECTIONS {
let one = section(id, &s, "USD").unwrap(); let one = section(id, &s, "USD", true).unwrap();
let found = all[at..] let found = all[at..]
.find(&one) .find(&one)
.unwrap_or_else(|| panic!("{id} missing or out of order")); .unwrap_or_else(|| panic!("{id} missing or out of order"));
+86 -30
View File
@@ -794,6 +794,16 @@ impl BooksServer {
self.live().current() self.live().current()
} }
/// Whether the books declared the journal feature
/// `document_line_projects` (the handbook's projects section says how
/// an invoice's or bill's lines reach a project by it).
fn document_line_projects(&self) -> bool {
self.lock().is_ok_and(|s| {
s.journal_format()
.has_feature(ledger_store::format::DOCUMENT_LINE_PROJECTS)
})
}
/// The books' base currency code, e.g. "USD". /// The books' base currency code, e.g. "USD".
fn currency(&self) -> &str { fn currency(&self) -> &str {
self.store.currency.code() self.store.currency.code()
@@ -2636,6 +2646,54 @@ fn resolve_class(store: &Store, class: &str) -> Result<u32, String> {
} }
} }
/// An invoice's or bill's lines as the tool takes them: each amount
/// parsed and each project resolved ([`resolve_class`]) before anything is
/// written.
fn document_lines(
store: &Store,
lines: Vec<DocumentLineParam>,
) -> Result<Vec<DocumentLine>, String> {
lines
.into_iter()
.map(|l| {
Ok(DocumentLine {
description: l.description,
amount: parse_amount(&l.amount)?,
class_id: l
.class
.as_deref()
.map(|c| resolve_class(store, c))
.transpose()?,
})
})
.collect()
}
/// A document's notes, each project once, in the order first named: its
/// late-tag note ([`projects::late_tag_note`]) for a finished or abandoned
/// project, and on an invoice ([`projects::on_spec_invoice_note`]) one for
/// an on-spec project (rules.md B-37 items 4, 5 and 8).
fn document_notes(
ledger: &ledger_core::Ledger,
lines: &[DocumentLine],
invoice: bool,
) -> Vec<String> {
let mut named: Vec<u32> = Vec::new();
for c in lines.iter().filter_map(|l| l.class_id) {
if !named.contains(&c) {
named.push(c);
}
}
let mut notes = Vec::new();
for c in named {
notes.extend(projects::late_tag_note(ledger, c));
if invoice {
notes.extend(projects::on_spec_invoice_note(ledger, c));
}
}
notes
}
/// Preview lines with resulting balances for raw (account, side, amount) lines. /// Preview lines with resulting balances for raw (account, side, amount) lines.
fn preview_lines_for( fn preview_lines_for(
store: &Store, store: &Store,
@@ -2967,10 +3025,12 @@ impl BooksServer {
params: Parameters<HandbookParams>, params: Parameters<HandbookParams>,
) -> Result<Json<HandbookOut>, String> { ) -> Result<Json<HandbookOut>, String> {
let settings = self.settings(); let settings = self.settings();
let declared = self.document_line_projects();
let sections = handbook::SECTIONS let sections = handbook::SECTIONS
.iter() .iter()
.map(|(id, _)| { .map(|(id, _)| {
let text = handbook::section(id, &settings, self.currency()).unwrap_or_default(); let text =
handbook::section(id, &settings, self.currency(), declared).unwrap_or_default();
HandbookSectionOut { HandbookSectionOut {
id: id.to_string(), id: id.to_string(),
title: handbook::title(&text).to_string(), title: handbook::title(&text).to_string(),
@@ -2982,7 +3042,7 @@ impl BooksServer {
let section = match params.0.section.as_deref().map(str::trim) { let section = match params.0.section.as_deref().map(str::trim) {
None => None, None => None,
Some(id) => { Some(id) => {
let text = handbook::section(id, &settings, self.currency()).ok_or_else(|| { let text = handbook::section(id, &settings, self.currency(), declared).ok_or_else(|| {
let ids: Vec<&str> = handbook::SECTIONS.iter().map(|(id, _)| *id).collect(); let ids: Vec<&str> = handbook::SECTIONS.iter().map(|(id, _)| *id).collect();
format!( format!(
"no handbook section {id:?}: the sections are {}, or \"{}\" for all of them", "no handbook section {id:?}: the sections are {}, or \"{}\" for all of them",
@@ -4119,6 +4179,11 @@ impl BooksServer {
Side::Credit => "credit".to_string(), Side::Credit => "credit".to_string(),
}, },
amount: money(l.amount.amount), amount: money(l.amount.amount),
class_id: l.class_id,
project: l
.class_id
.and_then(|c| store.ledger().get_class(c))
.map(|c| c.name.clone()),
} }
}) })
.collect(); .collect();
@@ -8489,7 +8554,7 @@ impl BooksServer {
#[tool( #[tool(
name = "books.create_invoice", name = "books.create_invoice",
title = "Create and post an invoice", title = "Create and post an invoice",
description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount} with decimal-string amounts.", description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's revenue: one revenue line per project, the untagged lines' as one; the receivable line is never tagged. Its result gains notes for a finished, abandoned or on-spec project (on spec: is there now an agreement?), tagged anyway.",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = true, destructive_hint = true,
@@ -8502,16 +8567,7 @@ impl BooksServer {
) -> Result<Json<CreateDocumentOut>, String> { ) -> Result<Json<CreateDocumentOut>, String> {
let p = params.0; let p = params.0;
let mut store = self.lock()?; let mut store = self.lock()?;
let lines: Vec<DocumentLine> = p let lines = document_lines(&store, p.lines)?;
.lines
.into_iter()
.map(|l| {
Ok(DocumentLine {
description: l.description,
amount: parse_amount(&l.amount)?,
})
})
.collect::<Result<_, String>>()?;
let first_new = store.ledger().next_account_id(); let first_new = store.ledger().next_account_id();
let id = store let id = store
.create_invoice( .create_invoice(
@@ -8530,13 +8586,14 @@ impl BooksServer {
total: inv.total().map_err(|e| e.to_string())?.to_string(), total: inv.total().map_err(|e| e.to_string())?.to_string(),
entry_id: inv.entry_id, entry_id: inv.entry_id,
warnings: created_uncategorized(store.ledger(), first_new), warnings: created_uncategorized(store.ledger(), first_new),
notes: document_notes(store.ledger(), &inv.lines, true),
})) }))
} }
#[tool( #[tool(
name = "books.create_bill", name = "books.create_bill",
title = "Create and post a bill", title = "Create and post a bill",
description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount} with decimal-string amounts.", description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's cost: one expense line per project, the untagged lines' as one; the payable line is never tagged. Its result gains notes for a finished or abandoned project, tagged anyway.",
annotations( annotations(
read_only_hint = false, read_only_hint = false,
destructive_hint = true, destructive_hint = true,
@@ -8549,16 +8606,7 @@ impl BooksServer {
) -> Result<Json<CreateDocumentOut>, String> { ) -> Result<Json<CreateDocumentOut>, String> {
let p = params.0; let p = params.0;
let mut store = self.lock()?; let mut store = self.lock()?;
let lines: Vec<DocumentLine> = p let lines = document_lines(&store, p.lines)?;
.lines
.into_iter()
.map(|l| {
Ok(DocumentLine {
description: l.description,
amount: parse_amount(&l.amount)?,
})
})
.collect::<Result<_, String>>()?;
let first_new = store.ledger().next_account_id(); let first_new = store.ledger().next_account_id();
let id = store let id = store
.create_bill( .create_bill(
@@ -8577,6 +8625,7 @@ impl BooksServer {
total: bill.total().map_err(|e| e.to_string())?.to_string(), total: bill.total().map_err(|e| e.to_string())?.to_string(),
entry_id: bill.entry_id, entry_id: bill.entry_id,
warnings: created_uncategorized(store.ledger(), first_new), warnings: created_uncategorized(store.ledger(), first_new),
notes: document_notes(store.ledger(), &bill.lines, false),
})) }))
} }
@@ -9926,8 +9975,13 @@ impl BooksServer {
} }
_ if path.starts_with(&format!("{}/", handbook::URI)) => { _ if path.starts_with(&format!("{}/", handbook::URI)) => {
let id = &path[handbook::URI.len() + 1..]; let id = &path[handbook::URI.len() + 1..];
handbook::section(id, &self.settings(), self.currency()) handbook::section(
.ok_or_else(|| format!("unknown handbook section: {id}")) id,
&self.settings(),
self.currency(),
self.document_line_projects(),
)
.ok_or_else(|| format!("unknown handbook section: {id}"))
} }
_ if path.starts_with("books://receipts/") => { _ if path.starts_with("books://receipts/") => {
let id: u64 = path let id: u64 = path
@@ -11212,10 +11266,12 @@ mod tests {
handbook::connect_text(&settings, "USD"), handbook::connect_text(&settings, "USD"),
)); ));
for (id, _) in handbook::SECTIONS { for (id, _) in handbook::SECTIONS {
texts.push(( for declared in [true, false] {
format!("handbook {id}"), texts.push((
handbook::section(id, &settings, "USD").unwrap(), format!("handbook {id}"),
)); handbook::section(id, &settings, "USD", declared).unwrap(),
));
}
} }
} }
// Every tool the text names is one there is. // Every tool the text names is one there is.
+15
View File
@@ -161,6 +161,21 @@ pub(super) fn late_tag_note(ledger: &Ledger, id: ClassId) -> Option<String> {
)) ))
} }
/// The note `books.create_invoice` gives for a line naming an on-spec
/// project (rules.md B-37 items 4 and 8): on spec, a hoped-for payment is
/// no receivable and no income until received, so an invoice for one
/// means there is now an agreement, or it shouldn't be an invoice.
pub(super) fn on_spec_invoice_note(ledger: &Ledger, id: ClassId) -> Option<String> {
let f = ledger.project_facts(id)?;
if f.kind != Some(ProjectKind::OnSpec) {
return None;
}
let name = &ledger.get_class(id)?.name;
Some(format!(
"project {name} is on spec: an invoice books a receivable and income, so is there now an agreement to be paid? If so, update its kind (books.update_project); if not, tell the human: on spec, a hoped-for payment is recorded only when received"
))
}
#[tool_router(router = projects_router, vis = "pub(super)")] #[tool_router(router = projects_router, vis = "pub(super)")]
impl BooksServer { impl BooksServer {
#[tool( #[tool(
+21
View File
@@ -1931,6 +1931,13 @@ pub struct EntryLineOut {
pub side: String, pub side: String,
/// Amount, decimal string. /// Amount, decimal string.
pub amount: String, pub amount: String,
/// The project (class) id the line is tagged with, if any (books.add_line
/// class, an invoice's or bill's line class).
#[serde(skip_serializing_if = "Option::is_none")]
pub class_id: Option<u32>,
/// That project's name.
#[serde(skip_serializing_if = "Option::is_none")]
pub project: Option<String>,
} }
/// Result of `books.get_entry`: the full entry with lines and receipts. /// Result of `books.get_entry`: the full entry with lines and receipts.
@@ -3063,6 +3070,15 @@ pub struct DocumentLineParam {
pub description: String, pub description: String,
/// Line amount, decimal string. /// Line amount, decimal string.
pub amount: String, pub amount: String,
/// The one project (name or id, as books.projects lists them) the
/// line's revenue (an invoice's) or cost (a bill's) belongs to, or
/// none. Several projects are several lines, each with its own amount,
/// never a percentage; a shared cost names none (the allocation
/// convention's own entries reach the projects). The posted
/// entry's revenue or expense line for it carries the tag; the
/// receivable or payable line never does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
} }
/// Parameters for `books.create_invoice` / `books.create_bill`. /// Parameters for `books.create_invoice` / `books.create_bill`.
@@ -3091,6 +3107,11 @@ pub struct CreateDocumentOut {
/// (Revenue 4000, General Expenses 6999) has no tax category yet. /// (Revenue 4000, General Expenses 6999) has no tax category yet.
#[serde(default, skip_serializing_if = "Vec::is_empty")] #[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>, pub warnings: Vec<String>,
/// One per finished or abandoned project a line names, as
/// `books.add_line`'s `note`, and on an invoice one per on-spec
/// project a line names (is there now an agreement?): tagged anyway.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
} }
/// Parameters for `books.receive_payment` / `books.pay_bill`. /// Parameters for `books.receive_payment` / `books.pay_bill`.
+387
View File
@@ -1200,3 +1200,390 @@ fn a_digit_name_that_shadows_an_id_says_what_to_pass() {
); );
assert_eq!(files["subject"]["label"], "project 1 Éclair"); assert_eq!(files["subject"]["label"], "project 1 Éclair");
} }
// ------------------------------------------------- invoices and bills
/// [`books`] as the live books stand until their owner consents again:
/// this version's format without `document_line_projects` (they declared
/// `projects`).
fn books_before_document_projects(name: &str) -> Mcp {
let dir = test_dir(name);
let mut format = ledger_store::format::current();
format
.features
.retain(|f| f != ledger_store::format::DOCUMENT_LINE_PROJECTS);
ledger_store::Store::create_in_format(
&dir.join("books-data"),
ledger_core::Currency::USD,
&format,
)
.unwrap();
let toml = books_toml(&dir, "Spec Co", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(CHART);
m.books_dir = Some(dir);
m
}
impl Mcp {
/// Each line of the entry as books.get_entry shows it: (code, side,
/// amount, project).
fn entry_lines(&mut self, entry: u64) -> Vec<(String, String, String, Option<String>)> {
self.read_only("books.get_entry", json!({"entry_id": entry}))["lines"]
.as_array()
.unwrap()
.iter()
.map(|l| {
(
l["code"].as_str().unwrap().to_string(),
l["side"].as_str().unwrap().to_string(),
l["amount"].as_str().unwrap().to_string(),
l["project"].as_str().map(str::to_string),
)
})
.collect()
}
/// Each line's `class_id` as books.get_entry shows it (absent: None).
fn entry_class_ids(&mut self, entry: u64) -> Vec<Option<u64>> {
let lines = self.read_only("books.get_entry", json!({"entry_id": entry}))["lines"].clone();
lines
.as_array()
.unwrap()
.iter()
.map(|l| {
assert!(
l.get("class_id").is_none_or(|c| !c.is_null()),
"an untagged line omits class_id: {l}"
);
l["class_id"].as_u64()
})
.collect()
}
/// The projects handbook section served as a resource.
fn handbook_resource(&mut self) -> String {
let r = self.rpc(
"resources/read",
json!({"uri": "books://handbook/projects"}),
);
r["result"]["contents"][0]["text"]
.as_str()
.unwrap_or_else(|| panic!("{r}"))
.to_string()
}
}
fn l4(
code: &str,
side: &str,
amount: &str,
project: Option<&str>,
) -> (String, String, String, Option<String>) {
(
code.into(),
side.into(),
amount.into(),
project.map(str::to_string),
)
}
/// rules.md B-37 item 8: each invoice and bill line takes a project (by
/// name or id); the entry's revenue or expense side is one line per
/// project, the untagged lines' as one, the receivable or payable line
/// and the payments untagged; books.project, profit_and_loss_by_class,
/// the 1099 worksheet and the party's totals all read them.
#[test]
fn invoice_and_bill_lines_count_toward_their_projects() {
let mut m = books("doc-lines");
let zeta = m.ok(
"books.add_project",
json!({"name": "Zeta site", "kind": "client"}),
)["id"]
.as_u64()
.unwrap();
let harbor = m.ok(
"books.add_project",
json!({"name": "Harbor app", "kind": "client"}),
)["id"]
.as_u64()
.unwrap();
let cedar = m.ok("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
let dana = m.ok("books.add_vendor", json!({"name": "Dana Illustration"}));
let (dana_vendor, dana_party) = (dana["id"].clone(), dana["party_id"].clone());
m.ok(
"books.set_vendor_1099",
json!({"vendor": "Dana Illustration", "form_1099": "nec", "decided_by": "Tobin Harlow", "decided_on": "2026-01-01"}),
);
let inv = m.ok(
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "Design", "amount": "3000.00", "class": "Zeta site"},
{"description": "Hosting", "amount": "200.00", "class": harbor.to_string()},
{"description": "Rush fee", "amount": "100.00"},
{"description": "Revisions", "amount": "500.00", "class": " zeta SITE "},
]}),
);
assert_eq!(inv["total"], "3800.00");
assert!(inv.get("notes").is_none(), "{inv}");
let inv_entry = inv["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(inv_entry),
vec![
l4("1200", "debit", "3800.00", None),
l4("4000", "credit", "3500.00", Some("Zeta site")),
l4("4000", "credit", "200.00", Some("Harbor app")),
l4("4000", "credit", "100.00", None),
]
);
// books.get_entry's class_id is the project's id on a tagged line and
// absent on the others.
assert_eq!(
m.entry_class_ids(inv_entry),
vec![None, Some(zeta), Some(harbor), None]
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08", "lines": [
{"description": "Illustration", "amount": "1200.00", "class": "Zeta site"},
{"description": "Postage", "amount": "50.00"},
]}),
);
let bill_entry = bill["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(bill_entry),
vec![
l4("6999", "debit", "1200.00", Some("Zeta site")),
l4("6999", "debit", "50.00", None),
l4("2000", "credit", "1250.00", None),
]
);
assert_eq!(m.entry_class_ids(bill_entry), vec![Some(zeta), None, None]);
// Payments are balance-sheet only: no project.
let got = m.ok(
"books.receive_payment",
json!({"document_id": inv["id"], "date": "2026-03-20", "amount": "3800.00", "cash_account": "1000"}),
);
let paid = m.ok(
"books.pay_bill",
json!({"document_id": bill["id"], "date": "2026-03-30", "amount": "1250.00", "cash_account": "1000"}),
);
for e in [&got["entry_id"], &paid["entry_id"]] {
let e = e.as_u64().unwrap();
assert!(m.entry_lines(e).iter().all(|l| l.3.is_none()), "entry {e}");
}
// The project's figures, and the class P&L, to the cent.
let year = json!({"start": "2026-01-01", "end": "2026-12-31"});
let mut a = year.clone();
a["project"] = json!("Zeta site");
let z = m.read_only("books.project", a);
assert_eq!(
(
&z["period"]["revenue"],
&z["period"]["expenses"],
&z["period"]["net"]
),
(&json!("3500.00"), &json!("1200.00"), &json!("2300.00"))
);
assert_eq!(z["to_date"]["net"], "2300.00");
assert_eq!(z["period"]["by_month"][2]["net"], "2300.00");
let entries: Vec<u64> = z["entries"]
.as_array()
.unwrap()
.iter()
.map(|e| e["entry_id"].as_u64().unwrap())
.collect();
assert_eq!(entries, vec![bill_entry, inv_entry]);
let by_class = m.read_only("books.profit_and_loss_by_class", year.clone());
let row = |name: &str| {
by_class["classes"]
.as_array()
.unwrap()
.iter()
.find(|r| r["class"] == name)
.unwrap_or_else(|| panic!("{name}: {by_class}"))
.clone()
};
assert_eq!(row("Zeta site")["net_income"], "2300.00");
assert_eq!(row("Harbor app")["net_income"], "200.00");
// The 1099 worksheet and the party's totals follow the money: the
// bill's payment, 1,250.00, whatever its lines' projects.
let w = m.read_only("books.vendor_1099", json!({"year": 2026}));
let dana_line = w["vendors"]
.as_array()
.unwrap()
.iter()
.find(|v| v["name"] == "Dana Illustration")
.unwrap()
.clone();
assert_eq!(dana_line["paid"], "1250.00");
let p = m.read_only(
"books.party",
json!({"party": dana_party.to_string(), "year": 2026}),
);
assert_eq!(p["totals"]["paid"], "1250.00", "{p}");
assert_eq!(p["totals"]["amount_1099"], "1250.00", "{p}");
}
#[test]
fn a_document_naming_an_unknown_project_is_refused_whole() {
let mut m = books("doc-unknown");
m.ok("books.add_project", json!({"name": "Zeta site"}));
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] {
let why = m.refuse(
tool,
json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00", "class": "Zeta site"},
{"description": "b", "amount": "10.00", "class": "Nowhere"},
]}),
);
assert!(why.contains("unknown project \"Nowhere\""), "{tool}: {why}");
}
}
#[test]
fn a_document_for_a_finished_project_says_so() {
let mut m = books("doc-late");
m.ok(
"books.add_project",
json!({"name": "Harbor", "status": "finished", "ended_on": "2026-09-30"}),
);
m.ok(
"books.add_project",
json!({"name": "Dock", "status": "abandoned"}),
);
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let out = m.ok(
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
{"description": "Final", "amount": "10.00", "class": "Harbor"},
{"description": "Again", "amount": "10.00", "class": "harbor"},
{"description": "Old", "amount": "10.00", "class": "Dock"},
]}),
);
let notes: Vec<&str> = out["notes"]
.as_array()
.unwrap()
.iter()
.map(|n| n.as_str().unwrap())
.collect();
assert_eq!(notes.len(), 2, "{notes:?}");
assert!(
notes[0].contains("Harbor is finished (ended 2026-09-30)"),
"{notes:?}"
);
assert!(notes[1].contains("Dock is abandoned"), "{notes:?}");
// Tagged anyway.
let e = out["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(e)[1],
l4("4000", "credit", "20.00", Some("Harbor"))
);
}
/// B-37 items 4 and 8: an invoice line naming an on-spec project is taken
/// and its result asks whether there is now an agreement (a hoped-for
/// payment is no receivable); a bill's cost for one is an expense like any
/// other, so a bill gets no such note.
#[test]
fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
let mut m = books("doc-on-spec");
m.ok(
"books.add_project",
json!({"name": "Kestrel", "kind": "on_spec"}),
);
m.ok(
"books.add_project",
json!({"name": "Zeta", "kind": "client"}),
);
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
let out = m.ok(
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
{"description": "Build", "amount": "10.00", "class": "Kestrel"},
{"description": "More", "amount": "5.00", "class": "kestrel"},
{"description": "Site", "amount": "7.00", "class": "Zeta"},
]}),
);
let notes = out["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1, "{out}");
let note = notes[0].as_str().unwrap();
assert!(
note.contains("project Kestrel is on spec") && note.contains("is there now an agreement"),
"{note}"
);
let e = out["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(e)[1],
l4("4000", "credit", "15.00", Some("Kestrel"))
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
{"description": "Icons", "amount": "10.00", "class": "Kestrel"},
]}),
);
assert!(bill.get("notes").is_none(), "{bill}");
}
/// D-39: books that declared `projects` but not
/// `document_line_projects` refuse a document naming a project, journaling
/// nothing, and take one naming none; the handbook tells them so.
#[test]
fn books_without_the_feature_refuse_a_tagged_document() {
let mut m = books_before_document_projects("doc-gate");
m.ok(
"books.add_project",
json!({"name": "Zeta site", "kind": "client"}),
);
let cedar = m.ok("books.add_customer", json!({"name": "Cedar"}))["id"].clone();
let v = m.ok("books.add_vendor", json!({"name": "Dana"}))["id"].clone();
let head = m.journal();
for (tool, party) in [("books.create_invoice", &cedar), ("books.create_bill", &v)] {
let why = m.refuse(
tool,
json!({"party_id": party, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00", "class": "Zeta site"},
]}),
);
assert!(
why.starts_with("format_upgrade_pending:") && why.contains("document_line_projects"),
"{tool}: {why}"
);
}
assert_eq!(m.journal(), head, "nothing journaled");
m.ok(
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00"},
]}),
);
let text = m.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(
text.contains("takes no project in these books until their format upgrade"),
"{text}"
);
// The same section read as a resource tells the same books the same.
let served = m.handbook_resource();
assert!(
served.contains("takes no project in these books until their format upgrade"),
"{served}"
);
assert!(!served.contains("take class on each line"), "{served}");
let mut fresh = books("doc-gate-fresh");
let served = fresh.handbook_resource();
assert!(served.contains("take class on each line"), "{served}");
assert!(!served.contains("takes no project"), "{served}");
let text = fresh.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"]
.as_str()
.unwrap()
.to_string();
assert!(text.contains("take class on each line"), "{text}");
assert!(!text.contains("takes no project"), "{text}");
}
+46
View File
@@ -60,6 +60,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
DISPOSAL_BEFORE_SERVICE, DISPOSAL_BEFORE_SERVICE,
DEPRECIATION_CATCH_UP, DEPRECIATION_CATCH_UP,
PROJECTS, PROJECTS,
DOCUMENT_LINE_PROJECTS,
]; ];
/// Feature: `period.closed` closes to members with `"rounding": /// Feature: `period.closed` closes to members with `"rounding":
@@ -155,6 +156,13 @@ pub const ASSET_BASIS: &str = "asset_basis";
/// silently). /// silently).
pub const PROJECTS: &str = "projects"; pub const PROJECTS: &str = "projects";
/// Feature: an `invoice.created` or `bill.created` line naming its project
/// (`class_id`, rules.md B-37 item 8). An older version would restore the
/// document without its lines' projects, silently. Its own feature, not
/// `projects`': books that consented to projects before it existed must
/// consent again before a document names one.
pub const DOCUMENT_LINE_PROJECTS: &str = "document_line_projects";
/// The line type that declares the journal format. /// The line type that declares the journal format.
pub const FORMAT_SET: &str = "journal.format_set"; pub const FORMAT_SET: &str = "journal.format_set";
@@ -742,6 +750,44 @@ mod tests {
); );
} }
/// The version the live books run (every feature through `projects`)
/// stops at a declaration of `document_line_projects` as newer, never
/// restoring an invoice or bill without its lines' projects; and books
/// that declared `projects` alone still wait for it (B-27, D-39).
#[test]
fn document_line_projects_is_its_own_feature() {
assert!(KNOWN_FEATURES.contains(&DOCUMENT_LINE_PROJECTS));
assert_ne!(DOCUMENT_LINE_PROJECTS, PROJECTS);
let before: Vec<&str> = KNOWN_FEATURES
.iter()
.copied()
.filter(|f| *f != DOCUMENT_LINE_PROJECTS)
.collect();
let l = line(
FORMAT_SET,
json!({"format": 2, "features": [PROJECTS, DOCUMENT_LINE_PROJECTS]}),
);
match declared_knowing(&l, &before) {
Err(StoreError::NewerJournal { need, .. }) => {
assert_eq!(need, "journal features document_line_projects")
}
other => panic!("expected NewerJournal, got {other:?}"),
}
let with_projects = JournalFormat {
format: 2,
features: before.iter().map(|f| f.to_string()).collect(),
};
assert!(matches!(
with_projects.require_feature(DOCUMENT_LINE_PROJECTS),
Err(StoreError::FormatUpgradePending { .. })
));
let up = with_projects.upgrade().expect("waits for it");
assert_eq!(
up.adds(),
vec!["the journal feature document_line_projects"]
);
}
/// This version's format: the newest it reads, every feature it /// This version's format: the newest it reads, every feature it
/// knows, each once, sorted (what new books and an upgrade declare). /// knows, each once, sorted (what new books and an upgrade declare).
#[test] #[test]
+94 -16
View File
@@ -2816,10 +2816,7 @@ impl Store {
} }
/// Journal an auto-posted two-line entry (debit one account, credit /// Journal an auto-posted two-line entry (debit one account, credit
/// another for the same amount) through the standard entry.created / /// another for the same amount): [`Self::journal_auto_lines`].
/// entry.line_added / entry.posted flow. Returns the predicted entry id
/// and the journaled lines; the caller mutates the ledger afterwards
/// and applies the lines to SQLite.
fn journal_auto_entry( fn journal_auto_entry(
&mut self, &mut self,
date: NaiveDate, date: NaiveDate,
@@ -2827,10 +2824,32 @@ impl Store {
debit_account: AccountId, debit_account: AccountId,
credit_account: AccountId, credit_account: AccountId,
amount: Decimal, amount: Decimal,
) -> Result<(EntryId, Vec<JournalLine>), StoreError> {
self.journal_auto_lines(
date,
description,
&[
(debit_account, Side::Debit, amount, None),
(credit_account, Side::Credit, amount, None),
],
)
}
/// Journal an auto-posted entry of `lines` (account, side, amount,
/// project) through the standard entry.created / entry.line_added /
/// entry.posted flow, a tagged line's `entry.line_class_set` right
/// after its `entry.line_added` (as `books.add_line` journals one).
/// Returns the predicted entry id and the journaled lines; the caller
/// mutates the ledger afterwards and applies the lines to SQLite.
fn journal_auto_lines(
&mut self,
date: NaiveDate,
description: &str,
lines: &[(AccountId, Side, Decimal, Option<ClassId>)],
) -> Result<(EntryId, Vec<JournalLine>), StoreError> { ) -> Result<(EntryId, Vec<JournalLine>), StoreError> {
let entry_id = self.ledger.next_entry_id(); let entry_id = self.ledger.next_entry_id();
let base = self.base_currency; let base = self.base_currency;
let mut journaled = Vec::with_capacity(4); let mut journaled = Vec::with_capacity(lines.len() * 2 + 2);
journaled.push(self.append_journal( journaled.push(self.append_journal(
"entry.created", "entry.created",
json!({ json!({
@@ -2839,7 +2858,7 @@ impl Store {
"description": description, "description": description,
}), }),
)?); )?);
for (account, side) in [(debit_account, Side::Debit), (credit_account, Side::Credit)] { for (index, &(account, side, amount, class)) in lines.iter().enumerate() {
journaled.push(self.append_journal( journaled.push(self.append_journal(
"entry.line_added", "entry.line_added",
json!({ json!({
@@ -2853,7 +2872,30 @@ impl Store {
"currency": base.code(), "currency": base.code(),
}), }),
)?); )?);
if let Some(class) = class {
journaled.push(self.append_journal(
"entry.line_class_set",
json!({
"entry_id": entry_id,
"line_index": index as u64,
"class_id": class,
}),
)?);
}
} }
let posted: Vec<Value> = lines
.iter()
.map(|&(account, side, amount, class_id)| {
journal_line_json(&ledger_core::JournalLine {
account,
side,
amount: Money::new(amount, base),
foreign_currency: None,
foreign_amount: None,
class_id,
})
})
.collect();
journaled.push(self.append_journal( journaled.push(self.append_journal(
"entry.posted", "entry.posted",
json!({ json!({
@@ -2861,15 +2903,22 @@ impl Store {
"date": date.to_string(), "date": date.to_string(),
"description": description, "description": description,
"reversal_of": Option::<u64>::None, "reversal_of": Option::<u64>::None,
"lines": [ "lines": posted,
plain_line_json(debit_account, Side::Debit, Money::new(amount, base)),
plain_line_json(credit_account, Side::Credit, Money::new(amount, base)),
],
}), }),
)?); )?);
Ok((entry_id, journaled)) Ok((entry_id, journaled))
} }
/// The feature a document's lines need (rules.md B-27): a line naming
/// a project is `document_line_projects`'s; a document naming none is
/// written as every version before it wrote one.
fn ensure_document_lines(&self, lines: &[DocumentLine]) -> Result<(), StoreError> {
if lines.iter().any(|l| l.class_id.is_some()) {
self.ensure_feature(crate::format::DOCUMENT_LINE_PROJECTS)?;
}
Ok(())
}
/// Add a customer. Journaled as `customer.added`. /// Add a customer. Journaled as `customer.added`.
pub fn add_customer(&mut self, name: &str) -> Result<CustomerId, StoreError> { pub fn add_customer(&mut self, name: &str) -> Result<CustomerId, StoreError> {
self.rehearse(|l| l.add_customer(name))?; self.rehearse(|l| l.add_customer(name))?;
@@ -2927,13 +2976,19 @@ impl Store {
if let Some(pid) = self.ledger.closed_period_containing(date) { if let Some(pid) = self.ledger.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
} }
self.ensure_document_lines(&lines)?;
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?; let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
let revenue = self.ensure_control_account(&ledger_core::REVENUE)?; let revenue = self.ensure_control_account(&ledger_core::REVENUE)?;
let invoice_id = self.ledger.next_invoice_id(); let invoice_id = self.ledger.next_invoice_id();
let total: Decimal = lines.iter().map(|l| l.amount).sum(); let total: Decimal = lines.iter().map(|l| l.amount).sum();
let description = format!("Invoice {invoice_id}"); let description = format!("Invoice {invoice_id}");
let (entry_id, mut journaled) = let mut posting = vec![(ar, Side::Debit, total, None)];
self.journal_auto_entry(date, &description, ar, revenue, total)?; posting.extend(
arap::document_parts(&lines, "the invoice")?
.into_iter()
.map(|(class, amount)| (revenue, Side::Credit, amount, class)),
);
let (entry_id, mut journaled) = self.journal_auto_lines(date, &description, &posting)?;
let doc_lines: Vec<Value> = lines.iter().map(document_line_json).collect(); let doc_lines: Vec<Value> = lines.iter().map(document_line_json).collect();
journaled.push(self.append_journal( journaled.push(self.append_journal(
"invoice.created", "invoice.created",
@@ -2987,13 +3042,18 @@ impl Store {
if let Some(pid) = self.ledger.closed_period_containing(date) { if let Some(pid) = self.ledger.closed_period_containing(date) {
return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into()); return Err(LedgerError::ClosedPeriod(pid, date.to_string()).into());
} }
self.ensure_document_lines(&lines)?;
let expense = self.ensure_control_account(&ledger_core::GENERAL_EXPENSES)?; let expense = self.ensure_control_account(&ledger_core::GENERAL_EXPENSES)?;
let ap = self.ensure_control_account(&ledger_core::PAYABLES)?; let ap = self.ensure_control_account(&ledger_core::PAYABLES)?;
let bill_id = self.ledger.next_bill_id(); let bill_id = self.ledger.next_bill_id();
let total: Decimal = lines.iter().map(|l| l.amount).sum(); let total: Decimal = lines.iter().map(|l| l.amount).sum();
let description = format!("Bill {bill_id}"); let description = format!("Bill {bill_id}");
let (entry_id, mut journaled) = let mut posting: Vec<_> = arap::document_parts(&lines, "the bill")?
self.journal_auto_entry(date, &description, expense, ap, total)?; .into_iter()
.map(|(class, amount)| (expense, Side::Debit, amount, class))
.collect();
posting.push((ap, Side::Credit, total, None));
let (entry_id, mut journaled) = self.journal_auto_lines(date, &description, &posting)?;
let doc_lines: Vec<Value> = lines.iter().map(document_line_json).collect(); let doc_lines: Vec<Value> = lines.iter().map(document_line_json).collect();
journaled.push(self.append_journal( journaled.push(self.append_journal(
"bill.created", "bill.created",
@@ -5422,10 +5482,16 @@ fn plain_line_json(account: AccountId, side: Side, amount: Money) -> Value {
/// One invoice/bill document line as journal JSON. Amounts are decimal /// One invoice/bill document line as journal JSON. Amounts are decimal
/// strings, never floats. /// strings, never floats.
fn document_line_json(l: &DocumentLine) -> Value { fn document_line_json(l: &DocumentLine) -> Value {
json!({ let mut v = json!({
"description": l.description, "description": l.description,
"amount": l.amount.to_string(), "amount": l.amount.to_string(),
}) });
// Its project, behind the feature document_line_projects (rules.md
// B-27, B-37 item 8); a line naming none is written as before it.
if let Some(c) = l.class_id {
v["class_id"] = json!(c);
}
v
} }
/// Parse the `lines` array of an invoice/bill journal payload back into /// Parse the `lines` array of an invoice/bill journal payload back into
@@ -5442,6 +5508,18 @@ fn parse_document_lines(p: &Value) -> Result<Vec<DocumentLine>, StoreError> {
amount: get_str(l, "amount")? amount: get_str(l, "amount")?
.parse() .parse()
.map_err(|_| StoreError::JournalCorrupt("bad line amount".into()))?, .map_err(|_| StoreError::JournalCorrupt("bad line amount".into()))?,
// Absent (every document before document_line_projects)
// or null: no project.
class_id: match l.get("class_id") {
None | Some(Value::Null) => None,
Some(c) => Some(
c.as_u64()
.and_then(|c| ClassId::try_from(c).ok())
.ok_or_else(|| {
StoreError::JournalCorrupt("bad document line class_id".into())
})?,
),
},
}) })
}) })
.collect() .collect()
+2
View File
@@ -321,6 +321,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec<Subject> {
vec![DocumentLine { vec![DocumentLine {
description: "App".into(), description: "App".into(),
amount: dec("100.00"), amount: dec("100.00"),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -333,6 +334,7 @@ fn every_subject(s: &mut Store, fx: &Fx) -> Vec<Subject> {
vec![DocumentLine { vec![DocumentLine {
description: "Design".into(), description: "Design".into(),
amount: dec("200.00"), amount: dec("200.00"),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -0,0 +1,514 @@
//! An invoice's or bill's lines in the journal (rules.md B-37 item 8,
//! B-27): a line naming its project carries `class_id` (journal feature
//! `document_line_projects`), and the entry's revenue or expense line for
//! that project is tagged by `entry.line_class_set` right after its
//! `entry.line_added`. A document naming no project is journaled exactly
//! as before, and books that haven't declared the feature refuse one that
//! names a project, journaling nothing.
//!
//! ```text
//! GOAL: a document's projects are journaled once, replay exactly, and
//! never reach books whose owner hasn't consented to the format.
//! SOURCE: docs/spec/rules.md B-37 item 8, B-27, B-28; plan/served.md
//! D-39 (the owner's upgrade).
//! ```
use chrono::NaiveDate;
use ledger_core::arap::DocumentLine;
use ledger_core::{ClassId, Currency, ProjectFacts};
use ledger_store::format::{DOCUMENT_LINE_PROJECTS, PROJECTS, current};
use ledger_store::{JOURNAL_FILE, JournalWriter, Snapshot, Store, StoreError};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
struct TestDir(PathBuf);
impl Drop for TestDir {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.0);
}
}
fn test_dir(name: &str) -> TestDir {
let dir = std::env::temp_dir().join(format!(
"wanderbooks-document-projects-{name}-{}",
std::process::id()
));
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
TestDir(dir)
}
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
fn cents(c: i64) -> Decimal {
Decimal::new(c, 2)
}
fn line(what: &str, c: i64, class: Option<ClassId>) -> DocumentLine {
DocumentLine {
description: what.into(),
amount: cents(c),
class_id: class,
}
}
/// Every journal line, as (type, payload).
fn lines(dir: &Path) -> Vec<(String, Value)> {
std::fs::read_to_string(dir.join(JOURNAL_FILE))
.unwrap()
.lines()
.map(|l| {
let v: Value = serde_json::from_str(l).unwrap();
(
v["type"].as_str().unwrap().to_string(),
v["payload"].clone(),
)
})
.collect()
}
/// The lines journaled from `from` on, as (type, payload).
fn since(dir: &Path, from: usize) -> Vec<(String, Value)> {
lines(dir).split_off(from)
}
fn project(s: &mut Store, name: &str) -> ClassId {
s.add_project(name, &ProjectFacts::default()).unwrap()
}
#[test]
fn a_tagged_invoice_journals_each_projects_line_and_replays() {
let dir = test_dir("invoice");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let zeta = project(&mut s, "Zeta");
let harbor = project(&mut s, "Harbor");
let c = s.add_customer("Cedar").unwrap();
// The control accounts first, so what follows is the invoice alone.
s.create_invoice(c, d(2026, 1, 2), d(2026, 2, 1), vec![line("x", 100, None)])
.unwrap();
let from = lines(&dir.0).len();
let inv = s
.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![
line("Design", 300_000, Some(zeta)),
line("Hosting", 20_000, Some(harbor)),
line("Fee", 10_000, None),
],
)
.unwrap();
let e = s.ledger().get_invoice(inv).unwrap().entry_id;
let (ar, rev) = (
s.ledger().chart().find_by_code("1200").unwrap().id,
s.ledger().chart().find_by_code("4000").unwrap().id,
);
let added = |account: u64, side: &str, amount: &str| {
(
"entry.line_added".to_string(),
json!({"id": e, "account": account, "side": side, "amount": amount, "currency": "USD"}),
)
};
let class_set = |index: u64, class: ClassId| {
(
"entry.line_class_set".to_string(),
json!({"entry_id": e, "line_index": index, "class_id": class}),
)
};
let got = since(&dir.0, from);
assert_eq!(
got[..7],
[
(
"entry.created".to_string(),
json!({"id": e, "date": "2026-03-02", "description": format!("Invoice {inv}")})
),
added(ar, "debit", "3300.00"),
added(rev, "credit", "3000.00"),
class_set(1, zeta),
added(rev, "credit", "200.00"),
class_set(2, harbor),
added(rev, "credit", "100.00"),
]
);
assert_eq!(got[7].0, "entry.posted");
assert_eq!(
got[7].1["lines"],
json!([
{"account": ar, "side": "debit", "amount": "3300.00", "currency": "USD"},
{"account": rev, "side": "credit", "amount": "3000.00", "currency": "USD", "class_id": zeta},
{"account": rev, "side": "credit", "amount": "200.00", "currency": "USD", "class_id": harbor},
{"account": rev, "side": "credit", "amount": "100.00", "currency": "USD"},
])
);
assert_eq!(got[8].0, "invoice.created");
assert_eq!(
got[8].1["lines"],
json!([
{"description": "Design", "amount": "3000.00", "class_id": zeta},
{"description": "Hosting", "amount": "200.00", "class_id": harbor},
{"description": "Fee", "amount": "100.00"},
])
);
assert_eq!(got.len(), 9);
// Live, replayed, projected and the viewer's snapshot all agree.
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(s.ledger().differs_from(&live).is_empty());
assert_eq!(
s.ledger().get_invoice(inv).unwrap().lines[1].class_id,
Some(harbor)
);
let snap = Snapshot::load(&dir.0).unwrap();
assert!(snap.ledger().differs_from(&live).is_empty());
}
#[test]
fn a_tagged_bill_tags_its_expense_lines_never_the_payable() {
let dir = test_dir("bill");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let zeta = project(&mut s, "Zeta");
let v = s.add_vendor("Dana").unwrap();
let bill = s
.create_bill(
v,
d(2026, 3, 9),
d(2026, 4, 8),
vec![
line("Misc", 2_500, None),
line("Illustration", 180_000, Some(zeta)),
],
)
.unwrap();
let e = s.ledger().get_bill(bill).unwrap().entry_id;
let tags: Vec<Option<ClassId>> = s
.ledger()
.get_entry(e)
.unwrap()
.lines()
.iter()
.map(|l| l.class_id)
.collect();
assert_eq!(tags, vec![None, Some(zeta), None]);
let set: Vec<Value> = lines(&dir.0)
.into_iter()
.filter(|(t, _)| t == "entry.line_class_set")
.map(|(_, p)| p)
.collect();
assert_eq!(
set,
vec![json!({"entry_id": e, "line_index": 1, "class_id": zeta})]
);
// Its payment is balance-sheet only: no tag journaled.
let cash = s
.add_account("1000", "Cash", ledger_core::AccountType::Asset, None, false)
.unwrap();
let before = lines(&dir.0).len();
s.pay_bill(bill, d(2026, 4, 1), cents(182_500), cash)
.unwrap();
assert!(since(&dir.0, before).iter().all(|(t, p)| {
t != "entry.line_class_set"
&& p.get("class_id").is_none()
&& p["lines"]
.as_array()
.is_none_or(|ls| ls.iter().all(|l| l.get("class_id").is_none()))
}));
s.verify().unwrap();
}
/// A document naming no project is journaled exactly as every version
/// before the feature journaled one: no `class_id` anywhere, no
/// `entry.line_class_set`, one revenue (expense) line.
/// Reversing a tagged invoice's entry journals one `entry.reversed`, whose
/// replay (Ledger::reverse_entry) carries each line's project; live and
/// replayed books agree that its projects net to zero.
#[test]
fn a_reversed_tagged_invoice_replays_with_its_projects_at_zero() {
let dir = test_dir("reversed");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let zeta = project(&mut s, "Zeta");
let harbor = project(&mut s, "Harbor");
let c = s.add_customer("Cedar").unwrap();
let inv = s
.create_invoice(
c,
d(2026, 3, 2),
d(2026, 4, 1),
vec![
line("Design", 300_000, Some(zeta)),
line("Hosting", 20_000, Some(harbor)),
],
)
.unwrap();
let e = s.ledger().get_invoice(inv).unwrap().entry_id;
let from = lines(&dir.0).len();
let r = s.reverse_entry(e, d(2026, 3, 31), "Reverse").unwrap();
let types: Vec<String> = since(&dir.0, from).into_iter().map(|(t, _)| t).collect();
assert_eq!(types, ["entry.reversed"]);
let tags = |s: &Store| -> Vec<Option<ClassId>> {
s.ledger()
.get_entry(r)
.unwrap()
.lines()
.iter()
.map(|l| l.class_id)
.collect()
};
assert_eq!(tags(&s), [None, Some(zeta), Some(harbor)]);
let zero = |s: &Store, p: ClassId| {
let t = ledger_core::project_to_date(s.ledger(), p, d(2026, 12, 31)).unwrap();
(t.revenue, t.expenses) == (Decimal::ZERO, Decimal::ZERO)
};
assert!(zero(&s, zeta) && zero(&s, harbor));
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(s.ledger().differs_from(&live).is_empty());
assert_eq!(tags(&s), [None, Some(zeta), Some(harbor)]);
assert!(zero(&s, zeta) && zero(&s, harbor));
}
#[test]
fn an_untagged_document_journals_as_before() {
let dir = test_dir("untagged");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
project(&mut s, "Zeta");
let c = s.add_customer("Cedar").unwrap();
let v = s.add_vendor("Dana").unwrap();
let from = lines(&dir.0).len();
s.create_invoice(
c,
d(2026, 1, 5),
d(2026, 2, 4),
vec![line("A", 10_000, None), line("B", 2_550, None)],
)
.unwrap();
s.create_bill(v, d(2026, 1, 6), d(2026, 2, 5), vec![line("C", 999, None)])
.unwrap();
let got = since(&dir.0, from);
let types: Vec<&str> = got.iter().map(|(t, _)| t.as_str()).collect();
assert_eq!(
types,
[
"account.added",
"account.added",
"entry.created",
"entry.line_added",
"entry.line_added",
"entry.posted",
"invoice.created",
"account.added",
"account.added",
"entry.created",
"entry.line_added",
"entry.line_added",
"entry.posted",
"bill.created",
]
);
let text = got
.iter()
.map(|(_, p)| p.to_string())
.collect::<Vec<_>>()
.join("\n");
assert!(!text.contains("class_id"), "{text}");
assert_eq!(
got[6].1["lines"],
json!([{"description": "A", "amount": "100.00"}, {"description": "B", "amount": "25.50"}])
);
}
/// The books the live server keeps until the owner consents again: this
/// version's format without `document_line_projects` (they declared
/// `projects`). A document naming a project is refused, journaling
/// nothing (not even its control accounts); one naming none is taken;
/// after the upgrade both are.
#[test]
fn a_document_naming_a_project_waits_for_the_feature() {
let dir = test_dir("gate");
let mut format = current();
format.features.retain(|f| f != DOCUMENT_LINE_PROJECTS);
assert!(format.features.iter().any(|f| f == PROJECTS));
let mut s = Store::create_in_format(&dir.0, Currency::USD, &format).unwrap();
let zeta = project(&mut s, "Zeta");
let c = s.add_customer("Cedar").unwrap();
let v = s.add_vendor("Dana").unwrap();
let head = s.head();
let waits = |r: Result<u64, StoreError>| match r {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature document_line_projects")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
};
let tagged = || {
vec![
line("Design", 50_000, None),
line("Build", 1_000, Some(zeta)),
]
};
waits(s.create_invoice(c, d(2026, 3, 2), d(2026, 4, 1), tagged()));
waits(s.create_bill(v, d(2026, 3, 2), d(2026, 4, 1), tagged()));
assert_eq!(s.head(), head, "nothing journaled");
assert!(s.ledger().chart().find_by_code("1200").is_none());
// Untagged: as before, accepted.
s.create_invoice(c, d(2026, 3, 2), d(2026, 4, 1), vec![line("A", 100, None)])
.unwrap();
assert!(s.upgrade_format().unwrap().is_some());
let inv = s
.create_invoice(c, d(2026, 3, 3), d(2026, 4, 2), tagged())
.unwrap();
s.create_bill(v, d(2026, 3, 3), d(2026, 4, 2), tagged())
.unwrap();
assert_eq!(
s.ledger().get_invoice(inv).unwrap().lines[1].class_id,
Some(zeta)
);
s.verify().unwrap();
}
/// Invoices and bills an earlier version journaled replay unchanged: no
/// project on their lines, the entry as it was, and opening the books
/// writes nothing.
#[test]
fn old_invoices_and_bills_replay_unchanged() {
let dir = test_dir("old");
let mut w = JournalWriter::open(&dir.0.join(JOURNAL_FILE)).unwrap();
for (t, p) in [
("store.created", json!({"base_currency": "USD"})),
(
"account.added",
json!({"account_type": "Asset", "code": "1200", "currency": "USD", "id": 1, "name": "Accounts Receivable", "parent": null, "placeholder": false}),
),
(
"account.added",
json!({"account_type": "Revenue", "code": "4000", "currency": "USD", "id": 2, "name": "Revenue", "parent": null, "placeholder": false}),
),
(
"account.added",
json!({"account_type": "Expense", "code": "6999", "currency": "USD", "id": 3, "name": "General Expenses", "parent": null, "placeholder": false}),
),
(
"account.added",
json!({"account_type": "Liability", "code": "2000", "currency": "USD", "id": 4, "name": "Accounts Payable", "parent": null, "placeholder": false}),
),
("class.added", json!({"id": 1, "name": "Zeta"})),
("customer.added", json!({"id": 1, "name": "Cedar"})),
("vendor.added", json!({"id": 1, "name": "Dana"})),
(
"entry.created",
json!({"date": "2026-03-01", "description": "Invoice 1", "id": 1}),
),
(
"entry.line_added",
json!({"account": 1, "amount": "125.50", "currency": "USD", "id": 1, "side": "debit"}),
),
(
"entry.line_added",
json!({"account": 2, "amount": "125.50", "currency": "USD", "id": 1, "side": "credit"}),
),
(
"entry.posted",
json!({"date": "2026-03-01", "description": "Invoice 1", "id": 1, "reversal_of": null,
"lines": [{"account": 1, "amount": "125.50", "currency": "USD", "side": "debit"},
{"account": 2, "amount": "125.50", "currency": "USD", "side": "credit"}]}),
),
(
"invoice.created",
json!({"customer_id": 1, "date": "2026-03-01", "due_date": "2026-03-31", "entry_id": 1, "id": 1,
"lines": [{"amount": "100.00", "description": "a"}, {"amount": "25.50", "description": "b"}]}),
),
(
"entry.created",
json!({"date": "2026-03-02", "description": "Bill 1", "id": 2}),
),
(
"entry.line_added",
json!({"account": 3, "amount": "40.00", "currency": "USD", "id": 2, "side": "debit"}),
),
(
"entry.line_added",
json!({"account": 4, "amount": "40.00", "currency": "USD", "id": 2, "side": "credit"}),
),
(
"entry.posted",
json!({"date": "2026-03-02", "description": "Bill 1", "id": 2, "reversal_of": null,
"lines": [{"account": 3, "amount": "40.00", "currency": "USD", "side": "debit"},
{"account": 4, "amount": "40.00", "currency": "USD", "side": "credit"}]}),
),
(
"bill.created",
json!({"vendor_id": 1, "date": "2026-03-02", "due_date": "2026-04-01", "entry_id": 2, "id": 1,
"lines": [{"amount": "40.00", "description": "c"}]}),
),
] {
w.append(t, p).unwrap();
}
drop(w);
let bytes = std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap();
let s = Store::open(&dir.0).unwrap();
s.verify().unwrap();
let inv = s.ledger().get_invoice(1).unwrap();
assert_eq!(
inv.lines,
vec![line("a", 10_000, None), line("b", 2_550, None)]
);
assert_eq!(
s.ledger().get_bill(1).unwrap().lines,
vec![line("c", 4_000, None)]
);
for e in [1, 2] {
let entry = s.ledger().get_entry(e).unwrap();
assert_eq!(entry.lines().len(), 2);
assert!(entry.lines().iter().all(|l| l.class_id.is_none()));
}
assert!(ledger_core::project_entries(s.ledger(), 1).is_empty());
drop(s);
assert_eq!(std::fs::read(dir.0.join(JOURNAL_FILE)).unwrap(), bytes);
}
/// A document line's `class_id` that isn't a class id is damage, never
/// silently no project; null is none, and an id is taken as journaled
/// (replay checks no line, B-28).
#[test]
fn a_document_lines_class_id_that_isnt_an_id_is_corrupt() {
let journal = |dir: &Path, class: Value| {
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
w.append("store.created", json!({"base_currency": "USD"}))
.unwrap();
w.append("customer.added", json!({"id": 1, "name": "Cedar"}))
.unwrap();
w.append(
"invoice.created",
json!({"customer_id": 1, "date": "2026-03-01", "due_date": "2026-03-31", "entry_id": 1, "id": 1,
"lines": [{"amount": "1.00", "description": "a", "class_id": class}]}),
)
.unwrap();
};
for (fine, want) in [(json!(null), None), (json!(7), Some(7))] {
let dir = test_dir("fine-class");
journal(&dir.0, fine);
let s = Store::open(&dir.0).unwrap();
assert_eq!(s.ledger().get_invoice(1).unwrap().lines[0].class_id, want);
}
for bad in [
json!("1"),
json!(-1),
json!(1.5),
json!(u64::from(u32::MAX) + 1),
] {
let dir = test_dir("bad-class");
journal(&dir.0, bad.clone());
assert!(
matches!(Store::open(&dir.0), Err(StoreError::JournalCorrupt(_))),
"{bad}"
);
}
}
+1
View File
@@ -59,6 +59,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
DocumentLine { DocumentLine {
description: desc.to_string(), description: desc.to_string(),
amount: dec(amount), amount: dec(amount),
class_id: None,
} }
} }
+1
View File
@@ -349,6 +349,7 @@ fn approvals_cover_every_post() {
let line = |n: i64| ledger_core::DocumentLine { let line = |n: i64| ledger_core::DocumentLine {
description: "work".into(), description: "work".into(),
amount: Decimal::from(n), amount: Decimal::from(n),
class_id: None,
}; };
assert!(matches!( assert!(matches!(
s.create_bill(v, d(2026, 10, 1), d(2026, 10, 31), vec![line(600)]), s.create_bill(v, d(2026, 10, 1), d(2026, 10, 31), vec![line(600)]),
+4
View File
@@ -815,6 +815,7 @@ fn vendor_1099_tags_replay_and_project() {
vec![DocumentLine { vec![DocumentLine {
description: "Design".into(), description: "Design".into(),
amount: Decimal::from(1500), amount: Decimal::from(1500),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -1209,6 +1210,7 @@ mod refusals {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: Decimal::new(c, 2), amount: Decimal::new(c, 2),
class_id: None,
}] }]
}; };
match *op { match *op {
@@ -2778,6 +2780,7 @@ fn the_database_tracks_what_an_invoice_has_been_paid() {
vec![DocumentLine { vec![DocumentLine {
description: "work".into(), description: "work".into(),
amount: Decimal::from(100), amount: Decimal::from(100),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
@@ -3325,6 +3328,7 @@ fn the_tax_category_policy_refuses_only_what_it_says() {
vec![ledger_core::DocumentLine { vec![ledger_core::DocumentLine {
description: "hosting".into(), description: "hosting".into(),
amount: Decimal::new(4_200, 2), amount: Decimal::new(4_200, 2),
class_id: None,
}], }],
) )
.unwrap(); .unwrap();
+45 -19
View File
@@ -505,7 +505,9 @@ every tool over every book in ledger-store's `tests/fixtures/old-books`.
schedule); and a reversal of a depreciation run or a schedule month's schedule); and a reversal of a depreciation run or a schedule month's
posting (or of a reversal in its chain) dated before that entry's own posting (or of a reversal in its chain) dated before that entry's own
date (a correction comes after what it corrects, B-9). date (a correction comes after what it corrects, B-9).
- `books.get_entry { entry_id }` → full entry with lines, receipts, and - `books.get_entry { entry_id }` → full entry with lines (each `{
account_id, code, name, side, amount, class_id?, project? }`: a tagged
line's project, its id and name), receipts, and
its `vendor`, `party_id`, and `party` (the party's label, when it names its `vendor`, `party_id`, and `party` (the party's label, when it names
exactly one) tags, `parties` / `parties_error` as in the `post_entry` exactly one) tags, `parties` / `parties_error` as in the `post_entry`
preview, and `no_party_reason` when it is marked as having no preview, and `no_party_reason` when it is marked as having no
@@ -1980,19 +1982,46 @@ accounting only: the caller supplies every figure), tax filing.
- `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to - `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to
a party ("Tools — parties"). a party ("Tools — parties").
- `books.create_invoice { party_id, date, due_date, lines: [{ description, - `books.create_invoice { party_id, date, due_date, lines: [{ description,
amount }] }` → `{ id, total, entry_id, warnings? }`. Auto-posts Dr amount, class? }] }` → `{ id, total, entry_id, warnings?, notes? }`.
`1200` Accounts Receivable / Cr `4000` Revenue. `warnings` names a Auto-posts Dr `1200` Accounts Receivable / Cr `4000` Revenue. `warnings`
revenue account it created with no tax category (rules.md B-32). names a revenue account it created with no tax category (rules.md
`party_id` is the customer's `id` from `books.add_customer`, not its B-32). `party_id` is the customer's `id` from `books.add_customer`, not
`party_id`. its `party_id`. A line's `class` (a project: name or id, resolved as
`books.add_line`'s) puts its revenue on that project (rules.md B-37 item
8, the owner's decision D-43): one project or none per line, several
projects as several lines each with its own amount, never a percentage
(a cost truly shared names none: it reaches the projects only as
entries of their own under the company's monthly allocation convention,
when `books.conventions` has one, and otherwise stays untagged while the
agent asks the human). The entry's revenue side is one line per project named, in the
order each is first named, the untagged lines' as one (a document
naming none posts the one revenue line it always did); the receivable
line is never tagged. Every project is resolved before anything is
written (an unknown one is refused, the document whole). `notes`: one
per finished or abandoned project named, as `books.add_line`'s `note`,
and one per on-spec project named, asking whether there is now an
agreement (on spec, a hoped-for payment is no receivable until
received: B-37 item 4) (tagged anyway). Books that haven't declared the journal feature
`document_line_projects` (B-27; the live books until their owner's next
upgrade, D-39) refuse a line naming a project with
`format_upgrade_pending`, journaling nothing; a document naming none is
taken as before.
- `books.create_bill { party_id, date, due_date, lines }` → `{ id, total, - `books.create_bill { party_id, date, due_date, lines }` → `{ id, total,
entry_id, warnings? }`. Auto-posts Dr `6999` General Expenses / Cr entry_id, warnings?, notes? }`. Auto-posts Dr `6999` General Expenses /
`2000` Accounts Payable; `warnings` as for invoices. `party_id` is the Cr `2000` Accounts Payable; `warnings` as for invoices. `party_id` is
vendor's `id` from `books.add_vendor`, not its `party_id`. the vendor's `id` from `books.add_vendor`, not its `party_id`. A line's
`class` puts its cost on that project, as an invoice's its revenue: one
expense line per project, the untagged lines' as one, the payable line
never tagged; `notes` as for invoices, but none for an on-spec project
(a cost of on-spec work is an expense like any other); the feature as
for invoices.
- `books.receive_payment { document_id, date, amount, cash_account }` → - `books.receive_payment { document_id, date, amount, cash_account }` →
`{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R. `{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R, neither line
tagged with a project (balance-sheet only: the invoice's lines carry
its projects).
- `books.pay_bill { document_id, date, amount, cash_account }` → - `books.pay_bill { document_id, date, amount, cash_account }` →
`{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash. `{ entry_id, remaining, status }`. Posts Dr A/P / Cr Cash, neither line
tagged with a project.
- `books.ar_aging { as_of? }` → `{ as_of, rows: [{ name, current, days_30, - `books.ar_aging { as_of? }` → `{ as_of, rows: [{ name, current, days_30,
days_60, days_90, days_90_plus, total }] }` (`as_of` omitted: today). days_60, days_90, days_90_plus, total }] }` (`as_of` omitted: today).
- `books.ap_aging { as_of? }` → same, per vendor (`as_of` omitted: today). - `books.ap_aging { as_of? }` → same, per vendor (`as_of` omitted: today).
@@ -2121,14 +2150,11 @@ until the owner upgrades them.
- `books.record_payout` lines and adjustments take optional `class` (a - `books.record_payout` lines and adjustments take optional `class` (a
project); its result gains `notes`, one per finished or abandoned project); its result gains `notes`, one per finished or abandoned
project tagged, as `books.add_line`'s `note`. project tagged, as `books.add_line`'s `note`.
- An invoice or a bill takes no project yet: `books.create_invoice` and - An invoice's or bill's lines take a project too (`books.create_invoice`
`books.create_bill` post their entry at once with untagged lines, and and `books.create_bill`, `class` on a line, rules.md B-37 item 8), in
only a draft's lines take a class (`books.add_line`'s `class`). Link the document to the books that declared the journal feature `document_line_projects`; the
project (`books.link_attachment` subject kind `project`); when its lines handbook's projects section says which these books are. Their payments
must count in the project's figures, book it as a hand-built entry take none.
(`books.draft_entry` + `books.add_line` class) instead, which then is
no invoice or bill in the agings (an open item: tagged document
lines).
- `books.profit_and_loss_by_class { start?, end?, class? }` (omitted dates: - `books.profit_and_loss_by_class { start?, end?, class? }` (omitted dates:
the fiscal year to date, as every period report) → per-class revenue, the fiscal year to date, as every period report) → per-class revenue,
expenses, net income, plus an unclassified row (only that class's row expenses, net income, plus an unclassified row (only that class's row
+1 -1
View File
@@ -37,7 +37,7 @@ No other statuses.
| A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5 | | A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5 |
| A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5 | | A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5 |
| Payroll runs (accounting only, no tax tables) + tax remittance | Payroll | done | — | | Payroll runs (accounting only, no tax tables) + tax remittance | Payroll | done | — |
| Tracking classes (divisional P&L) | Class tracking | done | — | | Tracking classes (divisional P&L) | Class tracking | done | invoice and bill lines take a class too (projects, rules.md B-37 item 8; QBO/Xero class or tracking per line) |
| CPA handoff package (TB + GL xlsx, P&L + BS pdf, zip) | Reports → export | done | — | | CPA handoff package (TB + GL xlsx, P&L + BS pdf, zip) | Reports → export | done | — |
| Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) | | Budgets | Budgets | todo | R-FUTURE-6 (declined as bloat) |
| Scheduled transactions (recurring entries → drafts) | Scheduled txns / Recurring transactions | partial | hand-verified fixture incl. month-end dates; drafts only, by design | | Scheduled transactions (recurring entries → drafts) | Scheduled txns / Recurring transactions | partial | hand-verified fixture incl. month-end dates; drafts only, by design |
+81 -14
View File
@@ -1585,7 +1585,12 @@ value at contribution and the §704(c) method, B-25) and `projects` (a
`project.set`, a new event type: a class's project facts; an `project.set`, a new event type: a class's project facts; an
`attachment.linked` to the subject kind `project`, which an older version `attachment.linked` to the subject kind `project`, which an older version
would call corrupt; a `decision.recorded` with `projects`, which an older would call corrupt; a `decision.recorded` with `projects`, which an older
version would drop silently: B-37). A command asks for version would drop silently: B-37). After P3: `document_line_projects`
(an `invoice.created` or `bill.created` line carrying `class_id`, its
project, B-37 item 8; an older version would restore the document without
it, silently). It is a feature of its own, not part of `projects`: books
that declared `projects` before it existed wait for their owner's next
upgrade before a document's line names a project. A command asks for
the feature it needs (`Store::ensure_feature`) before its first journaled the feature it needs (`Store::ensure_feature`) before its first journaled
line, and only when the line it is about to write uses it, so a write line, and only when the line it is about to write uses it, so a write
refused for waiting on the upgrade journals nothing, never an entry refused for waiting on the upgrade journals nothing, never an entry
@@ -2545,8 +2550,12 @@ package). **Checked:** `ledger-core` `tests/tax_capital.rs`;
a class as a project ... Projects are entities too and can have name, a class as a project ... Projects are entities too and can have name,
description, whatever other metadata you think they should have"; the description, whatever other metadata you think they should have"; the
convention `link-to-projects` in the books: "whenever applicable, we link convention `link-to-projects` in the books: "whenever applicable, we link
things to [projects]"); CONVENTION (job costing, item 3; the snapshot, item 7: a presentation choice from management accounting, not GAAP); AUTHORITY (on things to [projects]"); DECISION (the owner, 2026-10-22, plan D-43: an invoice's or bill's
spec, item 4: ASC 450-30-25-1; ASC 340-40-25-5 with B-22; lines take a project, each line one or none, item 8); CONVENTION (job
costing, items 3 and 8; the snapshot, item 7: a presentation choice from
management accounting, not GAAP); AUTHORITY (on
spec, item 4: ASC 450-30-25-1; ASC 340-40-25-5 with B-22; directly
related costs, item 8: ASC 340-40-25-7;
`sources/fasb-asc.md`, both quoted through Deloitte's DART roadmaps and `sources/fasb-asc.md`, both quoted through Deloitte's DART roadmaps and
not yet re-read in the ASC basic view: an open item) not yet re-read in the ASC basic view: an open item)
@@ -2582,8 +2591,10 @@ not yet re-read in the ASC basic view: an open item)
such line through the day, from the first day the books keep, whatever such line through the day, from the first day the books keep, whatever
the project's dates. A tagged balance-sheet line counts nothing (an the project's dates. A tagged balance-sheet line counts nothing (an
asset bought for it counts as it is depreciated or expensed, tagged asset bought for it counts as it is depreciated or expensed, tagged
then). No cost is allocated: a shared cost counts only as split across then). The books allocate no cost: a shared cost counts toward
lines when it is entered. The projects with the untagged lines projects only as allocation entries of their own, made by the
company's allocation convention (item 8). The projects with the
untagged lines
partition the P&L. By month, the period's last 240 months at most. partition the P&L. By month, the period's last 240 months at most.
4. **On spec.** Work done hoping to be paid: its costs are expenses when 4. **On spec.** Work done hoping to be paid: its costs are expenses when
incurred (B-22; ASC 340-40-25-5 makes a cost of an anticipated incurred (B-22; ASC 340-40-25-5 makes a cost of an anticipated
@@ -2595,8 +2606,9 @@ not yet re-read in the ASC basic view: an open item)
same). Its unrecovered cost, "if repaid", is expenses less revenue to same). Its unrecovered cost, "if repaid", is expenses less revenue to
date, never below zero: a figure, never an asset. date, never below zero: a figure, never an asset.
5. **Status.** Finished and abandoned projects still take lines (a late 5. **Status.** Finished and abandoned projects still take lines (a late
cost, a final invoice); `books.add_line` and `books.record_payout` say cost, a final invoice); `books.add_line`, `books.record_payout`,
so when one is tagged. `books.create_invoice` and `books.create_bill` say so when one is
tagged.
6. Omitted dates mean what B-30 says (`books.project`'s period); to date 6. Omitted dates mean what B-30 says (`books.project`'s period); to date
is always stated as such, beside it. is always stated as such, beside it.
7. **Snapshot (a presentation choice, not GAAP).** A project's net is 7. **Snapshot (a presentation choice, not GAAP).** A project's net is
@@ -2625,6 +2637,52 @@ not yet re-read in the ASC basic view: an open item)
standard defines or requires these figures (ASC 280's segment standard defines or requires these figures (ASC 280's segment
reporting is for public entities' operating segments, not projects); reporting is for public entities' operating segments, not projects);
they come from item 3's, one function the tools and the viewer share. they come from item 3's, one function the tools and the viewer share.
8. **Invoices and bills (the owner's decision, 2026-10-22, D-43).** Each
line of an invoice or bill names one project or none. A document that
spans N projects is N lines, each with its own amount; there is no
percentage split, so every figure is exact, as entered, with nothing
to round. A cost truly shared among projects is not split on the
document: its line names none, and it reaches the projects, if at
all, only as allocation entries of their own, on the stated basis of
the company's monthly allocation convention when `books.conventions`
has one; without one the line stays untagged and the agent asks the
human. The reasoning: job costing (item 3) charges a job the costs
traceable to it, its direct costs, whole, and reaches a shared
(indirect) cost only by an allocation on a stated basis, never by
guessing at the document. ASC 340-40-25-7 counts as relating
directly to a contract (25-5(a)) both the costs traceable to it
(direct labor and materials, costs explicitly chargeable to the
customer, costs incurred only because of the contract, such as
subcontractors: 25-7(a), (b), (d), (e)) and allocations of costs that
relate directly to the contract or its activities (25-7(c)); 25-8
sends general and administrative costs to expense unless the contract
makes them chargeable to the customer (`sources/fasb-asc.md`). The
books carry the first kind whole on the document's line and the
second as entries under the allocation convention on its basis;
neither is a percentage on the document, and 25-7(c) is what the
allocation convention models. An invoice line naming an on-spec
project (item 4) is taken, and the tools ask whether there is now an
agreement: on spec, a hoped-for payment is no receivable until it is
received. Its revenue (an
invoice's) or cost (a bill's) then counts toward the project exactly
as a tagged line of a hand-built entry does: the document's entry
posts one revenue (expense) line per project its lines name, in the
order each is first named, each the sum of that project's lines, and
the untagged lines' sum as one line (`arap::document_parts`), so a
document naming none posts exactly the one line every document posted
before. The receivable (payable) line is never tagged: it is a
balance-sheet line (item 3 counts it nothing), and so are the
payments (`books.receive_payment`, `books.pay_bill`: cash against the
receivable or payable), which take no project. Nothing else moves: the
P&L, the 1099 worksheet (B-19: payments made) and a party's totals
(B-35: what moved the money) read the same with or without the
projects; only the projects' figures and the P&L by class differ. A
line naming a finished or abandoned project is taken, and the tools
say so (item 5); one naming a project the books don't have is refused,
the document whole. Journaled as the line's `class_id` in
`invoice.created` or `bill.created` and an `entry.line_class_set` right
after its revenue or expense line's `entry.line_added`, behind the
journal feature `document_line_projects` (B-27).
**Enforced:** `ledger_core::classes::{ProjectFacts, ProjectStatus, **Enforced:** `ledger_core::classes::{ProjectFacts, ProjectStatus,
ProjectKind}`, `Ledger::{check_set_project, set_project, ProjectKind}`, `Ledger::{check_set_project, set_project,
@@ -2632,13 +2690,22 @@ replay_set_project, projects_of_party}`, `reports::{project_figures,
project_to_date, project_entries, contribution_margin_pct}`, project_to_date, project_entries, contribution_margin_pct}`,
`ProjectToDate::{net, margin_pct, any_tagged}`, `ProjectFigures::{months_net_to_date, `ProjectToDate::{net, margin_pct, any_tagged}`, `ProjectFigures::{months_net_to_date,
period_margin_pct, period_lines}`, `kernel::unrecovered_cost`; period_margin_pct, period_lines}`, `kernel::unrecovered_cost`;
`Store::{add_project, set_project, subject_feature}` (feature `arap::document_parts`, `Ledger::{create_invoice, create_bill}` (item
`projects`); `books.add_project`, `books.update_project`, 8); `Store::{add_project, set_project, subject_feature}` (feature
`books.projects`, `books.project`; the viewer's Projects pages. `projects`), `Store::{create_invoice, create_bill}` (feature
**Checked:** `ledger-core` `tests/spec_projects.rs`, `ledger-store` `document_line_projects`); `books.add_project`, `books.update_project`,
`tests/projects.rs`, `ledger-mcp` `tests/spec_projects.rs`, `ledger-gui` `books.projects`, `books.project`, `books.create_invoice` and
`tests/projects.rs`; bookspec `projects-net-is-its-tagged-lines` and `books.create_bill` (`class` on a line); the viewer's Projects pages and
`projects-on-spec-costs-are-expenses`; Kani `unrecovered_cost_laws`; an invoice's or bill's page (each line's project).
**Checked:** `ledger-core` `tests/spec_projects.rs` and
`tests/spec_document_projects.rs`, `ledger-store` `tests/projects.rs` and
`tests/document_projects.rs`, `ledger-mcp` `tests/spec_projects.rs`,
`ledger-gui` `tests/projects.rs`, `ledger-cli` `tests/projects_parity.rs`
and `tests/format_upgrade.rs` (`books_p2_served_cut_over_whole`); bookspec
`projects-net-is-its-tagged-lines`,
`projects-on-spec-costs-are-expenses` and
`projects-invoice-and-bill-lines-count-by-project`; proptest
`prop_document_parts_partition_the_total`; Kani `unrecovered_cost_laws`;
proptests `prop_project_nets_partition_the_pnl`, proptests `prop_project_nets_partition_the_pnl`,
`prop_project_set_round_trips`, `prop_project_contribution_is_the_net_to_date` `prop_project_set_round_trips`, `prop_project_contribution_is_the_net_to_date`
(core) and `prop_snapshot_contribution_is_the_net_to_date` (viewer). (core) and `prop_snapshot_contribution_is_the_net_to_date` (viewer).
+29
View File
@@ -274,3 +274,32 @@ performance obligations in the future; and they "are expected to be
recovered." recovered."
→ B-37 item 4 (with B-22): on-spec work has no contract it is expected → B-37 item 4 (with B-22): on-spec work has no contract it is expected
to recover its costs from, so its costs are expensed as incurred. to recover its costs from, so its costs are expensed as incurred.
## 340-40-25-7 (Costs that relate directly to a contract)
Added 2026-10-22 for B-37 item 8 as the DART revenue roadmap sets it
out; not re-read in the ASC basic view: check it there at the next
reading. The costs that "relate directly to a contract" (25-5(a)) are
(a) direct labor, (b) direct materials, (c) allocations of costs that
relate directly to the contract or its activities (supervision,
insurance, depreciation of the tools used on it), (d) costs explicitly
chargeable to the customer, and (e) other costs incurred only because
the entity entered into the contract (payments to subcontractors, for
one). Allocations are among the directly related costs, not set apart
from them.
## 340-40-25-8 (Costs expensed when incurred)
Added 2026-10-22 with 25-7, from the same DART chapter (13.3), likewise
to check in the basic view. Expensed when incurred: general and
administrative costs (unless explicitly chargeable to the customer under
the contract), costs of wasted materials, labor or other resources not
reflected in the contract's price, costs that relate to satisfied (or
partly satisfied) performance obligations, and costs the entity cannot
tell apart between satisfied and unsatisfied obligations.
→ B-37 item 8 (with 25-7): a cost traceable to one job (25-7(a), (b),
(d), (e)) is that job's, whole, on the document's line; a cost that
relates to several jobs reaches them only by an allocation on a stated
basis (25-7(c)), which the books record as the allocation convention's
own entries, never as a percentage on the document; general and
administrative cost (25-8) is company overhead, no project's.
+110
View File
@@ -2028,6 +2028,116 @@ fail.
rounding strategy; `project_figures`' opening net and running sum; rounding strategy; `project_figures`' opening net and running sum;
`profit_and_loss_counted`'s counts; `Standing::of`'s order. `profit_and_loss_counted`'s counts; `Standing::of`'s order.
## Invoices and bills take a project (2026-10-22)
rules.md B-37 item 8: each invoice and bill line takes an optional project
(`books.create_invoice` / `books.create_bill` `class`); the entry's revenue
or expense side is one line per project (`arap::document_parts`), the
receivable or payable and the payments untagged; journaled as the line's
`class_id` behind the new feature `document_line_projects` (B-27).
- **ledger-core:** `tests/spec_document_projects.rs`
(`an_invoices_revenue_is_one_line_per_project_and_the_receivable_none`,
`a_bills_cost_is_one_line_per_project_and_the_payable_none`,
`an_untagged_document_posts_as_before`,
`a_line_naming_an_unknown_project_is_refused_whole`,
`a_finished_project_still_takes_an_invoice`,
`tags_move_only_the_project_figures` (twin books, tagged and not: the
P&L, the 1099 worksheet and a party's activity identical; the projects
with the untagged lines partition the P&L),
`document_parts_refuse_past_exact_arithmetic`,
`reversing_a_tagged_document_takes_its_projects_to_zero` (a partial
payment moves no project figure; the reversal carries each line's
project, so every project nets to zero), and the proptest
`prop_document_parts_partition_the_total`); bookspec
`projects-invoice-and-bill-lines-count-by-project` (the loader gains an
`invoice` event and a document's `lines` with `project`; loader tests
`a_document_line_naming_an_unknown_project_is_refused`,
`a_document_with_an_amount_and_lines_is_refused`,
`an_invoice_lines_project_is_what_the_project_counts`).
- **ledger-store:** `tests/document_projects.rs`
(`a_tagged_invoice_journals_each_projects_line_and_replays`: the exact
lines, then verify, reopen and the viewer's snapshot;
`a_tagged_bill_tags_its_expense_lines_never_the_payable`;
`an_untagged_document_journals_as_before`: no `class_id` anywhere;
`a_document_naming_a_project_waits_for_the_feature`: books with
`projects` but not this feature refuse, journaling nothing, not even
the control accounts; `old_invoices_and_bills_replay_unchanged`: an
earlier version's journal opens, verifies, writes nothing;
`a_document_lines_class_id_that_isnt_an_id_is_corrupt`;
`a_reversed_tagged_invoice_replays_with_its_projects_at_zero`: one
`entry.reversed`, its replay tagged as live); `format.rs`
`document_line_projects_is_its_own_feature`.
- **ledger-mcp:** `tests/spec_projects.rs`
(`invoice_and_bill_lines_count_toward_their_projects`: by name, id and
any capitals; `books.get_entry`'s line `project` and `class_id`;
payments untagged;
`books.project`, `profit_and_loss_by_class`, `vendor_1099` and
`books.party` totals; `a_document_naming_an_unknown_project_is_refused_whole`,
`a_document_for_a_finished_project_says_so`,
`an_invoice_for_an_on_spec_project_asks_about_an_agreement` (an
invoice's note, a bill's none),
`books_without_the_feature_refuse_a_tagged_document` with the
handbook's text both ways, by `books.handbook` and as the
`books://handbook/projects` resource); `handbook.rs`
`document_projects_follow_the_books_feature` (no sentence lets a shared
cost be split on a document; the allocation convention only when
`books.conventions` has one).
- **ledger-gui:** `tests/projects.rs`
(`an_invoice_and_a_bill_show_each_lines_project`: each document line's
project a link that opens it, untagged lines bare;
`the_demos_client_work_counts_every_client_invoice`);
`pages::entry::tests::a_tagged_line_keeps_its_amounts_in_their_column`
(in a narrow pane a tagged line's name is cut to leave its tag room:
looking at the demo's invoice page at 1,100 points found the tag pushing
the credit column past the pane, a fault every tagged entry had);
`tests/screens.rs` shoots an invoice's and a bill's page.
- **ledger-cli:** `tests/projects_parity.rs`
(`invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_server`:
made through the server's tools, read by both, the month lists the same
length and not empty); `tests/format_upgrade.rs`
`books_p2_served_cut_over_whole` refuses a tagged invoice and bill
before consent and takes them after.
- **fuzz:** `fuzz_store_replay` can now declare features (this one
among them), so a document line's `class_id` reaches replay under a
declared format.
**Broken to confirm (each restored):** the core's revenue/expense line
left untagged (core 4 and store 3 tests fail); `document_parts` one part
per line (4 core, the proptest among them); the store's feature check
dropped (store gate test, MCP `books_without_the_feature_...`, CLI
`books_p2_served_cut_over_whole`); `document_line_json` without
`class_id` (3 store); `parse_document_lines` ignoring it (4 store, the
corrupt-id test among them); the `entry.line_class_set` not journaled (3
store, by verify); the MCP ignoring a line's `class` (4 MCP); no late-tag
notes (1 MCP); the handbook ungated (the handbook unit test and 1 MCP);
the handbook's rule without the owner's decision of D-43 (one project or
none a line, no percentage, a shared cost by the allocation convention:
the handbook unit test);
`books.get_entry` without `project` (2 MCP, the CLI parity test); the
viewer's document lines dropped, or their project not a link (the GUI
tests); no room kept back for the tag (the narrow-pane test); the demo's invoices untagged (the demo test); the bookspec loader
dropping a line's project (`every_bookspec_example_holds`). After
review: the projects handbook's old "split a shared cost across lines"
sentence restored, or the convention made unconditional (the handbook
unit test); no on-spec note, or one on a bill too (the on-spec MCP test);
the handbook resource served as if the feature were declared
(`books_without_the_feature_...`); `books.get_entry`'s `class_id`
dropped (`invoice_and_bill_lines_...`); the server's months cut short
(the parity test, which `zip` alone let pass); a reversal's line tags
dropped (the core and store reversal tests); the bookspec's Harbor made
on spec again (`every_bookspec_example_holds`). A separate
up-front unknown-class check in the core was removed: equivalent to
`set_line_class`'s own refusal inside `atomically`.
**For P12 (mutation targets):** `document_parts` (the first-appearance
order, the `contains` test); `add_document_parts`' tag index;
`ensure_document_lines`' `any`; `document_line_json`'s `if let`;
`parse_document_lines`' arms (null, absent, out of `u32`);
`document_notes`' dedup and its `invoice` flag; `on_spec_invoice_note`'s
kind test; `document_projects_rule`'s branch; the viewer's
`document_of` (invoice before bill).
## Benchmarks (baseline 2026-10-13) ## Benchmarks (baseline 2026-10-13)
No budget, by decision (the owner, 2026-10-13: the books do little work No budget, by decision (the owner, 2026-10-13: the books do little work
+131 -4
View File
@@ -65,6 +65,18 @@ dependencies = [
"derive_arbitrary", "derive_arbitrary",
] ]
[[package]]
name = "argon2"
version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
"cpufeatures 0.3.1",
"password-hash",
]
[[package]] [[package]]
name = "arrayref" name = "arrayref"
version = "0.3.9" version = "0.3.9"
@@ -101,6 +113,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "biblatex" name = "biblatex"
version = "0.12.0" version = "0.12.0"
@@ -154,6 +172,15 @@ dependencies = [
"serde_core", "serde_core",
] ]
[[package]]
name = "blake2"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
"digest 0.11.3",
]
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
version = "0.10.4" version = "0.10.4"
@@ -163,6 +190,15 @@ dependencies = [
"generic-array", "generic-array",
] ]
[[package]]
name = "block-buffer"
version = "0.12.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "borsh" name = "borsh"
version = "1.8.1" version = "1.8.1"
@@ -325,6 +361,12 @@ dependencies = [
"serde_path_to_error", "serde_path_to_error",
] ]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]] [[package]]
name = "cobs" name = "cobs"
version = "0.3.0" version = "0.3.0"
@@ -406,6 +448,15 @@ dependencies = [
"libc", "libc",
] ]
[[package]]
name = "cpufeatures"
version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
dependencies = [
"libc",
]
[[package]] [[package]]
name = "crc32fast" name = "crc32fast"
version = "1.5.2" version = "1.5.2"
@@ -456,6 +507,15 @@ dependencies = [
"typenum", "typenum",
] ]
[[package]]
name = "crypto-common"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
]
[[package]] [[package]]
name = "csv" name = "csv"
version = "1.4.0" version = "1.4.0"
@@ -477,6 +537,15 @@ dependencies = [
"memchr", "memchr",
] ]
[[package]]
name = "ctutils"
version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "03bb0e1cc970d482d121d9a1744999169b69a07470b3d644a7894e53fcaf4574"
dependencies = [
"cmov",
]
[[package]] [[package]]
name = "data-url" name = "data-url"
version = "0.3.2" version = "0.3.2"
@@ -531,14 +600,31 @@ dependencies = [
"syn 3.0.9", "syn 3.0.9",
] ]
[[package]]
name = "deunicode"
version = "1.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "339544cc9e2c4dc3fc7149fd630c5f22263a4fdf18a98afd0075784968b5cf00"
[[package]] [[package]]
name = "digest" name = "digest"
version = "0.10.7" version = "0.10.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
dependencies = [ dependencies = [
"block-buffer", "block-buffer 0.10.4",
"crypto-common", "crypto-common 0.1.7",
]
[[package]]
name = "digest"
version = "0.11.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2",
"ctutils",
] ]
[[package]] [[package]]
@@ -1055,6 +1141,15 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hybrid-array"
version = "0.4.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
dependencies = [
"typenum",
]
[[package]] [[package]]
name = "hypher" name = "hypher"
version = "0.1.8" version = "0.1.8"
@@ -1541,6 +1636,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"chrono", "chrono",
"csv", "csv",
"deunicode",
"ledger-core", "ledger-core",
"qrcode", "qrcode",
"rust_decimal", "rust_decimal",
@@ -1558,14 +1654,18 @@ dependencies = [
name = "ledger-store" name = "ledger-store"
version = "0.1.0" version = "0.1.0"
dependencies = [ dependencies = [
"argon2",
"chrono", "chrono",
"getrandom 0.2.17",
"hex", "hex",
"ledger-core", "ledger-core",
"libc",
"rusqlite", "rusqlite",
"rust_decimal", "rust_decimal",
"serde", "serde",
"serde_json", "serde_json",
"sha2", "sha2",
"subtle",
"thiserror", "thiserror",
] ]
@@ -1819,6 +1919,16 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "password-hash"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
"getrandom 0.4.3",
"phc",
]
[[package]] [[package]]
name = "paste" name = "paste"
version = "1.0.15" version = "1.0.15"
@@ -1856,6 +1966,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phc"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
dependencies = [
"base64ct",
"ctutils",
"getrandom 0.4.3",
]
[[package]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"
@@ -2478,8 +2599,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"cpufeatures", "cpufeatures 0.2.17",
"digest", "digest 0.10.7",
] ]
[[package]] [[package]]
@@ -2607,6 +2728,12 @@ dependencies = [
"write-fonts", "write-fonts",
] ]
[[package]]
name = "subtle"
version = "2.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
[[package]] [[package]]
name = "svgtypes" name = "svgtypes"
version = "0.16.1" version = "0.16.1"
+17 -1
View File
@@ -63,10 +63,16 @@ const FIELDS: &[&str] = &[
"status", "party_id", "started_on", "ended_on", "links", "notes", "projects", "status", "party_id", "started_on", "ended_on", "links", "notes", "projects",
]; ];
/// Journal features a `journal.format_set` may declare.
const FEATURES: &[&str] = &[
"projects", "document_line_projects", "payment_instruments", "month_reposting",
"a_feature_from_the_future",
];
/// A value replay might plausibly see: small ids, amounts as decimal /// A value replay might plausibly see: small ids, amounts as decimal
/// strings, dates, sides, currencies, account types, nested objects. /// strings, dates, sides, currencies, account types, nested objects.
fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> { fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> {
Ok(match u.int_in_range(0..=12)? { Ok(match u.int_in_range(0..=13)? {
0 => json!(u.int_in_range(0u64..=6)?), 0 => json!(u.int_in_range(0u64..=6)?),
1 => json!(format!("{}.{:02}", u.int_in_range(0u32..=5000)?, u.int_in_range(0u32..=99)?)), 1 => json!(format!("{}.{:02}", u.int_in_range(0u32..=5000)?, u.int_in_range(0u32..=99)?)),
2 => json!(format!("2026-{:02}-{:02}", u.int_in_range(1u32..=12)?, u.int_in_range(1u32..=28)?)), 2 => json!(format!("2026-{:02}-{:02}", u.int_in_range(1u32..=12)?, u.int_in_range(1u32..=28)?)),
@@ -83,6 +89,16 @@ fn value(u: &mut Unstructured, depth: u32) -> arbitrary::Result<Value> {
10 if depth < 2 => object(u, depth + 1)?, 10 if depth < 2 => object(u, depth + 1)?,
// 1099 decisions and payment methods (plan P3). // 1099 decisions and payment methods (plan P3).
11 => json!(*u.choose(&["nec", "misc", "not_reportable", "corporation", "business_card", "member_card", "0042"])?), 11 => json!(*u.choose(&["nec", "misc", "not_reportable", "corporation", "business_card", "member_card", "0042"])?),
// A declaration's features: some this version knows (projects,
// the invoice and bill lines' projects) and one it doesn't.
12 => {
let n = u.int_in_range(0..=3)?;
Value::Array(
(0..n)
.map(|_| u.choose(FEATURES).map(|f| json!(*f)))
.collect::<arbitrary::Result<_>>()?,
)
}
_ => json!(String::arbitrary(u)?), _ => json!(String::arbitrary(u)?),
}) })
} }
+20 -3
View File
@@ -167,10 +167,27 @@ the net to date, and costs by category. The sidebar measures its footer.
made; the bridge says a locked keyring instead of "make a token"; `token made; the bridge says a locked keyring instead of "make a token"; `token
check` tells a kept token live, revoked, missing or duplicated. check` tells a kept token live, revoked, missing or duplicated.
**Invoices and bills take a project (2026-10-22).** Each line of
`books.create_invoice` and `books.create_bill` takes an optional `class`
(a project, by id or name): the entry's revenue or expense side is one
line per project, the untagged lines' as one, the receivable or payable
line and the payments never tagged (rules.md B-37 item 8), so a client
project's invoiced revenue and a contractor's billed cost count in its
figures, the P&L by class, and its snapshot; the P&L, the 1099 worksheet
and party totals don't move. Journaled as the line's `class_id` behind a
new feature, `document_line_projects` (not `projects`, which the live
books already declared); a document naming none is journaled byte for
byte as before. Late-tag notes as `books.add_line`'s; `books.get_entry`
shows a line's project; the viewer's invoice and bill pages list the
document's lines, each project a link; the demo's "Client work" carries
its invoices. The handbook's caveat is gone for books with the feature.
**Still open after P3 (each for the phase named, or the lead):** **Still open after P3 (each for the phase named, or the lead):**
- Invoices and bills take no project: a client project's invoiced revenue - Invoices and bills take a project now (above), behind the new journal
can't count toward it (a format-gated field on `invoice.created` and feature `document_line_projects`: the first company's books declared
`bill.created`; the owner to decide). `projects` but not it, so until the owner's next format upgrade
(`wanderbooks books upgrade`, D-39) their invoices and bills still take
none (the handbook says so).
- Raised by the cutover review (private change log, "Open points"): the - Raised by the cutover review (private change log, "Open points"): the
IIF leaves its CLASS field empty although entries carry projects; a IIF leaves its CLASS field empty although entries carry projects; a
state filing fee is classed as cash paid to suppliers where ASC state filing fee is classed as cash paid to suppliers where ASC
+1
View File
@@ -1336,6 +1336,7 @@ The owner decisions of 2026-10-16 (at the top of this document) replace the form
| D-40 | 1099s (P3, defects 23, 12 and 18; owner, 2026-10-20) | **A bank payment with no method recorded counts toward 1099s** (presumed check, ACH or transfer; debit-card payments are tagged as card). **Anthropic, PBC is recorded "not reportable: a corporation (Delaware public benefit corporation, from the public record)"**; Namecheap, Apple and OpenRouter "a corporation by name" (Treas. Reg. §1.6041-3(p)(1)), USPS "the United States" (§1.6041-3(p)(3)); Google LLC stays undecided until a W-9 shows what it is. **Needs attention asks only about vendors paid in a way that could need a 1099.** **A vendor's 1099 status is dated** (owner: "I think legally this has to be 2 dated statuses, right? If someone incorporates on Dec 30 …"): each status carries the date it takes effect (the W-9's date, or a tax election's effective date), and the worksheet applies the status in force on each payment's date. A payee that incorporates is a new legal person (a new vendor record with its own W-9); an LLC electing corporate taxation keeps its EIN and changes status on the election's date: dated statuses cover both. A status changed for a closed year warns. | | D-40 | 1099s (P3, defects 23, 12 and 18; owner, 2026-10-20) | **A bank payment with no method recorded counts toward 1099s** (presumed check, ACH or transfer; debit-card payments are tagged as card). **Anthropic, PBC is recorded "not reportable: a corporation (Delaware public benefit corporation, from the public record)"**; Namecheap, Apple and OpenRouter "a corporation by name" (Treas. Reg. §1.6041-3(p)(1)), USPS "the United States" (§1.6041-3(p)(3)); Google LLC stays undecided until a W-9 shows what it is. **Needs attention asks only about vendors paid in a way that could need a 1099.** **A vendor's 1099 status is dated** (owner: "I think legally this has to be 2 dated statuses, right? If someone incorporates on Dec 30 …"): each status carries the date it takes effect (the W-9's date, or a tax election's effective date), and the worksheet applies the status in force on each payment's date. A payee that incorporates is a new legal person (a new vendor record with its own W-9); an LLC electing corporate taxation keeps its EIN and changes status on the election's date: dated statuses cover both. A status changed for a closed year warns. |
| D-41 | The contributed devices' tax basis (P3, defect 13; owner, 2026-10-20) | **Depreciable basis is the lower of fair market value and the member's adjusted basis at conversion** (Treas. Reg. §1.168(i)-4(b); Pub. 946, property changed from personal use): for the four contributed devices, the CPA to confirm at year end. **§704(c) method: traditional**, recorded now and confirmed with the CPA before the first Form 1065 (for members filing jointly every method gives the household the same tax). **The four devices' values on 2026-10-15 are the books' amounts**, citing the entries that recorded them (figures in the company's private change log). **New P3 item: the K-1 worksheet reports tax-basis capital** (Form 1065 instructions, Item L, since 2020), the contributed property at its §723 carryover basis, beside the book figures. | | D-41 | The contributed devices' tax basis (P3, defect 13; owner, 2026-10-20) | **Depreciable basis is the lower of fair market value and the member's adjusted basis at conversion** (Treas. Reg. §1.168(i)-4(b); Pub. 946, property changed from personal use): for the four contributed devices, the CPA to confirm at year end. **§704(c) method: traditional**, recorded now and confirmed with the CPA before the first Form 1065 (for members filing jointly every method gives the household the same tax). **The four devices' values on 2026-10-15 are the books' amounts**, citing the entries that recorded them (figures in the company's private change log). **New P3 item: the K-1 worksheet reports tax-basis capital** (Form 1065 instructions, Item L, since 2020), the contributed property at its §723 carryover basis, beside the book figures. |
| D-42 | A member paying for the company, on the cash flow statement (P3, defect 1; owner, 2026-10-20, revising D-35's first point after the review found ASC 230 silent on a payment a third party makes on the entity's behalf) | **Constructive receipt and disbursement:** when a member (or a lender) pays a company expense or purchase directly, the statement shows a financing inflow (borrowed from the member) and the operating or investing outflow, on that date; paying the member back is a financing outflow. Source: ASC 230 does not address it; practice fills the gap by substance (Deloitte DART Roadmap 7.2 with AICPA TQA 1300.16 and the SEC staff's view; PwC, BDO and EY guides alike), the way a CPA would present it; KPMG accepts noncash only for equipment a lender pays for. Property a member contributes that it already owned stays noncash (ASC 230-10-50-4: no cash moved). Total cash is the same either way; the first company's figures are in its private P3 change log. | | D-42 | A member paying for the company, on the cash flow statement (P3, defect 1; owner, 2026-10-20, revising D-35's first point after the review found ASC 230 silent on a payment a third party makes on the entity's behalf) | **Constructive receipt and disbursement:** when a member (or a lender) pays a company expense or purchase directly, the statement shows a financing inflow (borrowed from the member) and the operating or investing outflow, on that date; paying the member back is a financing outflow. Source: ASC 230 does not address it; practice fills the gap by substance (Deloitte DART Roadmap 7.2 with AICPA TQA 1300.16 and the SEC staff's view; PwC, BDO and EY guides alike), the way a CPA would present it; KPMG accepts noncash only for equipment a lender pays for. Property a member contributes that it already owned stays noncash (ASC 230-10-50-4: no cash moved). Total cash is the same either way; the first company's figures are in its private P3 change log. |
| D-43 | Invoices and bills take a project, line by line (owner, 2026-10-22) | **Each line of an invoice or bill takes one project or none.** A document that spans N projects is N lines, each with its own amount; no percentage split, so every figure is exact. A cost truly shared among projects names none on the document and reaches projects only as allocation entries of their own under the company's monthly allocation convention, when the books have one (otherwise it stays untagged and the agent asks the human). Reasoning: job costing charges a job its traceable (direct) costs whole and reaches shared costs only by allocation on a stated basis. ASC 340-40-25-7 counts as directly related both the costs traceable to a contract (25-7(a), (b), (d), (e)) and allocations of costs that relate directly to it (25-7(c)); 25-8 expenses general and administrative costs unless chargeable to the customer. The books carry a traceable cost whole on the document's line and an allocated cost as entries under the allocation convention on its basis; neither is a percentage on the document. Behind its own journal feature, `document_line_projects`, so books that declared `projects` wait for the owner's next upgrade (rules.md B-37 item 8, B-27). |
### 10.2 Deferred ### 10.2 Deferred