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`