feat: the invoice PDF, with stamps and each payment (P4b S3)
check / check (push) Canceled after 0s

ledger-import invoice_pdf (templates/invoice.typ): seller, bill-to,
number, dates, terms, lines, total, each settled line and the balance
due; money formatted in Rust (the template does no arithmetic), text as
data, deterministic bytes. Stamps as a rubber stamp would mark it, in
red: PAID (with the day it was paid in full: the invoice is then the
customer's receipt), PARTIALLY PAID, PAST DUE, VOID; DRAFT in gray for
S4's preview. ledger-package invoice_document builds it from the books
as of a day: the derived figures (B-38), each payment that stands on its
own line with its date, payment method and reference; re-created copies
say so (the issued copy is kept from S4 on and never changes).
books.document pdf hands it over as a download; the viewer's invoice
page saves or opens it. The owner asked for the paid stamp with the
payment's date and details (2026-10-24).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01T3gXMVt7XJPi6ens1or9Dj
This commit is contained in:
swaitsandClaude Opus 5.5 committed 2026-10-24 18:20:55 -06:00
1 parent b7b7f17163
commit c1f9001cfe
12 files changed
+843 -4

No files matched your search

+329
View File
@@ -0,0 +1,329 @@
//! An invoice as the books hold it, ready to print (plan P4b.11): the
//! data `ledger_import::invoice_pdf` typesets, built in one place so the
//! server and the viewer give the same bytes for the same books.
//!
//! ```text
//! GOAL: print an invoice from the books, as of a day.
//! SOURCE: plan/served.md P4b.11 (the "current" version); rules.md B-38
//! (a document's figures are its lines that stand).
//! INVARIANTS:
//! 1. Its figures are the invoice's derived state as of the day (as of
//! its own date when the day is earlier): total, what was paid,
//! discounted and written off, and the balance due.
//! 2. Its lines are the invoice's, in order; projects, accounts and ids
//! never print.
//! 3. Each payment that stands prints on its own line, with its date,
//! payment method and reference ("Payment Feb 10, 2026 · Harbor
//! checking · ref tr_8812"), so a paid invoice is the customer's
//! receipt. Discounts and write-offs print as one line each.
//! 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.
//! 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.
//! ```
use chrono::NaiveDate;
use ledger_core::settings::Company;
use ledger_core::{DocRef, DocStatus, InvoiceId, Ledger};
use ledger_import::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp};
/// A date as an invoice prints it: "Feb 1, 2026".
fn printed(d: NaiveDate) -> String {
d.format("%b %-d, %Y").to_string()
}
/// An address's lines: one per line of the text, blank ones dropped.
fn address_lines(address: Option<&str>) -> Vec<String> {
address
.unwrap_or("")
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(str::to_string)
.collect()
}
/// Invoice `id` as of `as_of`, re-created on `today` (INVARIANT 4).
pub fn invoice_document(
ledger: &Ledger,
company: &Company,
id: InvoiceId,
as_of: NaiveDate,
today: NaiveDate,
) -> Result<InvoiceDoc, String> {
let inv = ledger
.get_invoice(id)
.ok_or_else(|| format!("no invoice {id}"))?;
let doc = DocRef::Invoice(id);
let st = ledger
.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();
let mut last_paid = None;
for l in ledger.doc_lines(doc) {
if l.movement != ledger_core::Movement::Payment
|| l.reversal
|| l.date > as_of
|| !ledger.stands_on(l.entry, as_of)
{
continue;
}
let mut label = format!("Payment {}", printed(l.date));
if let Some(i) = ledger
.entry_instrument(l.entry)
.and_then(|i| ledger.get_instrument(i))
{
label.push_str(&format!(" · {}", i.label));
}
if let Some(r) = ledger
.get_entry(l.entry)
.and_then(|e| e.description().rsplit_once(" (ref "))
.and_then(|(_, r)| r.strip_suffix(')'))
{
label.push_str(&format!(" · ref {r}"));
}
settled.push((label, -l.amount));
last_paid = Some(l.date);
}
for (label, amount) in [
("Discounts", st.discounted),
("Written off", st.written_off),
] {
if !amount.is_zero() {
settled.push((label.to_string(), amount));
}
}
let (stamp, stamp_line) = match st.status {
DocStatus::Void => (Some(InvoiceStamp::Void), None),
DocStatus::Paid => (Some(InvoiceStamp::Paid), last_paid.map(printed)),
_ if st.overdue_days > 0 => (Some(InvoiceStamp::PastDue), None),
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)
)),
})
}
#[cfg(test)]
mod tests {
use super::*;
use ledger_core::{AccountType, Currency, DocumentLine};
use rust_decimal::Decimal;
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
NaiveDate::from_ymd_opt(y, m, day).unwrap()
}
#[test]
fn an_invoice_prints_its_figures_as_of_a_day() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let c = l.add_customer("Harbor Client").unwrap();
let inv = l
.create_invoice(
c,
d(2026, 2, 1),
d(2026, 3, 3),
vec![DocumentLine {
description: "Consulting".into(),
amount: Decimal::new(50_000, 2),
class_id: None,
}],
)
.unwrap();
l.receive_payment(inv, d(2026, 2, 10), Decimal::new(50_000, 2), cash)
.unwrap();
let mut company = Company {
name: "Fernhill Labs LLC".into(),
..Company::default()
};
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();
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();
assert_eq!(after.balance, Decimal::ZERO);
assert_eq!(
after.settled,
vec![("Payment Feb 10, 2026".to_string(), Decimal::new(50_000, 2))]
);
assert_eq!(after.stamp, Some(InvoiceStamp::Paid));
assert_eq!(after.stamp_line.as_deref(), Some("Feb 10, 2026"));
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!(ledger_import::invoice_pdf(&after).is_ok());
}
/// The sample invoice, through the books, in three states (part paid
/// and past due, paid in full, void): placeholder company and customer
/// with full addresses, several lines, payments with their method and
/// reference. SHOT_DIR set: each PDF is saved there, to look at.
#[test]
fn the_sample_invoice_in_each_state() {
let mut l = Ledger::new(Currency::USD);
let cash = l
.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let bank = l
.add_instrument(
ledger_core::InstrumentKind::Bank,
"ACH",
Some("4821"),
None,
cash,
)
.unwrap();
let c = l.add_customer("Harbor Coffee Roasters").unwrap();
let party = l.party_of_customer(c).unwrap();
l.update_party(
party,
ledger_core::PartyField::DisplayName(Some("Harbor Coffee Roasters, Inc.".into())),
)
.unwrap();
l.update_party(
party,
ledger_core::PartyField::Address(Some(
"Attn: Accounts Payable\n455 Wharf Street, Suite 210\nPortland, OR 97209".into(),
)),
)
.unwrap();
let line = |d: &str, cents: i64| DocumentLine {
description: d.into(),
amount: Decimal::new(cents, 2),
class_id: None,
};
let inv = l
.create_invoice(
c,
d(2026, 9, 1),
d(2026, 10, 1),
vec![
line(
"iOS app: ordering flow, design and build (40 hours at $125)",
500_000,
),
line(
"Android app: port of the ordering flow (24 hours at $125)",
300_000,
),
line(
"App Store and Google Play submission and review support",
45_000,
),
line("Hosting and push notifications, September", 8_900),
],
)
.unwrap();
let pay = |l: &mut Ledger, day: u32, cents: i64, r: &str| {
l.receive_payment_with(
inv,
d(2026, 10, day),
Decimal::new(cents, 2),
cash,
&ledger_core::PaymentDetails {
instrument: Some(bank),
reference: Some(r.into()),
},
)
.unwrap()
};
pay(&mut l, 6, 400_000, "tr_8812");
let mut company = Company {
name: "Fernhill Labs LLC".into(),
..Company::default()
};
company.profile.push((
"address".into(),
"12 Orchard Lane, Suite 4\nSpringfield, UT 84000\nhello@fernhill.example".into(),
));
let save = |name: &str, doc: &ledger_import::InvoiceDoc| {
let pdf = ledger_import::invoice_pdf(doc).unwrap();
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(name), pdf).unwrap();
}
};
let today = d(2026, 10, 24);
let part = invoice_document(&l, &company, inv, today, 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();
assert_eq!(paid.stamp, Some(InvoiceStamp::Paid));
assert_eq!(paid.stamp_line.as_deref(), Some("Oct 20, 2026"));
assert_eq!(paid.balance, Decimal::ZERO);
save("sample-paid.pdf", &paid);
let spare = l
.create_invoice(
c,
d(2026, 10, 2),
d(2026, 11, 1),
vec![line("Entered twice", 8_900)],
)
.unwrap();
l.void_document(DocRef::Invoice(spare), d(2026, 10, 2), "entered twice")
.unwrap();
let void = invoice_document(&l, &company, spare, today, today).unwrap();
assert_eq!(void.stamp, Some(InvoiceStamp::Void));
save("sample-void.pdf", &void);
}
}
+2
View File
@@ -25,6 +25,7 @@
mod books;
mod iif;
mod invoice;
mod k1_tax;
mod package;
mod reports;
@@ -34,6 +35,7 @@ mod vendor_1099;
pub use books::PackageBooks;
pub use iif::export_iif;
pub use invoice::invoice_document;
pub use k1_tax::{open_text, tax_basis_csv};
pub use package::{Package, PackageRequest, build_package, package_file_name};
pub use reports::{