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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 12:55:10 -06:00
1 parent ce69d9d7bc
commit 400d0cdcab
8 files changed
+706 -169

No files matched your search

-1
View File
@@ -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())
+202 -9
View File
@@ -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<Decimal>,
/// The price of one unit, with [`Self::quantity`].
pub unit_price: Option<Decimal>,
}
/// 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<String>,
/// A QR code for it, as an SVG document (`labels::qr_svg`), when it
/// has one.
pub qr_svg: Option<String>,
}
/// 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<String>,
/// The company.
pub seller: InvoiceParty,
/// The seller's email, when it has one.
pub seller_email: Option<String>,
/// The seller's website, when it has one.
pub seller_website: Option<String>,
/// The customer.
pub bill_to: InvoiceParty,
/// Where goods go, when the customer said so: printed as a Ship-to
/// block.
pub ship_to: Vec<String>,
/// The customer's own reference (their purchase order), when given.
pub customer_ref: Option<String>,
/// A memo printed under the lines, when given.
pub memo: Option<String>,
/// The ways to pay.
pub payment_methods: Vec<PaymentMethodDoc>,
/// Text at the foot of the page (`invoicing.footer`).
pub footer: Option<String>,
/// Which copy this is.
pub version: InvoiceVersion,
/// Its lines.
pub items: Vec<InvoiceItem>,
/// 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<String>,
/// 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<String>,
}
@@ -125,12 +184,50 @@ fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
})
}
/// 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<String> {
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<String> {
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<serde_json::Value, PdfError> {
"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::<Vec<_>>(),
"payment_methods": doc.payment_methods.iter().map(|m| serde_json::json!({
"label": m.label,
"lines": m.lines,
"qr": m.qr_svg,
})).collect::<Vec<_>>(),
"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<serde_json::Value, PdfError> {
"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"));
}
}
+4 -1
View File
@@ -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};
+59 -3
View File
@@ -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))
}
+117
View File
@@ -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::<Vec<_>>().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}");
}
}
+2 -7
View File
@@ -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(
+216 -148
View File
@@ -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<String> {
.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<Vec<PaymentMethodDoc>, 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<String> = 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<InvoiceDoc, String> {
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<InvoiceDoc, String> {
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<InvoiceDoc, String> {
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<InvoiceDoc, String> {
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<InvoiceStamp>,
note: Option<String>,
) -> Result<InvoiceDoc, String> {
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);
}
+106
View File
@@ -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`