From 400d0cdcab884eca3596594a7d5c7e6f0c30d4a7 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 12:55:07 -0600 Subject: [PATCH 1/4] feat(pdf): the issued invoice carries what the customer pays from (P4b S4.5) The page a customer pays from must show what the books issued, and only that. InvoiceDoc gains each line's quantity and unit price ("3 x $33.33" beside the line's own amount, never recomputed), the customer's PO, memo, footer, the ways to pay (a QR code through labels::qr_svg for a method with a link that asks for one), the seller's email and website, and a Ship-to block. InvoiceVersion separates the issued copy (no stamp, no settled rows, no "re-created" note) from the current copy, whose "Re-created on" note prints the as_of it was given, never the wall clock, so the bytes stay deterministic (B-40 item 3). Every amount reaches the page through report::amount_parts; every string is data. ledger_package builds all three copies (issued, DRAFT, current) from one function over the invoice's own snapshots (A-13, D-55: number, terms, bill-to contact block, shipping address), falling back to the party record only for a document made before snapshots, so the three cannot disagree. invoice_document drops its now-unused `today` argument. rules.md gains B-39 (terms) and B-40 (numbers and the issued copy) from the 8.3.13 spec blocks, CONVENTION until their sources are vendored. No golden moved: the render test never held a hash, it compares two renders for equal bytes, and still does. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-gui/src/pages/invoices.rs | 1 - crates/ledger-import/src/invoice_pdf.rs | 211 +++++++++++- crates/ledger-import/src/lib.rs | 5 +- crates/ledger-import/templates/invoice.typ | 62 +++- crates/ledger-import/tests/spec_import.rs | 117 +++++++ crates/ledger-mcp/src/tools/documents.rs | 9 +- crates/ledger-package/src/invoice.rs | 364 ++++++++++++--------- docs/spec/rules.md | 106 ++++++ 8 files changed, 706 insertions(+), 169 deletions(-) diff --git a/crates/ledger-gui/src/pages/invoices.rs b/crates/ledger-gui/src/pages/invoices.rs index 64dfe2f..b82ec5d 100644 --- a/crates/ledger-gui/src/pages/invoices.rs +++ b/crates/ledger-gui/src/pages/invoices.rs @@ -658,7 +658,6 @@ fn pdf_actions(ui: &mut Ui, b: &Books, id: ledger_core::InvoiceId) { &b.settings.company, id, b.today, - b.today, ) .and_then(|doc| { ledger_import::invoice_pdf::invoice_pdf(&doc).map_err(|e| e.to_string()) diff --git a/crates/ledger-import/src/invoice_pdf.rs b/crates/ledger-import/src/invoice_pdf.rs index 4158217..57cb33b 100644 --- a/crates/ledger-import/src/invoice_pdf.rs +++ b/crates/ledger-import/src/invoice_pdf.rs @@ -17,6 +17,14 @@ //! 3. Money is formatted here ([`format_amount`], with "$"); the template //! does no arithmetic, so the printed total is the total given. //! 4. Refused: no lines. +//! 5. The issued copy (`InvoiceVersion::Issued`) is the page the +//! customer got: no "re-created" note and no settled rows. The +//! current copy (`InvoiceVersion::Current { as_of }`) says on which +//! day it was re-created, and that day is the `as_of` it was given, +//! never the wall clock, so the bytes stay deterministic. +//! 6. A line given as a quantity and a unit price prints "3 × $33.33" +//! under its description; the amount beside it is the line's own. +//! Nothing internal prints: no account code, project or id. //! SCENARIOS: //! - An open invoice: total, nothing settled, balance due = total. //! - Part paid: a "Payments received" line, the balance due the rest. @@ -26,7 +34,8 @@ //! ``` use crate::pdf::PdfError; -use crate::report::format_amount; +use crate::report::amount_parts; +use chrono::NaiveDate; use rust_decimal::Decimal; use std::sync::OnceLock; use typst::foundations::{Dict, IntoValue}; @@ -49,6 +58,38 @@ pub struct InvoiceItem { pub description: String, /// Its amount. pub amount: Decimal, + /// How many units, when the line was given as a quantity and a unit + /// price (both or neither). + pub quantity: Option, + /// The price of one unit, with [`Self::quantity`]. + pub unit_price: Option, +} + +/// One way to pay, as the page prints it. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PaymentMethodDoc { + /// Its name ("Bank transfer", "Card"). + pub label: String, + /// The lines under it (bank details, a link). + pub lines: Vec, + /// A QR code for it, as an SVG document (`labels::qr_svg`), when it + /// has one. + pub qr_svg: Option, +} + +/// Which copy of the invoice this is. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum InvoiceVersion { + /// The copy issued to the customer (or its draft): no "re-created" + /// note, nothing settled on it. + #[default] + Issued, + /// The invoice re-created from the books as of a day; the page says + /// so, with that day. + Current { + /// The day the figures are as of, printed in the note. + as_of: NaiveDate, + }, } /// A stamp across the invoice, as a rubber stamp would mark it. @@ -80,7 +121,7 @@ impl InvoiceStamp { } /// Everything an invoice prints. -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct InvoiceDoc { /// Its number, as printed ("Invoice 7"; "INV-0007" once numbered). pub number: String, @@ -92,8 +133,25 @@ pub struct InvoiceDoc { pub terms: Option, /// The company. pub seller: InvoiceParty, + /// The seller's email, when it has one. + pub seller_email: Option, + /// The seller's website, when it has one. + pub seller_website: Option, /// The customer. pub bill_to: InvoiceParty, + /// Where goods go, when the customer said so: printed as a Ship-to + /// block. + pub ship_to: Vec, + /// The customer's own reference (their purchase order), when given. + pub customer_ref: Option, + /// A memo printed under the lines, when given. + pub memo: Option, + /// The ways to pay. + pub payment_methods: Vec, + /// Text at the foot of the page (`invoicing.footer`). + pub footer: Option, + /// Which copy this is. + pub version: InvoiceVersion, /// Its lines. pub items: Vec, /// The sum of its lines. @@ -109,7 +167,8 @@ pub struct InvoiceDoc { /// A line under the stamp's word: for PAID, the day it was paid in /// full. pub stamp_line: Option, - /// A note under it (for a re-created copy, saying so). + /// A note under it, besides the re-created note a current copy always + /// carries. pub note: Option, } @@ -125,12 +184,50 @@ fn engine() -> &'static TypstEngine { }) } -/// An amount as printed: "$1,234.56". +/// An amount as printed: "$1,234.56". Every amount on the page comes +/// through [`amount_parts`]. fn dollars(d: Decimal) -> String { - let s = format_amount(d); - match s.strip_prefix('(').and_then(|s| s.strip_suffix(')')) { - Some(inner) => format!("-${inner}"), - None => format!("${s}"), + let p = amount_parts(d); + if p.negative { + format!("-${}", p.digits) + } else { + format!("${}", p.digits) + } +} + +/// A unit price as printed: to the cent when it is one, else exactly (a +/// price of $0.125 must not print as $0.13 beside a total it multiplies +/// to). +fn unit_price(d: Decimal) -> String { + if d.normalize().scale() <= 2 { + dollars(d) + } else { + format!("${}", d.normalize()) + } +} + +/// "3 × $33.33" for a line given as a quantity and a price. +fn quantity_detail(i: &InvoiceItem) -> Option { + match (i.quantity, i.unit_price) { + (Some(q), Some(p)) => Some(format!("{} × {}", q.normalize(), unit_price(p))), + _ => None, + } +} + +/// The note under the totals: the re-created line of a current copy, then +/// whatever note the document carries. +fn note_text(doc: &InvoiceDoc) -> Option { + let recreated = match doc.version { + InvoiceVersion::Current { as_of } => Some(format!( + "Re-created on {} from the books; not a copy of the one sent.", + as_of.format("%b %-d, %Y") + )), + InvoiceVersion::Issued => None, + }; + match (recreated, &doc.note) { + (Some(r), Some(n)) => Some(format!("{r} {n}")), + (Some(r), None) => Some(r), + (None, n) => n.clone(), } } @@ -149,11 +246,23 @@ pub fn template_data(doc: &InvoiceDoc) -> Result { "due_date": doc.due_date, "terms": doc.terms, "seller": party(&doc.seller), + "seller_email": doc.seller_email, + "seller_website": doc.seller_website, "bill_to": party(&doc.bill_to), + "ship_to": doc.ship_to, + "customer_ref": doc.customer_ref, + "memo": doc.memo, "lines": doc.items.iter().map(|i| serde_json::json!({ "description": i.description, + "detail": quantity_detail(i), "amount": dollars(i.amount), })).collect::>(), + "payment_methods": doc.payment_methods.iter().map(|m| serde_json::json!({ + "label": m.label, + "lines": m.lines, + "qr": m.qr_svg, + })).collect::>(), + "footer": doc.footer, "total": dollars(doc.total), "settled": doc.settled.iter().map(|(label, a)| serde_json::json!({ "label": label, @@ -164,7 +273,7 @@ pub fn template_data(doc: &InvoiceDoc) -> Result { "stamp": doc.stamp.map(InvoiceStamp::text), "stamp_draft": doc.stamp == Some(InvoiceStamp::Draft), "stamp_line": doc.stamp_line, - "note": doc.note, + "note": note_text(doc), })) } @@ -207,10 +316,14 @@ mod tests { InvoiceItem { description: "Consulting, January".into(), amount: Decimal::new(120_000, 2), + quantity: None, + unit_price: None, }, InvoiceItem { description: "#panic(\"no\") *not bold* $x$".into(), amount: Decimal::new(3_450, 2), + quantity: Some(Decimal::new(3, 0)), + unit_price: Some(Decimal::new(1_150, 2)), }, ], total: Decimal::new(123_450, 2), @@ -219,6 +332,7 @@ mod tests { stamp: None, stamp_line: None, note: None, + ..InvoiceDoc::default() } } @@ -264,4 +378,83 @@ mod tests { d.items.clear(); assert!(invoice_pdf(&d).is_err()); } + + /// INVARIANT 2 for every string the page takes: Typst syntax in an + /// item, a party, a memo, a payment method or a footer prints as + /// written and breaks nothing. + #[test] + fn labels_are_data() { + let mut d = doc(); + d.items[0].description = "#import \"x\": *".into(); + d.memo = Some("#panic(\"memo\") $x$ ]".into()); + d.footer = Some("#panic(\"footer\")".into()); + d.customer_ref = Some("PO #{1}".into()); + d.payment_methods = vec![PaymentMethodDoc { + label: "#panic(\"label\")".into(), + lines: vec!["#import \"y\"".into()], + qr_svg: None, + }]; + let data = template_data(&d).unwrap(); + assert_eq!(data["lines"][0]["description"], "#import \"x\": *"); + assert_eq!(data["memo"], "#panic(\"memo\") $x$ ]"); + assert_eq!(data["payment_methods"][0]["label"], "#panic(\"label\")"); + assert!(invoice_pdf(&d).is_ok()); + } + + /// A line given as a quantity and a price prints both; the amount is + /// the line's own, never recomputed here. + #[test] + fn a_quantity_prints_beside_its_price() { + let data = template_data(&doc()).unwrap(); + assert_eq!(data["lines"][1]["detail"], "3 × $11.50"); + assert_eq!(data["lines"][1]["amount"], "$34.50"); + assert!(data["lines"][0]["detail"].is_null()); + let mut d = doc(); + d.items[1].quantity = Some(Decimal::new(8, 0)); + d.items[1].unit_price = Some(Decimal::new(125, 3)); + d.items[1].amount = Decimal::new(100, 2); + let data = template_data(&d).unwrap(); + assert_eq!(data["lines"][1]["detail"], "8 × $0.125"); + } + + /// The current copy says on which day it was re-created, from the + /// `as_of` it was given; the issued copy says nothing of the kind. + #[test] + fn only_the_current_copy_says_it_was_re_created() { + let mut d = doc(); + assert!(template_data(&d).unwrap()["note"].is_null()); + d.version = InvoiceVersion::Current { + as_of: NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + }; + let note = template_data(&d).unwrap()["note"] + .as_str() + .unwrap() + .to_string(); + assert!(note.starts_with("Re-created on Mar 1, 2026"), "{note}"); + d.note = Some("Voided.".into()); + let note = template_data(&d).unwrap()["note"] + .as_str() + .unwrap() + .to_string(); + assert!(note.ends_with(" Voided."), "{note}"); + } + + #[test] + fn two_hundred_lines_paginate() { + let mut d = doc(); + d.items = (1..=200) + .map(|n| InvoiceItem { + description: format!("Line {n}"), + amount: Decimal::new(100, 2), + quantity: None, + unit_price: None, + }) + .collect(); + d.total = Decimal::new(20_000, 2); + d.settled.clear(); + d.balance = d.total; + let pages = typeset_invoice(&d).unwrap(); + assert!(pages.pages().len() > 1, "{} pages", pages.pages().len()); + assert!(invoice_pdf(&d).unwrap().starts_with(b"%PDF")); + } } diff --git a/crates/ledger-import/src/lib.rs b/crates/ledger-import/src/lib.rs index 9b7df87..c71333d 100644 --- a/crates/ledger-import/src/lib.rs +++ b/crates/ledger-import/src/lib.rs @@ -35,7 +35,10 @@ pub use iif::{ Iif, IifAccount, IifChange, IifClass, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX, NUMBER_MAX, QbAccountType, iif_accounts, iif_classes, journal_to_iif, }; -pub use invoice_pdf::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp, invoice_pdf}; +pub use invoice_pdf::{ + InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp, InvoiceVersion, PaymentMethodDoc, + invoice_pdf, +}; pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf}; pub use pdf::{PdfError, statement_pdf}; pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount}; diff --git a/crates/ledger-import/templates/invoice.typ b/crates/ledger-import/templates/invoice.typ index 8cd371c..4977a13 100644 --- a/crates/ledger-import/templates/invoice.typ +++ b/crates/ledger-import/templates/invoice.typ @@ -7,9 +7,14 @@ // markup (a description holding Typst syntax prints literally). // // Shape: { title: "INVOICE", number, issue_date, due_date, terms: null | str, -// seller: { name, lines: [str] }, bill_to: { name, lines: [str] }, -// lines: [{ description, amount }], total, +// seller: { name, lines: [str] }, seller_email: null | str, +// seller_website: null | str, +// bill_to: { name, lines: [str] }, ship_to: [str], +// customer_ref: null | str, memo: null | str, +// lines: [{ description, detail: null | str, amount }], total, // settled: [{ label, amount }], balance_label, balance, +// payment_methods: [{ label, lines: [str], qr: null | svg }], +// footer: null | str, // stamp: null | str, stamp_draft: bool, stamp_line: null | str, // note: null | str } @@ -41,6 +46,8 @@ #text(15pt, weight: "bold", d.seller.name) \ #set text(fill: quiet) #lines(d.seller.lines) + #if d.seller_email != none [#d.seller_email \ ] + #if d.seller_website != none [#d.seller_website \ ] ], [ #text(22pt, weight: "bold", tracking: 0.08em, d.title) \ @@ -58,6 +65,12 @@ #v(2pt) #text(weight: "semibold", d.bill_to.name) \ #lines(d.bill_to.lines) + #if d.ship_to.len() > 0 { + v(6pt) + text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "SHIP TO") + linebreak() + lines(d.ship_to) + } ], grid( columns: (auto, auto), @@ -67,6 +80,7 @@ text(fill: quiet, "Invoice date"), d.issue_date, text(fill: quiet, "Due date"), text(weight: "semibold", d.due_date), ..if d.terms != none { (text(fill: quiet, "Terms"), d.terms) } else { () }, + ..if d.customer_ref != none { (text(fill: quiet, "Your reference"), d.customer_ref) } else { () }, ), ) @@ -82,7 +96,16 @@ text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "AMOUNT"), ), table.hline(stroke: hair), - ..d.lines.map(l => (l.description, l.amount)).flatten(), + ..d.lines.map(l => ( + { + l.description + if l.detail != none { + linebreak() + text(8.5pt, fill: quiet, l.detail) + } + }, + l.amount, + )).flatten(), table.hline(stroke: firm), ) @@ -126,3 +149,36 @@ v(0.3in) text(8.5pt, fill: quiet, d.note) } + +#if d.memo != none { + v(0.25in) + text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "NOTES") + linebreak() + d.memo +} + +#if d.payment_methods.len() > 0 { + v(0.3in) + block(breakable: false, { + text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "HOW TO PAY") + v(4pt) + for m in d.payment_methods { + block(breakable: false, below: 8pt, grid( + columns: (1fr, auto), + column-gutter: 12pt, + align: (left + top, right + top), + [ + #text(weight: "semibold", m.label) \ + #set text(fill: quiet) + #lines(m.lines) + ], + if m.qr != none { image(bytes(m.qr), format: "svg", width: 0.8in, height: 0.8in) } else { none }, + )) + } + }) +} + +#if d.footer != none { + v(0.3in) + align(center, text(8.5pt, fill: quiet, d.footer)) +} diff --git a/crates/ledger-import/tests/spec_import.rs b/crates/ledger-import/tests/spec_import.rs index 8c0db08..8c94e13 100644 --- a/crates/ledger-import/tests/spec_import.rs +++ b/crates/ledger-import/tests/spec_import.rs @@ -5205,3 +5205,120 @@ proptest! { prop_assert_eq!(&iif.changes, &want); } } + +// --------------------------------------------------------------------- +// The issued invoice's page (P4b S4.5): what a customer pays from +// --------------------------------------------------------------------- + +fn numbered_invoice() -> ledger_import::InvoiceDoc { + use ledger_import::{InvoiceItem, InvoiceParty, PaymentMethodDoc}; + ledger_import::InvoiceDoc { + number: "INV-0007".into(), + issue_date: "Oct 1, 2026".into(), + due_date: "Oct 31, 2026".into(), + terms: Some("Net 30".into()), + seller: InvoiceParty { + name: "Fernhill Labs LLC".into(), + lines: vec!["12 Orchard Lane".into(), "Springfield, UT 84000".into()], + }, + seller_email: Some("billing@fernhill.example".into()), + seller_website: Some("fernhill.example".into()), + bill_to: InvoiceParty { + name: "東京珈琲 株式会社".into(), + lines: vec!["שלום עולם בע״מ".into(), "1-2-3 Chiyoda".into()], + }, + ship_to: vec!["Dock 4, Harbor Road".into()], + customer_ref: Some("PO-4471".into()), + memo: Some("Thank you for the quick turnaround.".into()), + items: vec![ + InvoiceItem { + description: "Design hours".into(), + amount: dec("99.99"), + quantity: Some(dec("3")), + unit_price: Some(dec("33.33")), + }, + InvoiceItem { + description: "Hosting".into(), + amount: dec("1000.01"), + quantity: None, + unit_price: None, + }, + ], + total: dec("1100.00"), + balance: dec("1100.00"), + payment_methods: vec![ + PaymentMethodDoc { + label: "Bank transfer".into(), + lines: vec!["Routing 123456789".into(), "Account 000111222".into()], + qr_svg: None, + }, + PaymentMethodDoc { + label: "Card".into(), + lines: vec!["https://pay.example/fernhill".into()], + qr_svg: Some( + ledger_import::labels::qr_svg("https://pay.example/fernhill").unwrap(), + ), + }, + ], + footer: Some("Thank you for your business.".into()), + ..ledger_import::InvoiceDoc::default() + } +} + +/// The issued invoice's PDF, read back with the PDF text reader, carries +/// the number, both dates, the total, the bill-to (CJK and right-to-left +/// text included), the terms, the reference, each payment method's label +/// and lines, and the quantity beside its price; and nothing internal. +#[test] +fn invoice_pdf_prints_what_it_should() { + let doc = numbered_invoice(); + let pdf = ledger_import::invoice_pdf(&doc).unwrap(); + // SHOT_DIR set: the page, to look at. + if let Ok(dir) = std::env::var("SHOT_DIR") { + std::fs::create_dir_all(&dir).unwrap(); + std::fs::write( + std::path::Path::new(&dir).join("invoice-numbered.pdf"), + &pdf, + ) + .unwrap(); + } + let text = ledger_import::pdf_text(&pdf).unwrap(); + let flat: String = text.split_whitespace().collect::>().join(" "); + for want in [ + "INV-0007", + "Oct 1, 2026", + "Oct 31, 2026", + "$1,100.00", + "Net 30", + "PO-4471", + "Fernhill Labs LLC", + "billing@fernhill.example", + "fernhill.example", + "3 × $33.33", + "$99.99", + "$1,000.01", + "Bank transfer", + "Routing 123456789", + "Account 000111222", + "Card", + "https://pay.example/fernhill", + "Dock 4, Harbor Road", + "Thank you for your business.", + "Thank you for the quick turnaround.", + "東京珈琲", + "1-2-3 Chiyoda", + ] { + assert!(flat.contains(want), "{want:?} missing from:\n{flat}"); + } + // Right-to-left text comes back in reading or visual order depending + // on the reader; its letters are all there either way. + let has_hebrew = |s: &str| "שלוםעולם".chars().all(|c| s.contains(c)); + assert!(has_hebrew(&flat), "Hebrew missing from:\n{flat}"); + // The issued copy: no stamp, no "re-created", no balance of payments. + assert!(!flat.contains("Re-created"), "{flat}"); + assert!(!flat.contains("DRAFT") && !flat.contains("PAID"), "{flat}"); + // Nothing internal: no account code, project, or id. + for never in ["Project", "project", "class", "6999", "Account 4", "id:"] { + assert!(!flat.contains(never), "{never:?} on the page:\n{flat}"); + } +} diff --git a/crates/ledger-mcp/src/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs index 60d91ee..ccfe08c 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -1038,13 +1038,8 @@ impl BooksServer { } (true, ledger_core::DocRef::Invoice(id)) => { let settings = self.settings(); - let printed = ledger_package::invoice_document( - ledger, - &settings.company, - id, - as_of, - today(), - )?; + let printed = + ledger_package::invoice_document(ledger, &settings.company, id, as_of)?; let bytes = ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?; Some(self.hand_over( diff --git a/crates/ledger-package/src/invoice.rs b/crates/ledger-package/src/invoice.rs index 0a905e6..dcae8ae 100644 --- a/crates/ledger-package/src/invoice.rs +++ b/crates/ledger-package/src/invoice.rs @@ -19,8 +19,15 @@ //! 4. Stamps: void VOID; paid in full PAID with the day the last //! payment came in; owing and past due PAST DUE (over PARTIALLY //! PAID); part paid PARTIALLY PAID; otherwise none. -//! 5. Until invoices keep the copy issued (P4b S4), every render says it -//! was re-created from the books, and on which day. +//! 5. The current copy says it was re-created from the books, and as of +//! which day (the `as_of` given, never the wall clock); the issued +//! copy and the draft say no such thing. A legacy invoice (no +//! number, no snapshots) prints "Invoice {id}" and the party record. +//! 7. The number, terms, the customer's reference, memo, seller and +//! bill-to (contact, address, phone, email, a Ship-to block) are the +//! invoice's own snapshots (A-13, D-55), never today's party record +//! when a snapshot exists; the ways to pay and the footer are the +//! settings now. //! 6. The seller is the company (`company.name`, `company.address`); the //! customer is its party's display name and address when it has //! them, else the customer's name. @@ -28,8 +35,10 @@ use chrono::NaiveDate; use ledger_core::settings::Company; -use ledger_core::{DocRef, DocStatus, Invoice, InvoiceId, Ledger}; -use ledger_import::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp}; +use ledger_core::{DocRef, DocStatus, Invoice, InvoiceId, Ledger, PartySnapshot}; +use ledger_import::{ + InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp, InvoiceVersion, PaymentMethodDoc, labels, +}; /// A date as an invoice prints it: "Feb 1, 2026". fn printed(d: NaiveDate) -> String { @@ -47,13 +56,184 @@ fn address_lines(address: Option<&str>) -> Vec { .collect() } -/// Invoice `id` as of `as_of`, re-created on `today` (INVARIANT 4). +/// The company's own line from the profile (`company.email`, +/// `company.website`, the address), blank ones dropped. +fn profile<'a>(company: &'a Company, key: &str) -> Option<&'a str> { + company + .profile + .iter() + .find(|(k, _)| k == key) + .map(|(_, v)| v.trim()) + .filter(|v| !v.is_empty()) +} + +/// The ways to pay (`invoicing.payment_methods`) as the page prints them: +/// the label, its lines, its link as a last line, and a QR code of the +/// link when the method asks for one. +fn payment_methods(ledger: &Ledger) -> Result, String> { + let Some(list) = ledger + .settings() + .get("invoicing.payment_methods") + .and_then(|r| r.value.as_array()) + else { + return Ok(Vec::new()); + }; + let mut out = Vec::new(); + for m in list { + let text = |k: &str| m.get(k).and_then(|v| v.as_str()).map(str::to_string); + let mut lines: Vec = m + .get("lines") + .and_then(|v| v.as_array()) + .map(|a| { + a.iter() + .filter_map(|l| l.as_str()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(); + let link = text("link"); + if let Some(l) = &link { + lines.push(l.clone()); + } + let qr_wanted = m.get("qr").and_then(|v| v.as_bool()).unwrap_or(false); + let qr_svg = match (&link, qr_wanted) { + (Some(l), true) => Some(labels::qr_svg(l).map_err(|e| e.to_string())?), + _ => None, + }; + out.push(PaymentMethodDoc { + label: text("label").unwrap_or_default(), + lines, + qr_svg, + }); + } + Ok(out) +} + +/// What every copy of an invoice prints about the document itself: its +/// number, dates, terms, the seller and bill-to as the snapshots at issue +/// kept them (never today's party record when a snapshot exists, A-13), +/// the lines, the reference and memo, the ways to pay and the footer. The +/// figures that move (settled, balance, stamp) are the caller's. +fn page_of(ledger: &Ledger, company: &Company, invoice: &Invoice) -> Result { + let total = invoice.total().map_err(|e| e.to_string())?; + let party = ledger + .party_of_customer(invoice.customer_id) + .and_then(|p| ledger.get_party(p)); + let seller_address = profile(company, "address"); + let (seller_name, seller_lines, seller_email) = match &invoice.seller { + Some(s) => ( + s.name.clone(), + address_lines(s.address.as_deref()), + s.email + .clone() + .or_else(|| profile(company, "email").map(str::to_string)), + ), + None => ( + company.name.clone(), + address_lines(seller_address), + profile(company, "email").map(str::to_string), + ), + }; + // The bill-to: the snapshot when there is one, else the party record + // as the snapshot would have taken it (D-55's one rule). + let snapshot = match &invoice.bill_to { + Some(b) => Some(b.clone()), + None => party.map(PartySnapshot::from_party), + }; + let (bill_to, ship_to) = match snapshot { + Some(b) => { + let mut lines = Vec::new(); + lines.extend( + b.contact + .as_deref() + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(str::to_string), + ); + lines.extend(address_lines(b.address.as_deref())); + lines.extend( + b.phone + .as_deref() + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(str::to_string), + ); + lines.extend( + b.email + .as_deref() + .map(str::trim) + .filter(|c| !c.is_empty()) + .map(str::to_string), + ); + ( + InvoiceParty { + name: b.name.clone(), + lines, + }, + address_lines(b.shipping_address.as_deref()), + ) + } + None => ( + InvoiceParty { + name: ledger + .get_customer(invoice.customer_id) + .map(|c| c.name.clone()) + .unwrap_or_default(), + lines: Vec::new(), + }, + Vec::new(), + ), + }; + let footer = ledger + .settings() + .get("invoicing.footer") + .and_then(|r| r.value.as_str()) + .map(str::to_string) + .filter(|f| !f.trim().is_empty()); + Ok(InvoiceDoc { + number: invoice + .number + .clone() + .unwrap_or_else(|| format!("Invoice {}", invoice.id)), + issue_date: printed(invoice.date), + due_date: printed(invoice.due_date), + terms: invoice.terms.as_ref().map(ledger_core::Terms::describe), + seller: InvoiceParty { + name: seller_name, + lines: seller_lines, + }, + seller_email, + seller_website: profile(company, "website").map(str::to_string), + bill_to, + ship_to, + customer_ref: invoice.customer_ref.clone(), + memo: invoice.memo.clone(), + items: invoice + .lines + .iter() + .map(|l| InvoiceItem { + description: l.description.clone(), + amount: l.amount, + quantity: l.quantity, + unit_price: l.unit_price, + }) + .collect(), + total, + settled: Vec::new(), + balance: total, + payment_methods: payment_methods(ledger)?, + footer, + ..InvoiceDoc::default() + }) +} + +/// Invoice `id` as of `as_of`: the current copy (INVARIANT 4). It says it +/// was re-created, and on `as_of`, never the wall clock. pub fn invoice_document( ledger: &Ledger, company: &Company, id: InvoiceId, as_of: NaiveDate, - today: NaiveDate, ) -> Result { let inv = ledger .get_invoice(id) @@ -63,27 +243,6 @@ pub fn invoice_document( .doc_state(doc, as_of.max(inv.date)) .map_err(|e| e.to_string())? .ok_or_else(|| format!("invoice {id} is not in the ledger"))?; - let customer = ledger.get_customer(inv.customer_id); - let party = ledger - .party_of_customer(inv.customer_id) - .and_then(|p| ledger.get_party(p)); - let bill_to = InvoiceParty { - name: party - .and_then(|p| p.display_name.clone()) - .or_else(|| customer.map(|c| c.name.clone())) - .unwrap_or_default(), - lines: address_lines(party.and_then(|p| p.address.as_deref())), - }; - let seller = InvoiceParty { - name: company.name.clone(), - lines: address_lines( - company - .profile - .iter() - .find(|(k, _)| k == "address") - .map(|(_, v)| v.as_str()), - ), - }; // Each payment that stands, oldest first (INVARIANT 3): a payment // reversed later, and its reversal, print nothing. let mut settled = Vec::new(); @@ -128,46 +287,29 @@ pub fn invoice_document( DocStatus::Partial => (Some(InvoiceStamp::PartiallyPaid), None), _ => (None, None), }; - Ok(InvoiceDoc { - number: format!("Invoice {id}"), - issue_date: printed(inv.date), - due_date: printed(inv.due_date), - terms: None, - seller, - bill_to, - items: inv - .lines - .iter() - .map(|l| InvoiceItem { - description: l.description.clone(), - amount: l.amount, - }) - .collect(), - total: st.total, - settled, - balance: st.outstanding, - stamp, - stamp_line, - note: Some(format!( - "Re-created on {} from the books; not a copy of the one sent.", - printed(today) - )), - }) + let mut page = page_of(ledger, company, inv)?; + page.total = st.total; + page.settled = settled; + page.balance = st.outstanding; + page.stamp = stamp; + page.stamp_line = stamp_line; + page.version = InvoiceVersion::Current { as_of }; + Ok(page) } /// The copy of an invoice that is being issued (plan P4b S4.4; A-7): the /// page the confirm renders before the invoice exists in the ledger, and -/// files with it. No stamp and no note, because this is the copy the -/// customer gets. Its number is the one the issue will take (A-2), its -/// seller and bill-to the snapshots the issue will store (A-13) — read -/// from `ledger` when a document made before snapshots has none — and -/// nothing is settled on it yet. +/// files with it. No stamp, no re-created note and nothing settled, +/// because this is the copy the customer gets. Its number is the one the +/// issue will take (A-2), its seller and bill-to the snapshots the issue +/// will store (A-13) -- read from `ledger` when a document made before +/// snapshots has none. pub fn issued_invoice_document( ledger: &Ledger, company: &Company, invoice: &Invoice, ) -> Result { - unissued_document(ledger, company, invoice, None, None) + page_of(ledger, company, invoice) } /// The DRAFT copy of an invoice not yet issued (plan P4b S4.4): the PDF a @@ -178,90 +320,11 @@ pub fn draft_invoice_document( company: &Company, invoice: &Invoice, ) -> Result { - unissued_document( - ledger, - company, - invoice, - Some(InvoiceStamp::Draft), - Some("Draft for review: nothing is posted, and this is not the copy sent.".to_string()), - ) -} - -/// [`issued_invoice_document`]'s and [`draft_invoice_document`]'s page: -/// the invoice as the books would hold it, with the stamp and note given. -fn unissued_document( - ledger: &Ledger, - company: &Company, - invoice: &Invoice, - stamp: Option, - note: Option, -) -> Result { - let total = invoice.total().map_err(|e| e.to_string())?; - // The snapshots at issue are what prints (A-13); the party record and - // the company are the fallbacks for a document made before they were - // kept. - let seller = match &invoice.seller { - Some(s) => InvoiceParty { - name: s.name.clone(), - lines: address_lines(s.address.as_deref()), - }, - None => InvoiceParty { - name: company.name.clone(), - lines: address_lines( - company - .profile - .iter() - .find(|(k, _)| k == "address") - .map(|(_, v)| v.as_str()), - ), - }, - }; - let (name, address) = match &invoice.bill_to { - Some(b) => (Some(b.name.clone()), b.address.clone()), - None => { - let party = ledger - .party_of_customer(invoice.customer_id) - .and_then(|p| ledger.get_party(p)); - let name = party - .and_then(|p| p.display_name.clone()) - .or_else(|| { - ledger - .get_customer(invoice.customer_id) - .map(|c| c.name.clone()) - }) - .unwrap_or_default(); - (Some(name), party.and_then(|p| p.address.clone())) - } - }; - let bill_to = InvoiceParty { - name: name.unwrap_or_default(), - lines: address_lines(address.as_deref()), - }; - Ok(InvoiceDoc { - number: invoice - .number - .clone() - .unwrap_or_else(|| format!("Invoice {}", invoice.id)), - issue_date: printed(invoice.date), - due_date: printed(invoice.due_date), - terms: invoice.terms.as_ref().map(ledger_core::Terms::describe), - seller, - bill_to, - items: invoice - .lines - .iter() - .map(|l| InvoiceItem { - description: l.description.clone(), - amount: l.amount, - }) - .collect(), - total, - settled: Vec::new(), - balance: total, - stamp, - stamp_line: None, - note, - }) + let mut page = page_of(ledger, company, invoice)?; + page.stamp = Some(InvoiceStamp::Draft); + page.note = + Some("Draft for review: nothing is posted, and this is not the copy sent.".to_string()); + Ok(page) } #[cfg(test)] @@ -305,10 +368,10 @@ mod tests { company .profile .push(("address".into(), "12 Orchard Lane\nSpringfield, UT".into())); - let before = invoice_document(&l, &company, inv, d(2026, 2, 5), d(2026, 3, 1)).unwrap(); + let before = invoice_document(&l, &company, inv, d(2026, 2, 5)).unwrap(); assert_eq!(before.balance, Decimal::new(50_000, 2)); assert!(before.settled.is_empty() && before.stamp.is_none()); - let after = invoice_document(&l, &company, inv, d(2026, 2, 28), d(2026, 3, 1)).unwrap(); + let after = invoice_document(&l, &company, inv, d(2026, 2, 28)).unwrap(); assert_eq!(after.balance, Decimal::ZERO); assert_eq!( after.settled, @@ -319,7 +382,12 @@ mod tests { assert_eq!(after.seller.lines, ["12 Orchard Lane", "Springfield, UT"]); assert_eq!(after.bill_to.name, "Harbor Client"); assert_eq!(after.issue_date, "Feb 1, 2026"); - assert!(after.note.as_deref().unwrap().contains("Mar 1, 2026")); + assert_eq!( + after.version, + InvoiceVersion::Current { + as_of: d(2026, 2, 28) + } + ); assert!(ledger_import::invoice_pdf(&after).is_ok()); } @@ -416,13 +484,13 @@ mod tests { } }; let today = d(2026, 10, 24); - let part = invoice_document(&l, &company, inv, today, today).unwrap(); + let part = invoice_document(&l, &company, inv, today).unwrap(); assert_eq!(part.stamp, Some(InvoiceStamp::PastDue)); assert_eq!(part.balance, Decimal::new(453_900, 2)); assert_eq!(part.settled[0].0, "Payment Oct 6, 2026 · ACH · ref tr_8812"); save("sample-part-paid.pdf", &part); pay(&mut l, 20, 453_900, "tr_9310"); - let paid = invoice_document(&l, &company, inv, today, today).unwrap(); + let paid = invoice_document(&l, &company, inv, today).unwrap(); assert_eq!(paid.stamp, Some(InvoiceStamp::Paid)); assert_eq!(paid.stamp_line.as_deref(), Some("Oct 20, 2026")); assert_eq!(paid.balance, Decimal::ZERO); @@ -437,7 +505,7 @@ mod tests { .unwrap(); l.void_document(DocRef::Invoice(spare), d(2026, 10, 2), "entered twice") .unwrap(); - let void = invoice_document(&l, &company, spare, today, today).unwrap(); + let void = invoice_document(&l, &company, spare, today).unwrap(); assert_eq!(void.stamp, Some(InvoiceStamp::Void)); save("sample-void.pdf", &void); } diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 9e6c78e..dfe63e8 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -3124,6 +3124,112 @@ coding of `DocRef`), and an account becoming a control account by its first document, holding lines from before, is outside it (those lines are listed as unattached). +## B-39 Terms give one due date and one discount date, exactly + +**bookspec:** `BS-arap-3` + +**Status:** CONVENTION (standard trade-terms notation: "Net 30", "Net 15 +EOM", "2/10 net 30", "Due on receipt"). Sources cited and not yet +vendored, so this rule keeps the CONVENTION label (the Open list in +`README.md`): UCC §2-310(a) (for goods with no agreed terms, payment is +due when the buyer receives them); for services, the contract sets the +terms. Sending an invoice late never moves its due date. + +```text +GOAL: terms give one due date and one discount date, exactly. +SOURCE: CONVENTION: standard trade-terms notation; UCC §2-310(a) for + goods; for services, the contract. +INVARIANTS: + 1. Net n = date + n; Net n EOM = last day of date's month + n; due on + receipt = date; Custom = the given date. Always at least date, in the + calendar (calendar::check); n in 0..=3650. + 2. discount_by = date + d, d below the net days (none for due on + receipt or EOM); a taken discount is at most + kernel::discount_max(total, percent_bp), the caller's exact cents; + after discount_by only with a reason. + 3. The canonical spellings read back as they print. + 4. A line given as a quantity and a unit price has + quantity x unit price = amount exactly (kernel::line_amount, neither + rounded); a line's account is a revenue or deferred-revenue account + on an invoice, an expense or a non-cash asset on a bill, never a role + account, cash or a placeholder. +SCENARIOS: + 1. 2026-01-31 net 30 -> 2026-03-02; 2026-10-23 net 15 EOM -> + 2026-11-15; a leap day; due on receipt = the day. + 2. The 9999-12 bound is refused, not a panic. +``` + +**Enforced:** `ledger_core::Terms` (`new`, `parse`, `due_date`, +`discount_by`, `describe`), `kernel::discount_max`, `kernel::line_amount`, +`Ledger::check_document_line_account`; the settings +`invoicing.default_terms` and a party's terms per side; in the store +behind the feature `documents_v2`. +**Checked:** `ledger-core` `tests/spec_documents.rs` +(`terms_derive_due_dates`, `discount_bounds_and_late_reason`, +`terms_describe_and_parse_round_trip`, `qty_times_price_must_equal_amount`, +`document_line_account_refuses_roles_cash_and_placeholders`, +`bill_lines_post_to_their_accounts`); Kani `discount_max_is_bounded`, +`line_amount_is_exact` and their bridge properties; over the wire +`crates/ledger-cli/tests/documents.rs` (`terms_fall_back_in_order`, +`a_bill_with_its_vendor_invoice_and_terms`, `a_line_account_must_be_allowed`). + +## B-40 An invoice number names one invoice forever; the copy sent is kept; a vendor invoice is entered once + +**bookspec:** `BS-arap-4` + +**Status:** CONVENTION (sequential invoice numbers with voids kept, and a +duplicate-payment control). Sources cited and not yet vendored, so this +rule keeps the CONVENTION label (the Open list in `README.md`): IRS Pub. +583 (invoices are among the records to keep); Rev. Proc. 98-25 (an +electronic record keeps its audit trail: why the PDF as issued is filed +with its hash and a void keeps its number). + +```text +GOAL: an invoice number names one invoice forever; the copy sent is kept; + a vendor invoice is entered once. +SOURCE: Pub. 583; Rev. Proc. 98-25; CONVENTION: sequential numbers, voids + kept; duplicate-payment control. +INVARIANTS: + 1. Numbers are unique, in issue order, never reused; a void keeps its + number. Four digits, wider past 9,999, never truncated. + 2. Number, snapshots, terms and lines never change after issue. + 3. The issued PDF is filed in the issuing call as receipt.attached on + the entry and linked to the invoice; its sha256 is in that event. The + copy a later look prints is re-created from the books as of a day, + says so (that day, never the wall clock), and never replaces it. + 4. (vendor, normalized vendor_ref) is unique among standing bills unless + a duplicate_reason is journaled; the normalization drops case and + spaces. + 5. Sends never change a figure. +SCENARIOS: + 1. Three invoices, the second voided: INV-0001, INV-0002 (void), INV-0003. + 2. A second bill from the same vendor with the same number: refused + unless a reason says why. + 3. The page prints the number the preview showed; a number another + session took in between is refused as a duplicate. +``` + +**Enforced:** `Ledger::next_invoice_number`, `Ledger::check_vendor_ref`, +`PartySnapshot::from_party` (the snapshots at issue, D-55); +`Store::create_invoice_issued` (feature `documents_v2`; the PDF is +rendered before anything is journaled, so a Typst failure changes +nothing); `ledger_package::{issued_invoice_document, +draft_invoice_document, invoice_document}` and +`ledger_import::invoice_pdf` (deterministic bytes, every string data, +every amount through `amount_parts`). +**Checked:** `ledger-core` `tests/spec_documents.rs` +(`numbers_run_and_voids_keep_theirs`, `number_widens_past_9999`, +`duplicate_vendor_ref_refused_unless_a_reason_is_given`); `ledger-store` +`tests/arap_standing.rs` (`documents_v2_waits_for_the_upgrade`, +`issued_pdf_is_filed_with_the_invoice_or_nothing_is`); over the wire +`crates/ledger-cli/tests/documents.rs` (`an_invoice_from_issue_to_paid`, +`duplicate_vendor_invoice_refused`); the page `ledger-import` +`tests/spec_import.rs::invoice_pdf_prints_what_it_should` and the unit tests +`labels_are_data`, `an_invoice_renders_deterministically`, +`two_hundred_lines_paginate`; the viewer +`a_numbered_invoice_shows_its_number_terms_bill_to_and_po` and +`the_viewer_holds_a_numbered_invoice_as_the_store_does`. + ## B-41 A debt that will not be collected is written off, and a recovery is income **bookspec:** `BS-arap-5` From df93d8b951e80b3317d43632a32b4fd4d7a9ed2d Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 12:55:07 -0600 Subject: [PATCH 2/4] feat(viewer): the invoice page shows its number, terms, bill-to and PO (P4b S4.5) The viewer reads the same invoice the server prints. The Money in list and the page title name an invoice by its issued number (a legacy invoice keeps "Invoice {id}"); a Details card shows the number, terms, the bill-to as it was at issue (contact, address, phone, email), Ship-to, the customer's PO and memo, and a bill's vendor number, day received, terms and memo. Save invoice PDF still re-creates the current copy; when the issued copy is filed (found through its link to the invoice, never by name) Save issued copy and Open issued copy hand over those very bytes, checked against their SHA-256. Nothing is written to the books. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-gui/src/pages/invoices.rs | 175 +++++++++++++++++++++--- crates/ledger-gui/tests/invoices.rs | 135 ++++++++++++++++++ crates/ledger-gui/tests/view_model.rs | 61 +++++++++ 3 files changed, 349 insertions(+), 22 deletions(-) diff --git a/crates/ledger-gui/src/pages/invoices.rs b/crates/ledger-gui/src/pages/invoices.rs index b82ec5d..35abff9 100644 --- a/crates/ledger-gui/src/pages/invoices.rs +++ b/crates/ledger-gui/src/pages/invoices.rs @@ -59,6 +59,94 @@ struct Item { state: Result, ledger_core::LedgerError>, } +/// What an invoice is called: its number, else "Invoice {id}" (a document +/// made before numbers has none, A-2). +fn invoice_name(d: &ledger_core::Invoice) -> String { + d.number + .clone() + .unwrap_or_else(|| format!("Invoice {}", d.id)) +} + +/// A document's facts beyond its figures, as label and value, in the +/// order the page shows them (plan P4b S4.5): an invoice's number, terms, +/// bill-to (the snapshot at issue), ship-to, the customer's PO and memo; a +/// bill's vendor number, received day, terms and memo. Only what the +/// document has. +pub fn details(l: &ledger_core::Ledger, doc: DocRef) -> Vec<(&'static str, String)> { + let one_line = |s: &str| { + s.lines() + .map(str::trim) + .filter(|x| !x.is_empty()) + .collect::>() + .join(", ") + }; + let mut out: Vec<(&'static str, String)> = Vec::new(); + match doc { + DocRef::Invoice(id) => { + let Some(inv) = l.get_invoice(id) else { + return out; + }; + if let Some(n) = &inv.number { + out.push(("Number", n.clone())); + } + if let Some(t) = &inv.terms { + out.push(("Terms", t.describe())); + } + if let Some(b) = &inv.bill_to { + let mut parts = vec![b.name.clone()]; + for x in [&b.contact, &b.address, &b.phone, &b.email] + .into_iter() + .flatten() + { + let x = one_line(x); + if !x.is_empty() { + parts.push(x); + } + } + out.push(("Bill to", parts.join(", "))); + if let Some(ship) = &b.shipping_address { + out.push(("Ship to", one_line(ship))); + } + } + if let Some(r) = &inv.customer_ref { + out.push(("PO", r.clone())); + } + if let Some(m) = &inv.memo { + out.push(("Memo", m.clone())); + } + } + DocRef::Bill(id) => { + let Some(b) = l.get_bill(id) else { + return out; + }; + if let Some(n) = &b.vendor_ref { + out.push(("Vendor's number", n.clone())); + } + if let Some(d) = b.received_on { + out.push(("Received", fmt::date(d))); + } + if let Some(t) = &b.terms { + out.push(("Terms", t.describe())); + } + if let Some(m) = &b.memo { + out.push(("Memo", m.clone())); + } + } + } + out +} + +/// The copy of invoice `id` that was issued, when one is filed: found +/// through its link to the invoice (a PDF described "Issued invoice"), +/// never by guessing at names. The earliest, should there be several. +fn issued_copy(b: &Books, id: ledger_core::InvoiceId) -> Option<&ledger_store::Attachment> { + b.documents_for(&[ledger_store::Subject::Invoice(id)]) + .into_iter() + .find(|a| { + a.mime_type == "application/pdf" && a.description.as_deref() == Some("Issued invoice") + }) +} + /// Every invoice (or bill) in the books, read as of today (one dated /// ahead, as of its own date: Books::doc_today). fn items(b: &Books, bills: bool) -> Vec { @@ -84,7 +172,7 @@ fn items(b: &Books, bills: bool) -> Vec { .into_iter() .map(|d| Item { doc: DocRef::Invoice(d.id), - name: format!("Invoice {}", d.id), + name: invoice_name(d), party: l .get_customer(d.customer_id) .map(|c| c.name.clone()) @@ -428,7 +516,7 @@ pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) { ) = match doc { DocRef::Invoice(id) => match l.get_invoice(id) { Some(d) => ( - format!("Invoice {id}"), + invoice_name(d), l.get_customer(d.customer_id) .map(|c| c.name.clone()) .unwrap_or_default(), @@ -501,6 +589,27 @@ pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) { ); }); ui.add_space(8.0); + let facts = details(l, doc); + if !facts.is_empty() { + widgets::titled_card( + ui, + "Details", + |_| {}, + |ui| { + egui::Grid::new("invoice_details") + .num_columns(2) + .spacing([16.0, 4.0]) + .show(ui, |ui| { + for (label, value) in &facts { + ui.label(egui::RichText::new(*label).color(p.muted)); + ui.add(egui::Label::new(value.as_str()).wrap()); + ui.end_row(); + } + }); + }, + ); + ui.add_space(8.0); + } let amt = widgets::AMOUNT_MIN + 16.0; widgets::titled_card( ui, @@ -641,38 +750,60 @@ pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) { /// "Save invoice PDF" and "Open": the invoice as of today, re-created /// from the books (ledger_package::invoice_document, the same data the -/// server prints), saved in Downloads. Writes nothing to the books. +/// server prints), saved in Downloads; and, when the invoice has its +/// issued copy filed, "Save issued copy" and "Open issued copy", the very +/// bytes the customer got (read from the blob store, checked against its +/// SHA-256). Writes nothing to the books. fn pdf_actions(ui: &mut Ui, b: &Books, id: ledger_core::InvoiceId) { let p = pal(ui.ctx()); let said = ui.id().with(("invoice-pdf-said", id)); + let issued = issued_copy(b, id); ui.horizontal(|ui| { let save = ui .button(format!("{} Save invoice PDF", icon::FILE_PDF)) - .on_hover_text("As of today, in your Downloads folder"); + .on_hover_text("As of today, re-created from the books, in your Downloads folder"); let open = ui .button(format!("{} Open", icon::ARROW_SQUARE_OUT)) .on_hover_text("As of today, in your Downloads folder, opened with its app"); if save.clicked() || open.clicked() { - let result = ledger_package::invoice_document( - &b.ledger, - &b.settings.company, - id, - b.today, - ) - .and_then(|doc| { - ledger_import::invoice_pdf::invoice_pdf(&doc).map_err(|e| e.to_string()) - }) - .and_then(|bytes| { - let stem = format!("invoice-{id}"); - if open.clicked() { - crate::export::save_and_open(&stem, "pdf", &bytes) - } else { - crate::export::save(&stem, "pdf", &bytes) - } - }) - .map(|path| path.display().to_string()); + let result = + ledger_package::invoice_document(&b.ledger, &b.settings.company, id, b.today) + .and_then(|doc| { + ledger_import::invoice_pdf::invoice_pdf(&doc).map_err(|e| e.to_string()) + }) + .and_then(|bytes| { + let stem = format!("invoice-{id}"); + if open.clicked() { + crate::export::save_and_open(&stem, "pdf", &bytes) + } else { + crate::export::save(&stem, "pdf", &bytes) + } + }) + .map(|path| path.display().to_string()); ui.data_mut(|d| d.insert_temp(said, result)); } + if let Some(a) = issued { + let save = ui + .button(format!("{} Save issued copy", icon::FILE_PDF)) + .on_hover_text("The copy that was sent, as it was filed, in your Downloads folder"); + let open = ui + .button(format!("{} Open issued copy", icon::ARROW_SQUARE_OUT)) + .on_hover_text("The copy that was sent, opened with its app"); + if save.clicked() || open.clicked() { + let (stem, ext) = crate::export::split_name(&a.file_name); + let result = b + .attachment_bytes(a) + .and_then(|bytes| { + if open.clicked() { + crate::export::save_and_open(stem, ext, &bytes) + } else { + crate::export::save(stem, ext, &bytes) + } + }) + .map(|path| path.display().to_string()); + ui.data_mut(|d| d.insert_temp(said, result)); + } + } }); if let Some(result) = ui.data(|d| d.get_temp::>(said)) { let (text, color) = match result { diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index 9fcbc82..77e96ee 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -390,3 +390,138 @@ fn a_write_off_shows_on_the_timeline() { h.run_steps(2); assert_eq!(h.state_mut().route(), &Route::Entry(entry)); } + +/// Books with an invoice issued the way `books.create_invoice` issues it +/// (plan P4b S4.5): numbered, on terms, the bill-to kept as a snapshot +/// with its contact block, the customer's PO and a memo, and its issued +/// PDF filed with it. A second invoice is a legacy one: no number, no +/// issued copy. +fn numbered_books(dir: &TempDir) -> (u64, u64) { + use ledger_core::{PartySnapshot, Terms}; + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + s.add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let client = s.add_customer("Harbor Coffee").unwrap(); + let fields = InvoiceFields { + number: None, + terms: Some(Terms::parse("Net 30").unwrap()), + bill_to: Some(PartySnapshot { + name: "Harbor Coffee Roasters, Inc.".into(), + address: Some("455 Wharf Street\nPortland, OR 97209".into()), + email: Some("ap@harbor.example".into()), + phone: Some("503-555-0100".into()), + contact: Some("Ana Reyes".into()), + billing_address: None, + shipping_address: Some("Dock 4, Harbor Road".into()), + billing_email: None, + }), + seller: None, + customer_ref: Some("PO-4471".into()), + memo: Some("Thank you for the quick turnaround.".into()), + }; + let lines = vec![line("Consulting", 50_000)]; + let preview = s + .preview_invoice( + client, + d(2026, 3, 1), + d(2026, 3, 31), + lines.clone(), + fields.clone(), + ) + .unwrap(); + let fields = InvoiceFields { + number: preview.number.clone(), + ..fields + }; + let page = ledger_package::issued_invoice_document( + s.ledger(), + &ledger_core::settings::Company::default(), + &preview.invoice, + ) + .unwrap(); + let pdf = ledger_import::invoice_pdf(&page).unwrap(); + let (numbered, _, _) = s + .create_invoice_issued(client, d(2026, 3, 1), d(2026, 3, 31), lines, fields, pdf) + .unwrap(); + let legacy = s + .create_invoice( + client, + d(2026, 3, 2), + d(2026, 4, 1), + vec![line("Support", 8_000)], + ) + .unwrap(); + (numbered, legacy) +} + +/// Plan P4b S4.5: the invoice's page shows its number, terms, bill-to (the +/// snapshot at issue, contact block included), ship-to and PO, and offers +/// the issued copy beside the re-created one; the Money in list names it +/// by its number. A legacy invoice keeps "Invoice {id}", shows no details +/// card, and has no issued copy to save. +#[test] +fn a_numbered_invoice_shows_its_number_terms_bill_to_and_po() { + let dir = TempDir::new("numbered"); + let (numbered, legacy) = numbered_books(&dir); + let number = { + let books = data::load_once(&Source { + data_dir: dir.0.join("books-data"), + today: Some(d(2026, 3, 15)), + }) + .unwrap(); + let inv = books.ledger.get_invoice(numbered).unwrap(); + let n = inv.number.clone().expect("the issue numbered it"); + // The page's facts are the invoice's own (view_model parity). + let facts = ledger_gui::pages::invoices::details( + &books.ledger, + ledger_core::DocRef::Invoice(numbered), + ); + let get = |label: &str| { + facts + .iter() + .find(|(l, _)| *l == label) + .map(|(_, v)| v.clone()) + }; + assert_eq!(get("Number").as_deref(), Some(n.as_str())); + assert_eq!(get("Terms").as_deref(), Some("Net 30")); + assert_eq!(get("PO").as_deref(), Some("PO-4471")); + assert_eq!( + get("Bill to").as_deref(), + Some( + "Harbor Coffee Roasters, Inc., Ana Reyes, 455 Wharf Street, Portland, OR 97209, 503-555-0100, ap@harbor.example" + ) + ); + assert_eq!(get("Ship to").as_deref(), Some("Dock 4, Harbor Road")); + assert_eq!( + get("Memo").as_deref(), + Some("Thank you for the quick turnaround.") + ); + n + }; + let mut h = open(&dir, Route::Invoice(numbered)); + shot(&mut h, "invoice-numbered"); + assert!(h.query_all_by_label_contains(&number).count() >= 1); + assert!(h.query_by_label("Net 30").is_some()); + assert!(h.query_by_label("PO-4471").is_some()); + assert!( + h.query_by_label_contains("Harbor Coffee Roasters, Inc., Ana Reyes") + .is_some() + ); + assert!(h.query_by_label_contains("Save invoice PDF").is_some()); + assert!(h.query_by_label_contains("Save issued copy").is_some()); + + // The list names it by number; the legacy one keeps its old name. + let h = open(&dir, Route::Receivables); + assert!(h.query_all_by_label_contains(&number).count() >= 1); + assert!( + h.query_all_by_label_contains(&format!("Invoice {legacy}")) + .count() + >= 1 + ); + + // A legacy invoice: no details, and no issued copy to save. + let h = open(&dir, Route::Invoice(legacy)); + assert!(h.query_by_label("Details").is_none()); + assert!(h.query_by_label_contains("Save invoice PDF").is_some()); + assert!(h.query_by_label_contains("Save issued copy").is_none()); +} diff --git a/crates/ledger-gui/tests/view_model.rs b/crates/ledger-gui/tests/view_model.rs index d6c0c4d..83389aa 100644 --- a/crates/ledger-gui/tests/view_model.rs +++ b/crates/ledger-gui/tests/view_model.rs @@ -924,3 +924,64 @@ fn a_write_off_is_flagged_for_the_cpa() { assert_eq!(st.status, ledger_core::DocStatus::WrittenOff); assert!(books.ledger.get_entry(entry).is_some()); } + +/// Plan P4b S4.5: the viewer, which replays the journal itself, holds a +/// numbered invoice exactly as the store that wrote it does: its number, +/// terms, bill-to snapshot, the customer's PO and memo, and its lines' +/// quantity and unit price. What the page shows is read from those, so +/// the viewer and the server cannot disagree about them. +#[test] +fn the_viewer_holds_a_numbered_invoice_as_the_store_does() { + use ledger_core::arap::InvoiceFields; + use ledger_core::{PartySnapshot, Terms}; + let d = dir("numbered-parity"); + let today = NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(); + let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap(); + s.add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let cust = s.add_customer("Harbor Coffee").unwrap(); + let inv = s + .create_invoice_with( + cust, + NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), + NaiveDate::from_ymd_opt(2026, 3, 31).unwrap(), + vec![DocumentLine { + description: "Beans".into(), + amount: Decimal::new(9_999, 2), + class_id: None, + account: None, + quantity: Some(Decimal::new(3, 0)), + unit_price: Some(Decimal::new(3_333, 2)), + }], + InvoiceFields { + number: Some("INV-0007".into()), + terms: Some(Terms::parse("2/10 net 30").unwrap()), + bill_to: Some(PartySnapshot { + name: "Harbor Coffee Roasters, Inc.".into(), + address: Some("455 Wharf Street".into()), + email: Some("ap@harbor.example".into()), + phone: None, + contact: Some("Ana Reyes".into()), + billing_address: None, + shipping_address: Some("Dock 4".into()), + billing_email: None, + }), + seller: None, + customer_ref: Some("PO-4471".into()), + memo: Some("Thanks".into()), + }, + ) + .unwrap(); + let stored = s.ledger().get_invoice(inv).unwrap().clone(); + drop(s); + let books = data::load_once(&Source { + data_dir: d.join("books-data"), + today: Some(today), + }) + .unwrap(); + let shown = books.ledger.get_invoice(inv).unwrap(); + assert_eq!(shown, &stored); + assert_eq!(shown.number.as_deref(), Some("INV-0007")); + assert_eq!(shown.terms.as_ref().unwrap().describe(), "2/10 net 30"); + assert_eq!(shown.lines[0].quantity, Some(Decimal::new(3, 0))); +} From caad35d75bf921609ceb4ba8d0639421febf6b63 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 12:55:07 -0600 Subject: [PATCH 3/4] 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 From ab3bd958c4481e67f3aec04bb90c90bbc64a368b Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 12:55:41 -0600 Subject: [PATCH 4/4] docs(plan): S4.5 as built (P4b S4.5) Departures from the Build text, one bullet each: no golden sha to recompute, invoice_document's dropped `today`, where the ways to pay, the footer and the website come from, exact unit prices, the handbook steps the earlier slices had already written, the viewer parity test's shape, and the bookspec index left to P13. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- plan/served.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/plan/served.md b/plan/served.md index 850fd1d..a53083a 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1303,7 +1303,7 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 #### P4b S4: `documents_v2` -1. **Status:** in progress: S4.1 (`13282cf8`), S4.2 (`bf91c5a7`) and S4.3 (`d19fb464`) landed 2026-10-27; S4.4 landed 2026-10-29 (`d329bd6f`, merged clean from branch `S4.4` at `002fb9f8`); S4.5 not started, the next unit. +1. **Status:** in progress: S4.1 (`13282cf8`), S4.2 (`bf91c5a7`) and S4.3 (`d19fb464`) landed 2026-10-27; S4.4 landed 2026-10-29 (`d329bd6f`, merged clean from branch `S4.4` at `002fb9f8`); S4.5 built 2026-10-29 on branch `S4.5`, not merged. 2. **Depends on:** S1-S3 (landed). Inside the unit: S4.1 and S4.2 need nothing and may run in two worktrees; S4.3 needs both; S4.4 needs U0a and S4.3; S4.5 needs S4.4. S6 may start when S4.1 has merged; S5 when S4.2 has; S7.2 when S4.3 has. Go-ahead: `documents_v2`, one upgrade after S7 (D-52; Q-5 asks the owner whether to upgrade earlier). Until the go-ahead, on the owner's books a document carrying any new field is refused with `format_upgrade_pending`; a document carrying none posts exactly as before. 3. **Feature and schema:** feature `documents_v2` (R-A; shared with S5, S6, S7, so S4 creates the constant and the test and the later slices add their call sites). New fields on `invoice.created` and `bill.created` and on their lines, and on `party.updated`, as 8.0.3 lists; no new event type. Provisional schema 16: columns on the documents and document lines tables, a `parties.email` and two terms columns. 4. **Build:** @@ -1358,6 +1358,15 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 - D-55: `PartySnapshot::from_party` is the one place the billing-else-general rule lives (an invoice's bill-to `address`/`email` are the billing ones when set); the snapshot carries the whole block, and `PartyOut` gained the email, terms and contact fields so `books.party` shows what `books.update_party` sets. - Item 17's `documents_v2_refused_until_consent` is in `crates/ledger-cli/tests/format_upgrade.rs`; the wire tests are in the new `crates/ledger-cli/tests/documents.rs`, and D-55's round-trip and at-issue tests in `crates/ledger-store/tests/parties.rs`. - Existing callers of `books.create_invoice` and `books.create_bill` gained `confirmed: true` (and a bill its vendor's number, line accounts and file): the contract A-1 changes. Two tests that named `books.create_invoice` as a tool a write token cannot see now name `books.receive_payment`, since a write token may preview an invoice. + S4.5 (2026-10-29, branch `S4.5`), departures only: + - Item 19's "golden sha updated once": `an_invoice_renders_deterministically` never held a hash (it renders twice and compares bytes), so there was nothing to recompute; no golden moved. Its new siblings are unit tests beside it in `invoice_pdf.rs` (`labels_are_data`, `two_hundred_lines_paginate`, `a_quantity_prints_beside_its_price`, `only_the_current_copy_says_it_was_re_created`); `invoice_pdf_prints_what_it_should` is in `crates/ledger-import/tests/spec_import.rs`, as named. + - `ledger_package::invoice_document` lost its `today` argument: 8.3.11 has the "Re-created on" note print `as_of`, so `today` had no use (`InvoiceVersion::Current { as_of }` carries it). The three callers (server, viewer, the package's own tests) changed with it. `issued_invoice_document`, `draft_invoice_document` and `invoice_document` now share one private `page_of`, so the three copies read the invoice's snapshots one way. + - The ways to pay and the footer are read from the ledger's journaled settings (`ledger.settings()`), the company's email and website from `company.profile`: neither is in the seller snapshot (`PartySnapshot` has an email but no website), so the issued copy freezes today's values into its filed bytes and the current copy reprints with whatever the settings say by then. Only the party's side is snapshotted at issue (A-13, D-55); the plan's step 18 did not say where these come from. + - A unit price with more than two decimals prints exactly ("8 x $0.125"), never rounded to the cent: `quantity x unit_price = amount` is exact (B-39 item 4), and a rounded price beside the unchanged amount would read as an error. The two-decimal case still passes through `amount_parts`. + - Item 21: S5, S6 and S7 had already written steps 1 (the send), 2, 3, 4 and 5 of the handbook section, so this unit extended step 1 (the issue: preview, confirm, the filed copy, quantities, the snapshot) and renamed "What the CPA will check" step 6 with the 8.3.2 list, instead of writing the whole procedure. `{documents_v2}` needs the feature, which `handbook::section` did not know: `section_with(.., documents_v2)` is the new function, the server and the handbook tool call it, and the four-argument `section` stays only for tests (`#[cfg(test)]`), so no other caller changed. + - Item 20's "`view_model.rs` parity for the new fields": `books.document` carries no number, terms or snapshot (S4.4 did not add them to its output), so there is no server figure to compare. `the_viewer_holds_a_numbered_invoice_as_the_store_does` compares the viewer's replayed invoice to the live store's instead, and the page's own facts are one function (`pages::invoices::details`) that the invoices test reads back. + - The viewer's Details card is on bills too (the vendor's number, day received, terms, memo), which item 20 did not ask for; the list names a bill by "Bill {id}" as before. The breadcrumb, the command palette and the attention titles still say "Invoice {id}" for a numbered invoice; naming them by number is a follow-up outside this unit. + - B-39 and B-40 (item 21) are written in `docs/spec/rules.md`; `bookspec/spec/rules-index.md` still lists them as "allocated, not yet stated", which is P13's file and its checker's table, so it was not touched. #### P4b S5: sent tracking (`invoice.sent`)