docs(handbook): the invoices-and-bills procedure, whole (P4b S4.5)

Step 1 now covers the issue: preview first (nothing written, the DRAFT PDF,
an abandoned preview uses no number), confirm only after the human has
seen it, the filed issued copy that a later look never replaces,
quantities and prices, the PO, and the snapshot of the customer at issue.
Step 6 is the CPA's list: agings tied to 1200 and 2000, sequential numbers
with voids visible, bills on their real accounts with the vendor's number
and no duplicate, card payments identified for 1099-NEC, deposits as
liabilities, light governance by decision.

A {documents_v2} placeholder (handbook::section_with, the server's
documents_v2 feature test) says "your books have not declared documents_v2
yet" before the owner's upgrade and what is on after it; no other section
changes. Parity, testing (with the For-P12 paragraph) and mcp-interface
notes for S4.5.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 12:55:10 -06:00
1 parent df93d8b951
commit caad35d75b
7 files changed
+224 -15

No files matched your search

@@ -7,12 +7,34 @@ own figures are read from the ledger lines tagged with it, never kept on
the document: a reversed payment, a void or a write-off comes out right the document: a reversed payment, a void or a write-off comes out right
without another copy to correct (B-38). without another copy to correct (B-38).
## 1. Send an invoice {documents_v2}
- The books never send mail, and never say they did. To send an invoice, ## 1. Issue and send an invoice
create and post it (`books.create_invoice`), hand its PDF to the human
(`books.document` with `pdf: true`), and let the owner or a delegate - Preview first: `books.create_invoice` without `confirmed` writes
send it. nothing. It returns the posting, the number and due date the invoice
will take, and its DRAFT PDF; show the human the PDF and the posting.
An abandoned preview uses up no number.
- Confirm with `confirmed: true` only after the human has seen it. The
invoice posts Dr Accounts Receivable / Cr each line's account, takes the
next number (B-40), and the issued PDF, the page the customer gets, is
filed with it. That filed copy never changes: a later look at the
invoice (`books.document` with `pdf: true`, or the viewer's Save invoice
PDF) is a re-created copy as of that day, says so, and shows what has
been paid since.
- Give a line a quantity and a unit price when the work is billed that way
(`quantity` times `unit_price` must equal the line's amount exactly); the
page prints "3 × $33.33" under its description. The customer's purchase
order goes in `customer_ref`; `memo` prints under the lines. The
company's email, website, payment methods (each can show a QR code) and
footer come from the settings, not from the call.
- The page prints the customer as it was when the invoice was issued (its
name, billing address, contact, phone and billing email, and a Ship-to
line when it has a shipping address); changing the customer's record
later never changes an issued invoice.
- The books never send mail, and never say they did. Hand the issued PDF
to the human (`books.document` with `pdf: true`; the viewer's Save
issued copy), and let the owner or a delegate send it.
- Then record the send with `books.mark_sent`: the invoice (`invoice:7`), - Then record the send with `books.mark_sent`: the invoice (`invoice:7`),
the day it went out, who sent it (`by`: the owner, or the agent acting the day it went out, who sent it (`by`: the owner, or the agent acting
for them), how (`via`: `email`, or `mail`, `portal`, `hand`, or another for them), how (`via`: `email`, or `mail`, `portal`, `hand`, or another
@@ -138,11 +160,26 @@ if it was reversed, that it no longer stands).
`deposit_to: "2300"`, dated on or after the bounce, against the `deposit_to: "2300"`, dated on or after the bounce, against the
invoices it pays. The 2300 balance returns to zero. invoices it pays. The 2300 balance returns to zero.
## What the CPA will check ## 6. What the CPA will check
The agings tie to 1200 and 2000 at month and year end (they do by - The agings tie to 1200 and 2000 at month and year end (they do by
construction: the control check says so). Every write-off carries its construction: `books.control_check` says so; run it at month end and
reason and its evidence of collection efforts. Write-offs are not used in tie the aging to the trial balance before the CPA asks).
- Invoice numbers are unique and sequential, voids stay visible with
their number, nothing is deleted.
- Every bill is coded to its real account (the Form 1065 lines, B-32),
never a catch-all, with the vendor's invoice attached and its number
recorded, and no bill is paid twice (the same vendor and number twice is
refused).
- Payments are matched to documents, and card payments are identified
(excluded from 1099-NEC: Treas. Reg. §1.6041-1(a)(1)(iv)).
- Customer deposits are liabilities (2300), never netted inside
receivables; Schedule L lines 2a and 15 come straight from the control
accounts.
- Approvals and three-way matching are deliberately not adopted for a
two-member LLC: light governance, by decision.
- Every write-off carries its reason and its evidence of collection
efforts. Write-offs are not used in
place of a credit memo for a valid invoice, and never to hide a mistake place of a credit memo for a valid invoice, and never to hide a mistake
that a void would show. Whether a debt is worthless, and in which year, is that a void would show. Whether a debt is worthless, and in which year, is
a tax judgment that stays with the CPA: the books flag each write-off made a tax judgment that stays with the CPA: the books flag each write-off made
+72
View File
@@ -132,11 +132,25 @@ pub const ALL: &str = "all";
/// The section `id` (or [`ALL`]), rendered for these settings, the /// The section `id` (or [`ALL`]), rendered for these settings, the
/// books' base currency, and whether they declared the journal feature /// books' base currency, and whether they declared the journal feature
/// `document_line_projects` (None: no such section). /// `document_line_projects` (None: no such section).
#[cfg(test)]
pub fn section( pub fn section(
id: &str, id: &str,
settings: &Settings, settings: &Settings,
currency: &str, currency: &str,
document_line_projects: bool, document_line_projects: bool,
) -> Option<String> {
section_with(id, settings, currency, document_line_projects, true)
}
/// [`section`], also saying whether the books declared the journal
/// feature `documents_v2` (the invoices-and-bills section says what
/// waits for the owner's upgrade by it).
pub fn section_with(
id: &str,
settings: &Settings,
currency: &str,
document_line_projects: bool,
documents_v2: bool,
) -> Option<String> { ) -> 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))
@@ -145,6 +159,7 @@ pub fn section(
"{document_projects}", "{document_projects}",
document_projects_rule(document_line_projects), document_projects_rule(document_line_projects),
) )
.replace("{documents_v2}", documents_v2_rule(documents_v2))
}; };
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();
@@ -178,6 +193,17 @@ pub fn document_projects_rule(declared: bool) -> &'static str {
} }
} }
/// What the books have of the document upgrade (journal feature
/// `documents_v2`, plan P4b S4), as the invoices-and-bills section says
/// it: on, or waiting for the owner's go-ahead.
pub fn documents_v2_rule(declared: bool) -> &'static str {
if declared {
"These books have declared documents_v2: an invoice takes a number, terms and a purchase-order reference, a bill takes the vendor's number, and every bill line names its account."
} else {
"Your books have not declared documents_v2 yet: numbers, terms and line accounts wait for the owner's upgrade (books.start's format_upgrade). Until then an invoice is \"Invoice {id}\", a bill's lines post to 6999, and a document carrying a new field is refused with format_upgrade_pending; the issued PDF is still filed with the invoice."
}
}
/// 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!(
@@ -487,6 +513,7 @@ mod tests {
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}"); assert!(!text.contains("{document_projects}"), "{id}");
assert!(!text.contains("{documents_v2}"), "{id}");
} }
// The default period, in the descriptions' own words (B-30). // The default period, in the descriptions' own words (B-30).
assert!( assert!(
@@ -502,6 +529,51 @@ mod tests {
assert!(section("nope", &s, "USD", true).is_none()); assert!(section("nope", &s, "USD", true).is_none());
} }
/// The invoices-and-bills section says what waits for the owner's
/// upgrade (`documents_v2`) before the go-ahead and what is on after;
/// the procedure around it is the same text, and no other section
/// changes (plan P4b S4.5).
#[test]
fn documents_v2_follows_the_books_feature() {
let s = settings("Co", Decimal::ZERO);
let with = section_with("invoices-and-bills", &s, "USD", true, true).unwrap();
let without = section_with("invoices-and-bills", &s, "USD", true, false).unwrap();
assert!(without.contains(
"Your books have not declared documents_v2 yet: numbers, terms and line accounts wait for the owner's upgrade"
));
assert!(!with.contains("have not declared documents_v2"));
assert!(with.contains("These books have declared documents_v2"));
// Every step of the procedure is there either way, in order.
for text in [&with, &without] {
let at = |needle: &str| {
text.find(needle)
.unwrap_or_else(|| panic!("{needle:?} missing from:\n{text}"))
};
let steps = [
"## 1. Issue and send an invoice",
"## 2. Record a customer payment",
"## 3. Pay a vendor bill",
"## 4. Void, write off, or reverse",
"## 5. A processor's fee",
"## 6. What the CPA will check",
];
let places: Vec<usize> = steps.iter().map(|h| at(h)).collect();
assert!(places.windows(2).all(|w| w[0] < w[1]), "{places:?}");
assert!(text.contains("books.mark_sent") && text.contains("books.control_check"));
assert!(text.contains("An abandoned preview uses up no number"));
}
for (id, _) in SECTIONS
.iter()
.filter(|(id, _)| *id != "invoices-and-bills")
{
assert_eq!(
section_with(id, &s, "USD", true, true),
section_with(id, &s, "USD", true, false),
"{id}"
);
}
}
/// INVARIANT 6: the projects section tells books that declared /// INVARIANT 6: the projects section tells books that declared
/// document_line_projects to tag an invoice's or bill's lines, and the /// 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 /// rest that it waits for their format upgrade; no other section
+11 -1
View File
@@ -601,6 +601,15 @@ impl BooksServer {
}) })
} }
/// Whether the books declared the journal feature `documents_v2` (the
/// handbook's invoices-and-bills section says what waits for it).
fn documents_v2(&self) -> bool {
self.lock().is_ok_and(|s| {
s.journal_format()
.has_feature(ledger_store::format::DOCUMENTS_V2)
})
}
/// 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()
@@ -1519,11 +1528,12 @@ 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( handbook::section_with(
id, id,
&self.settings(), &self.settings(),
self.currency(), self.currency(),
self.document_line_projects(), self.document_line_projects(),
self.documents_v2(),
) )
.map(ResourceBody::Text) .map(ResourceBody::Text)
.ok_or_else(|| format!("unknown handbook section: {id}")) .ok_or_else(|| format!("unknown handbook section: {id}"))
+4 -2
View File
@@ -18,11 +18,13 @@ impl BooksServer {
) -> Result<Json<HandbookOut>, String> { ) -> Result<Json<HandbookOut>, String> {
let settings = self.settings(); let settings = self.settings();
let declared = self.document_line_projects(); let declared = self.document_line_projects();
let documents_v2 = self.documents_v2();
let sections = handbook::SECTIONS let sections = handbook::SECTIONS
.iter() .iter()
.map(|(id, _)| { .map(|(id, _)| {
let text = let text =
handbook::section(id, &settings, self.currency(), declared).unwrap_or_default(); handbook::section_with(id, &settings, self.currency(), declared, documents_v2)
.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(),
@@ -34,7 +36,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(), declared).ok_or_else(|| { let text = handbook::section_with(id, &settings, self.currency(), declared, documents_v2).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",
+8 -1
View File
@@ -2254,7 +2254,14 @@ snapshots; see plan 8.2 S4.
back as a handed file (`pdf`: run its download): re-created from the back as a handed file (`pdf`: run its download): re-created from the
books as of the day, each standing payment on its own line with its books as of the day, each standing payment on its own line with its
date, method and reference, stamped PAID (with the day), PARTIALLY date, method and reference, stamped PAID (with the day), PARTIALLY
PAID, PAST DUE or VOID (P4b S3); refused for a bill. PAID, PAST DUE or VOID (P4b S3); refused for a bill. The page prints
the invoice's own number, terms (an early discount reads in them), the customer's PO,
each line's quantity beside its price, the customer as at issue
(contact, billing address, phone, email, a Ship-to block), the
company's email, website, ways to pay (a QR code for a method with a
link) and footer; it says "Re-created on" the `as_of` day, never the
day it was printed, and the filed issued copy is never replaced by it
(P4b S4.5).
- `books.control_check { as_of? }` → `{ as_of, agrees, accounts: [{ role, - `books.control_check { as_of? }` → `{ as_of, agrees, accounts: [{ role,
code, name, balance, documents_total, unattached: [{ entry_id, line, code, name, balance, documents_total, unattached: [{ entry_id, line,
date, amount }], difference, attention: [{ kind, id, status, total, date, amount }], difference, attention: [{ kind, id, status, total,
+19 -2
View File
@@ -34,8 +34,8 @@ No other statuses.
| Bank reconciliation (cleared flags + report) | Reconcile | done | — | | Bank reconciliation (cleared flags + report) | Reconcile | done | — |
| QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 | | QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 |
| QuickBooks IIF + XLSX export | Export / reports | done | R-FUTURE-4 | | QuickBooks IIF + XLSX export | Export / reports | done | R-FUTURE-4 |
| A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5; since P4b S1 (D-45) a document's paid, outstanding and status are read from its tagged ledger lines, hand lines on the receivables account are refused, `books.void_document` and `books.control_check` tie the subledger to the control account (rules.md B-38); S2 `books.documents`/`books.document` and the viewer pages; S3 the invoice PDF; S4.1-S4.3 terms, numbers, line accounts, the issued PDF filed at issue (behind `documents_v2`, the owner's next upgrade); numbers, terms, line accounts, the party snapshots and the issued PDF filed with the invoice, through `books.create_invoice`'s preview and confirm (P4b S4.4) | | A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5; since P4b S1 (D-45) a document's paid, outstanding and status are read from its tagged ledger lines, hand lines on the receivables account are refused, `books.void_document` and `books.control_check` tie the subledger to the control account (rules.md B-38); S2 `books.documents`/`books.document` and the viewer pages; S3 the invoice PDF; S4.1-S4.3 terms, numbers, line accounts, the issued PDF filed at issue (behind `documents_v2`, the owner's next upgrade); numbers, terms, line accounts, the party snapshots and the issued PDF filed with the invoice, through `books.create_invoice`'s preview and confirm (P4b S4.4); S4.5 the issued PDF carries the number, terms, PO, quantity and price, the bill-to's contact block and ship-to, the ways to pay with QR codes, the memo and the footer, and the viewer shows the number, terms, bill-to and PO and saves the issued copy |
| A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5; the same S1 derivation and control guard on the payables account; S4.1-S4.3 the vendor's invoice number, terms, every line on its own account (behind `documents_v2`); the vendor's own invoice number (a duplicate refused), terms, line accounts and the vendor's invoice filed as the bill's evidence, through `books.create_bill`'s preview and confirm (P4b S4.4) | | A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5; the same S1 derivation and control guard on the payables account; S4.1-S4.3 the vendor's invoice number, terms, every line on its own account (behind `documents_v2`); the vendor's own invoice number (a duplicate refused), terms, line accounts and the vendor's invoice filed as the bill's evidence, through `books.create_bill`'s preview and confirm (P4b S4.4); S4.5 the viewer shows the vendor's number, the day received, terms and memo, and the handbook's procedure (numbers, terms, line accounts) |
| 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 | invoice and bill lines take a class too (projects, rules.md B-37 item 8; QBO/Xero class or tracking per line) | | 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 | — |
@@ -127,6 +127,23 @@ the owner's full contact block (D-55: phone, contact person, billing and
shipping addresses, billing email), which an invoice's bill-to copies at shipping addresses, billing email), which an invoice's bill-to copies at
issue. issue.
### P4b S4.5: the issued PDF, the viewer and the handbook (2026-10-29)
The A/R and A/P rows above gain their note rather than a new row: no
parity item changed status, so the floor stands. The invoice a customer
pays from now prints its number, both dates, the terms (an early
discount reads in them), the customer's purchase order, each line's quantity beside its
price, the customer as it was at issue (contact, billing address, phone,
email and a Ship-to block), the company's email and website, the ways to
pay (each with a QR code when it has a link), the memo and the footer. The
current copy says it was re-created as of a day (never the wall clock).
The viewer's invoice page shows the number, terms, bill-to and PO and
saves the issued copy beside the re-created one; the Money in list names an
invoice by its number. The handbook's invoices-and-bills procedure is
complete (issue, collect, pay on terms, void or write off, the fee, the
CPA's list) and says what waits for the owner's upgrade. rules.md gains
B-39 (terms) and B-40 (numbers and the issued copy). No golden moved.
### P9a self-host deployment (2026-10-26) ### P9a self-host deployment (2026-10-26)
P9a is a deployment, not a semantic feature: the same engine, store, MCP P9a is a deployment, not a semantic feature: the same engine, store, MCP
+64
View File
@@ -3052,6 +3052,70 @@ and `BooksServer::new`'s copy (a dropped field must fail
extension and `parse_args`' passthrough of `--isolation` and its optional extension and `parse_args`' passthrough of `--isolation` and its optional
`--http` value. `--http` value.
## P4b S4.5: the issued PDF, the viewer and the handbook (2026-10-29)
The page a customer pays from carries what the books know of the invoice
and nothing internal (no account code, project or id). `InvoiceDoc` gains
the quantity and unit price per line, the customer's reference, memo,
footer, the ways to pay (a QR code through `labels::qr_svg` when a method
has a link and asks for one), the seller's email and website, a Ship-to
block, and `InvoiceVersion` (`Issued`, or `Current { as_of }` whose
"Re-created on" note prints `as_of`, never the wall clock).
`ledger_package::page_of` builds every copy from the invoice's own
snapshots, so the issued copy, the DRAFT and the current copy cannot
disagree. No journal shape changed, so 8.0.6 does not apply and no golden
moved.
- **`ledger-import` unit tests (`src/invoice_pdf.rs`):**
`an_invoice_renders_deterministically` (two renders, equal bytes; the
plan's "golden sha updated once" has nothing to update: this test never
held a hash, it compares two renders), `labels_are_data` (an item
description `#import "x"`, a memo, footer, reference and a payment
method's label and lines print literally and break nothing),
`a_quantity_prints_beside_its_price` ("3 × $11.50"; a price of $0.125
prints exactly), `only_the_current_copy_says_it_was_re_created`,
`two_hundred_lines_paginate` (more than one page, a valid PDF).
- **`ledger-import` `tests/spec_import.rs`:** `invoice_pdf_prints_what_it_should`
reads the PDF back with the hayro text reader: number, both dates, the
total, terms, the PO, the seller's email and website, the quantity
detail, each payment method's label and lines, the Ship-to, memo and
footer, a CJK name and a right-to-left name; no stamp and no
"Re-created" on the issued copy; no project, account code or id.
- **`ledger-package` unit tests (`src/invoice.rs`):**
`an_invoice_prints_its_figures_as_of_a_day` now asserts the version
(`Current { as_of }`) instead of the wall-clock note, and
`the_sample_invoice_in_each_state` still renders every state.
- **`ledger-gui` `tests/invoices.rs`:**
`a_numbered_invoice_shows_its_number_terms_bill_to_and_po` (screenshot
`invoice-numbered`; the list names the invoice by number; the page offers
Save issued copy beside Save invoice PDF; a legacy invoice keeps
"Invoice {id}", shows no Details card and has no issued copy to save).
`the_list_shows_the_ledgers_figures` and `a_documents_page_shows_its_timeline`
pass unchanged.
- **`ledger-gui` `tests/view_model.rs`:**
`the_viewer_holds_a_numbered_invoice_as_the_store_does` (the viewer's
replay equals the live store's invoice: number, terms, bill-to, PO, memo,
quantity).
- **`ledger-mcp/src/handbook.rs`:** `documents_v2_follows_the_books_feature`
(the placeholder says what waits before the upgrade and what is on after;
the six steps are in order either way; no other section changes);
`handbook_sections_render_the_rule_in_force` also checks no placeholder
is left.
**For P12 (mutation targets):** `invoice_pdf::dollars` (the negative arm)
and `unit_price` (`scale() <= 2`, the exact arm), `quantity_detail`'s
both-or-neither match, `note_text`'s three arms (re-created, both, note
only); `ledger_package::invoice::payment_methods` (the `qr` flag read,
`link` appended as a last line, the QR only when a link exists);
`page_of`'s snapshot-else-party choice for the bill-to, the contact, address,
phone, email order of the bill-to lines, the seller email's snapshot-else-
company fallback, and the blank-footer filter; `invoice_document`'s stamp
arms and `InvoiceVersion::Current { as_of }`; the viewer's `details`
(each field's presence test, the bill-to join order), `invoice_name`'s
number-else-id and `issued_copy`'s PDF-and-description match; the
handbook's `documents_v2_rule` both arms and the server's `documents_v2`
feature test.
## P4b S5: sent tracking (2026-10-27) ## P4b S5: sent tracking (2026-10-27)
The books record that an invoice went out; they never send it (A-4). A The books record that an invoice went out; they never send it (A-4). A