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
+28
-14
@@ -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"
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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(_, _))));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(_))
|
||||||
|
));
|
||||||
|
}
|
||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
}]
|
}]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
|||||||
@@ -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,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.
|
||||||
@@ -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"));
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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`.
|
||||||
|
|||||||
@@ -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}");
|
||||||
|
}
|
||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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)]),
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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).
|
||||||
@@ -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
@@ -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
|
||||||
|
|||||||
Generated
+131
-4
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user