From caad35d75bf921609ceb4ba8d0639421febf6b63 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 12:55:07 -0600 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- .../ledger-mcp/handbook/invoices-and-bills.md | 55 +++++++++++--- crates/ledger-mcp/src/handbook.rs | 72 +++++++++++++++++++ crates/ledger-mcp/src/server.rs | 12 +++- crates/ledger-mcp/src/tools/settings.rs | 6 +- docs/mcp-interface.md | 9 ++- docs/parity.md | 21 +++++- docs/testing.md | 64 +++++++++++++++++ 7 files changed, 224 insertions(+), 15 deletions(-) diff --git a/crates/ledger-mcp/handbook/invoices-and-bills.md b/crates/ledger-mcp/handbook/invoices-and-bills.md index 1c452c7..371a6fe 100644 --- a/crates/ledger-mcp/handbook/invoices-and-bills.md +++ b/crates/ledger-mcp/handbook/invoices-and-bills.md @@ -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 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, - 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 - send it. +## 1. Issue and send an invoice + +- Preview first: `books.create_invoice` without `confirmed` writes + 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`), 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 @@ -138,11 +160,26 @@ if it was reversed, that it no longer stands). `deposit_to: "2300"`, dated on or after the bounce, against the 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 -construction: the control check says so). Every write-off carries its -reason and its evidence of collection efforts. Write-offs are not used in +- The agings tie to 1200 and 2000 at month and year end (they do by + construction: `books.control_check` says so; run it at month end and + 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 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 diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index cb1849f..7742024 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -132,11 +132,25 @@ pub const ALL: &str = "all"; /// The section `id` (or [`ALL`]), rendered for these settings, the /// books' base currency, and whether they declared the journal feature /// `document_line_projects` (None: no such section). +#[cfg(test)] pub fn section( id: &str, settings: &Settings, currency: &str, document_line_projects: bool, +) -> Option { + 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 { let render = |md: &str| { md.replace("{evidence_rule}", &evidence_rule(settings, currency)) @@ -145,6 +159,7 @@ pub fn section( "{document_projects}", document_projects_rule(document_line_projects), ) + .replace("{documents_v2}", documents_v2_rule(documents_v2)) }; if id == ALL { let parts: Vec = 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). pub fn evidence_rule(settings: &Settings, currency: &str) -> String { format!( @@ -487,6 +513,7 @@ mod tests { assert!(!text.contains("{evidence_rule}"), "{id}"); assert!(!text.contains("{period_rule}"), "{id}"); assert!(!text.contains("{document_projects}"), "{id}"); + assert!(!text.contains("{documents_v2}"), "{id}"); } // The default period, in the descriptions' own words (B-30). assert!( @@ -502,6 +529,51 @@ mod tests { 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 = 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 /// 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 diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index d3e0155..6613b11 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -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". fn currency(&self) -> &str { self.store.currency.code() @@ -1519,11 +1528,12 @@ impl BooksServer { } _ if path.starts_with(&format!("{}/", handbook::URI)) => { let id = &path[handbook::URI.len() + 1..]; - handbook::section( + handbook::section_with( id, &self.settings(), self.currency(), self.document_line_projects(), + self.documents_v2(), ) .map(ResourceBody::Text) .ok_or_else(|| format!("unknown handbook section: {id}")) diff --git a/crates/ledger-mcp/src/tools/settings.rs b/crates/ledger-mcp/src/tools/settings.rs index a7241b1..a01b60d 100644 --- a/crates/ledger-mcp/src/tools/settings.rs +++ b/crates/ledger-mcp/src/tools/settings.rs @@ -18,11 +18,13 @@ impl BooksServer { ) -> Result, String> { let settings = self.settings(); let declared = self.document_line_projects(); + let documents_v2 = self.documents_v2(); let sections = handbook::SECTIONS .iter() .map(|(id, _)| { 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 { id: id.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) { None => None, 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(); format!( "no handbook section {id:?}: the sections are {}, or \"{}\" for all of them", diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 4bae3d7..528a6f6 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2254,7 +2254,14 @@ snapshots; see plan 8.2 S4. 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 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, code, name, balance, documents_total, unattached: [{ entry_id, line, date, amount }], difference, attention: [{ kind, id, status, total, diff --git a/docs/parity.md b/docs/parity.md index d8c257f..a344771 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -34,8 +34,8 @@ No other statuses. | Bank reconciliation (cleared flags + report) | Reconcile | done | — | | QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | 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/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/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); 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 | — | | 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 | — | @@ -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 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 is a deployment, not a semantic feature: the same engine, store, MCP diff --git a/docs/testing.md b/docs/testing.md index a37c665..108bd8d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3052,6 +3052,70 @@ and `BooksServer::new`'s copy (a dropped field must fail extension and `parse_args`' passthrough of `--isolation` and its optional `--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) The books record that an invoice went out; they never send it (A-4). A