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:
51 files changed
+3201
-166
No files matched your search
@@ -89,6 +89,13 @@ pub struct DocumentLine {
|
||||
pub description: String,
|
||||
/// Line amount (positive).
|
||||
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.
|
||||
@@ -123,6 +130,44 @@ pub fn document_total(lines: &[DocumentLine], what: &str) -> Result<Decimal, Led
|
||||
.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.
|
||||
fn document_balance(total: Decimal, paid: Decimal, what: &str) -> Result<Decimal, LedgerError> {
|
||||
total
|
||||
@@ -350,6 +395,7 @@ mod tests {
|
||||
vec![DocumentLine {
|
||||
description: "Widgets".to_string(),
|
||||
amount: "1000.00".parse().unwrap(),
|
||||
class_id: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -399,6 +445,7 @@ mod tests {
|
||||
vec![DocumentLine {
|
||||
description: "Paper".to_string(),
|
||||
amount: "250.00".parse().unwrap(),
|
||||
class_id: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -440,6 +487,7 @@ mod tests {
|
||||
vec![DocumentLine {
|
||||
description: "X".to_string(),
|
||||
amount: "100.00".parse().unwrap(),
|
||||
class_id: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -517,6 +565,7 @@ mod tests {
|
||||
vec![DocumentLine {
|
||||
description: "A".to_string(),
|
||||
amount: "100.00".parse().unwrap(),
|
||||
class_id: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -528,6 +577,7 @@ mod tests {
|
||||
vec![DocumentLine {
|
||||
description: "B".to_string(),
|
||||
amount: "200.00".parse().unwrap(),
|
||||
class_id: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -557,6 +607,7 @@ mod tests {
|
||||
vec![DocumentLine {
|
||||
description: "X".to_string(),
|
||||
amount: "100.00".parse().unwrap(),
|
||||
class_id: None,
|
||||
}],
|
||||
);
|
||||
assert!(matches!(err, Err(LedgerError::ClosedPeriod(_, _))));
|
||||
|
||||
@@ -4900,6 +4900,30 @@ impl Ledger {
|
||||
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,
|
||||
/// Cr `4000` Revenue. The invoice date must not fall in a closed period.
|
||||
///
|
||||
@@ -4910,13 +4934,17 @@ impl Ledger {
|
||||
/// control accounts: Ledger::check_controls.
|
||||
/// INVARIANTS:
|
||||
/// 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.
|
||||
/// 3. A refusal MUST leave the books unchanged.
|
||||
/// SCENARIOS:
|
||||
/// 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
|
||||
/// period: SHOULD refuse.
|
||||
/// date is before the invoice date, a line names a project the
|
||||
/// books don't have, or the date is in a closed period: SHOULD
|
||||
/// refuse.
|
||||
/// ```
|
||||
pub fn create_invoice(
|
||||
&mut self,
|
||||
@@ -4945,13 +4973,18 @@ impl Ledger {
|
||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||||
}
|
||||
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
||||
let parts = arap::document_parts(&lines, "the invoice")?;
|
||||
self.check_controls(&[RECEIVABLES, REVENUE])?;
|
||||
let ar = self.ensure_control(&RECEIVABLES);
|
||||
let revenue = self.ensure_control(&REVENUE);
|
||||
let entry = self.create_entry(date, &format!("Invoice {}", self.next_invoice_id));
|
||||
let money = Money::new(total, self.base_currency);
|
||||
self.add_line(entry, ar, Side::Debit, money)?;
|
||||
self.add_line(entry, revenue, Side::Credit, money)?;
|
||||
self.add_line(
|
||||
entry,
|
||||
ar,
|
||||
Side::Debit,
|
||||
Money::new(total, self.base_currency),
|
||||
)?;
|
||||
self.add_document_parts(entry, revenue, Side::Credit, &parts)?;
|
||||
self.post_entry(entry)?;
|
||||
let id = self.next_invoice_id;
|
||||
self.next_invoice_id += 1;
|
||||
@@ -4983,12 +5016,15 @@ impl Ledger {
|
||||
/// INVARIANTS:
|
||||
/// 1. MUST post one balanced entry: Dr 6999 general expenses, Cr 2000
|
||||
/// 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.
|
||||
/// SCENARIOS:
|
||||
/// 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
|
||||
/// refuse.
|
||||
/// is before the bill date, a line names a project the books don't
|
||||
/// have, or the date is in a closed period: SHOULD refuse.
|
||||
/// ```
|
||||
pub fn create_bill(
|
||||
&mut self,
|
||||
@@ -5017,13 +5053,18 @@ impl Ledger {
|
||||
return Err(LedgerError::ClosedPeriod(pid, date.to_string()));
|
||||
}
|
||||
let total: Decimal = lines.iter().map(|l| l.amount).sum();
|
||||
let parts = arap::document_parts(&lines, "the bill")?;
|
||||
self.check_controls(&[GENERAL_EXPENSES, PAYABLES])?;
|
||||
let expense = self.ensure_control(&GENERAL_EXPENSES);
|
||||
let ap = self.ensure_control(&PAYABLES);
|
||||
let entry = self.create_entry(date, &format!("Bill {}", self.next_bill_id));
|
||||
let money = Money::new(total, self.base_currency);
|
||||
self.add_line(entry, expense, Side::Debit, money)?;
|
||||
self.add_line(entry, ap, Side::Credit, money)?;
|
||||
self.add_document_parts(entry, expense, Side::Debit, &parts)?;
|
||||
self.add_line(
|
||||
entry,
|
||||
ap,
|
||||
Side::Credit,
|
||||
Money::new(total, self.base_currency),
|
||||
)?;
|
||||
self.post_entry(entry)?;
|
||||
let id = self.next_bill_id;
|
||||
self.next_bill_id += 1;
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -154,6 +154,7 @@ fn books() -> Ledger {
|
||||
vec![DocumentLine {
|
||||
description: "Design work".into(),
|
||||
amount: dec("1500"),
|
||||
class_id: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
@@ -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,
|
||||
}]
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
@@ -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(_))
|
||||
));
|
||||
}
|
||||
@@ -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)),
|
||||
|
||||
@@ -41,6 +41,7 @@ fn line(desc: &str, amount: &str) -> DocumentLine {
|
||||
DocumentLine {
|
||||
description: desc.to_string(),
|
||||
amount: dec(amount),
|
||||
class_id: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user