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

+143 -13
View File
@@ -358,6 +358,8 @@ struct Event {
#[serde(default)]
bill: Option<BillSpec>,
#[serde(default)]
invoice: Option<InvoiceSpec>,
#[serde(default)]
run_depreciation: Option<RunSpec>,
#[serde(default)]
dispose_asset: Option<DisposeSpec>,
@@ -411,15 +413,48 @@ struct RunSchedulesSpec {
through: String,
}
/// A bill from `vendor` for `amount`, dated and due on the event's date:
/// Dr General Expenses (6999), Cr Accounts Payable (2000), each created
/// with its fixed name when first needed, as the engine creates a control
/// role's account (rules.md B-32).
/// A bill from `vendor`, dated and due on the event's date, for `amount`
/// (one line, the event's description) or its `lines`, exactly one of
/// them: Dr General Expenses (6999), Cr Accounts Payable (2000), each
/// 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)]
#[serde(deny_unknown_fields)]
struct BillSpec {
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,
#[serde(default)]
description: Option<String>,
#[serde(default)]
project: Option<String>,
}
/// A change to the books that posts no entry, made right after event
@@ -731,6 +766,8 @@ struct Books<'a> {
payees: BTreeMap<String, VendorId>,
/// The vendors bills came from, by name (a payee's record if one).
vendors: BTreeMap<String, VendorId>,
/// The customers invoices went to, by name.
customers: BTreeMap<String, u64>,
/// The payment methods, by label.
instruments: BTreeMap<String, InstrumentId>,
/// The fixed assets of `setup.assets`, by id.
@@ -1181,6 +1218,7 @@ impl<'a> Books<'a> {
party_members,
parties,
vendors: payees.clone(),
customers: BTreeMap::new(),
payees,
instruments,
assets,
@@ -1241,6 +1279,7 @@ impl<'a> Books<'a> {
("reverses", e.reverses.is_some()),
("close", e.close.is_some()),
("bill", e.bill.is_some()),
("invoice", e.invoice.is_some()),
("run_depreciation", e.run_depreciation.is_some()),
("dispose_asset", e.dispose_asset.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<_>>()[..] {
[(kind, _)] => *kind,
_ => 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
// disposal is dated; the other commands date their own entries,
// 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!(
e.date.is_some(),
own || kind == "dispose_asset",
@@ -1278,7 +1317,7 @@ impl<'a> Books<'a> {
);
assert!(
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!(
(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"
);
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"
);
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`]
/// found it): whatever it posts is posted, or the books refuse it.
fn take(
@@ -1459,7 +1535,7 @@ impl<'a> Books<'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) {
Some(&v) => v,
None => {
@@ -1468,14 +1544,25 @@ impl<'a> Books<'a> {
v
}
};
let lines = vec![DocumentLine {
description: description.to_string(),
amount,
}];
self.store
.create_bill(vendor, day(), day(), lines)
.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" => {
let r = e.run_depreciation.as_ref().expect("checked: a run");
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)
});
}
/// 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 {
description: "Design work".into(),
amount: dec("1500"),
class_id: None,
}],
)
.unwrap();
+1
View File
@@ -978,6 +978,7 @@ fn doc(amount: u64) -> Vec<ledger_core::DocumentLine> {
vec![ledger_core::DocumentLine {
description: "work".into(),
amount: Decimal::from(amount),
class_id: None,
}]
}
+3 -1
View File
@@ -36,6 +36,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
DocumentLine {
description: desc.to_string(),
amount: dec(amount),
class_id: None,
}
}
@@ -380,7 +381,7 @@ proptest! {
) {
let lines: Vec<DocumentLine> = amts
.iter()
.map(|c| DocumentLine { description: "l".into(), amount: cents(*c) })
.map(|c| DocumentLine { description: "l".into(), amount: cents(*c), class_id: None })
.collect();
let ok = !amts.is_empty() && amts.iter().all(|c| *c > 0);
prop_assert_eq!(validate_lines(&lines).is_ok(), ok);
@@ -1083,6 +1084,7 @@ fn doc_lines(doc: &Doc) -> Vec<DocumentLine> {
.map(|c| DocumentLine {
description: "item".into(),
amount: cents(*c),
class_id: None,
})
.collect()
}
+5
View File
@@ -141,6 +141,7 @@ fn dates_outside_the_calendar_are_refused_everywhere() {
let line = vec![DocumentLine {
description: "work".into(),
amount: Decimal::ONE,
class_id: None,
}];
assert_eq!(
b.ledger
@@ -219,6 +220,7 @@ fn replayed_dates_outside_the_calendar_are_accepted() {
let line = vec![DocumentLine {
description: "work".into(),
amount: Decimal::ONE,
class_id: None,
}];
b.ledger
.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 {
description: "a".into(),
amount: a,
class_id: None,
},
DocumentLine {
description: "b".into(),
amount: Decimal::ONE,
class_id: None,
},
]
};
@@ -617,6 +621,7 @@ fn replayed_payments_are_as_journaled() {
vec![DocumentLine {
description: "work".into(),
amount: Decimal::new(10000, 2),
class_id: None,
}],
)
.unwrap();
@@ -2085,6 +2085,7 @@ fn a_bill_payment_carries_its_bills_split() {
vec![ledger_core::DocumentLine {
description: "Equipment and supplies".into(),
amount: x("1700.00"),
class_id: None,
}],
entry,
)
@@ -2509,6 +2510,7 @@ fn a_reversed_bill_payment_leaves_the_purchase_unpaid() {
vec![ledger_core::DocumentLine {
description: "Equipment".into(),
amount: x("1000.00"),
class_id: None,
}],
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 {
description: "consulting".into(),
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 {
description: "work".into(),
amount: dec("100.00"),
class_id: None,
}],
)
.unwrap();
@@ -465,6 +465,7 @@ fn audit() -> Audit {
vec![DocumentLine {
description: "filing".into(),
amount: d("80.00"),
class_id: None,
}],
)
.unwrap();
@@ -482,6 +483,7 @@ fn audit() -> Audit {
vec![DocumentLine {
description: "consulting".into(),
amount: d("120.00"),
class_id: None,
}],
)
.unwrap();
@@ -292,6 +292,7 @@ fn documents_and_aging_are_exact_or_refused() {
let line = |a: Decimal| DocumentLine {
description: "x".into(),
amount: a,
class_id: None,
};
let fine = vec![
line(Decimal::new(1, 11)),
+1
View File
@@ -41,6 +41,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
DocumentLine {
description: desc.to_string(),
amount: dec(amount),
class_id: None,
}
}