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:
12 files changed
+843
-4
No files matched your search
@@ -2727,6 +2727,18 @@ fn documents_list_and_timeline_over_the_wire() {
|
|||||||
],
|
],
|
||||||
"{one}"
|
"{one}"
|
||||||
);
|
);
|
||||||
|
// Its PDF, by download (P4b S3): re-created from the books.
|
||||||
|
let with_pdf = m.ok(
|
||||||
|
"books.document",
|
||||||
|
json!({"ref": format!("invoice:{late}"), "as_of": "2026-03-15", "pdf": true}),
|
||||||
|
);
|
||||||
|
let bytes = m.wire.fetch(&with_pdf["pdf"]);
|
||||||
|
assert!(bytes.starts_with(b"%PDF"), "{with_pdf}");
|
||||||
|
assert_eq!(with_pdf["pdf"]["file_name"], format!("invoice-{late}.pdf"));
|
||||||
|
assert!(
|
||||||
|
m.tool("books.document", json!({"ref": "bill:1", "pdf": true}))
|
||||||
|
.refused()
|
||||||
|
);
|
||||||
assert!(
|
assert!(
|
||||||
m.tool("books.document", json!({"ref": "invoice"}))
|
m.tool("books.document", json!({"ref": "invoice"}))
|
||||||
.refused()
|
.refused()
|
||||||
|
|||||||
@@ -599,6 +599,10 @@ pub fn show_doc(ui: &mut Ui, b: &Books, doc: DocRef, shell: &mut Shell<'_>) {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
ui.add_space(8.0);
|
ui.add_space(8.0);
|
||||||
|
if let DocRef::Invoice(id) = doc {
|
||||||
|
pdf_actions(ui, b, id);
|
||||||
|
ui.add_space(4.0);
|
||||||
|
}
|
||||||
if ui
|
if ui
|
||||||
.link(format!(
|
.link(format!(
|
||||||
"{} Open the entry that recorded it (#{entry})",
|
"{} Open the entry that recorded it (#{entry})",
|
||||||
@@ -614,6 +618,51 @@ 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.
|
||||||
|
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));
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
let save = ui
|
||||||
|
.button(format!("{} Save invoice PDF", icon::FILE_PDF))
|
||||||
|
.on_hover_text("As of today, 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,
|
||||||
|
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(result) = ui.data(|d| d.get_temp::<Result<String, String>>(said)) {
|
||||||
|
let (text, color) = match result {
|
||||||
|
Ok(path) => (format!("Saved {path}"), p.muted),
|
||||||
|
Err(why) => (format!("Couldn't save it: {why}"), p.warning),
|
||||||
|
};
|
||||||
|
ui.add(egui::Label::new(egui::RichText::new(text).size(12.0).color(color)).wrap());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn missing(ui: &mut Ui, title: &str) {
|
fn missing(ui: &mut Ui, title: &str) {
|
||||||
widgets::card(ui, |ui| widgets::empty(ui, icon::RECEIPT, title, ""));
|
widgets::card(ui, |ui| widgets::empty(ui, icon::RECEIPT, title, ""));
|
||||||
}
|
}
|
||||||
@@ -175,6 +175,8 @@ fn a_documents_page_shows_its_timeline() {
|
|||||||
);
|
);
|
||||||
// Received: nothing stands.
|
// Received: nothing stands.
|
||||||
assert!(h.query_by_label("$0.00").is_some());
|
assert!(h.query_by_label("$0.00").is_some());
|
||||||
|
// An invoice can be saved as PDF (a bill can't: it is the vendor's).
|
||||||
|
assert!(h.query_by_label_contains("Save invoice PDF").is_some());
|
||||||
// A timeline row opens its entry.
|
// A timeline row opens its entry.
|
||||||
h.get_by_label_contains(&format!("Payment (reversed later), entry #{pay},"))
|
h.get_by_label_contains(&format!("Payment (reversed later), entry #{pay},"))
|
||||||
.click();
|
.click();
|
||||||
|
|||||||
@@ -0,0 +1,267 @@
|
|||||||
|
//! An invoice as PDF (plan P4b.11): what the company billed a customer,
|
||||||
|
//! typeset by `templates/invoice.typ` with the bundled fonts of
|
||||||
|
//! [`crate::pdf`].
|
||||||
|
//!
|
||||||
|
//! ```text
|
||||||
|
//! GOAL: an invoice the customer can pay from, and the books can keep as
|
||||||
|
//! the copy that was sent.
|
||||||
|
//! SOURCE: plan/served.md P4b.11; IRS Pub. 583 (invoices are supporting
|
||||||
|
//! documents); CONVENTION (what a US small-business invoice
|
||||||
|
//! shows: seller, bill-to, number, dates, lines, total, balance
|
||||||
|
//! due).
|
||||||
|
//! INVARIANTS:
|
||||||
|
//! 1. Deterministic: the same document renders to the same bytes (no
|
||||||
|
//! creation time; fonts bundled; typst pinned).
|
||||||
|
//! 2. Text is data: every string reaches Typst as JSON
|
||||||
|
//! (`sys.inputs.data`) and prints literally, Typst syntax included.
|
||||||
|
//! 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.
|
||||||
|
//! SCENARIOS:
|
||||||
|
//! - An open invoice: total, nothing settled, balance due = total.
|
||||||
|
//! - Part paid: a "Payments received" line, the balance due the rest.
|
||||||
|
//! - Paid in full: balance due $0.00 and a PAID stamp with the day; void:
|
||||||
|
//! a VOID stamp; past due: PAST DUE; a preview: DRAFT (gray; the rest
|
||||||
|
//! are red, as a rubber stamp).
|
||||||
|
//! ```
|
||||||
|
|
||||||
|
use crate::pdf::PdfError;
|
||||||
|
use crate::report::format_amount;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::sync::OnceLock;
|
||||||
|
use typst::foundations::{Dict, IntoValue};
|
||||||
|
use typst_as_lib::{TypstEngine, TypstTemplateMainFile};
|
||||||
|
use typst_layout::PagedDocument;
|
||||||
|
|
||||||
|
/// A party on the invoice: its name and the lines under it (an address).
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct InvoiceParty {
|
||||||
|
/// Its name.
|
||||||
|
pub name: String,
|
||||||
|
/// The lines under it, in order.
|
||||||
|
pub lines: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One line of the invoice.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct InvoiceItem {
|
||||||
|
/// What it is for.
|
||||||
|
pub description: String,
|
||||||
|
/// Its amount.
|
||||||
|
pub amount: Decimal,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A stamp across the invoice, as a rubber stamp would mark it.
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||||
|
pub enum InvoiceStamp {
|
||||||
|
/// Paid in full: the invoice is now the customer's receipt.
|
||||||
|
Paid,
|
||||||
|
/// Some paid, a balance still due.
|
||||||
|
PartiallyPaid,
|
||||||
|
/// Past its due date with a balance still due.
|
||||||
|
PastDue,
|
||||||
|
/// Voided: issued in error, cancelled.
|
||||||
|
Void,
|
||||||
|
/// Not issued yet: a preview.
|
||||||
|
Draft,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl InvoiceStamp {
|
||||||
|
/// What the stamp says.
|
||||||
|
pub fn text(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
InvoiceStamp::Paid => "PAID",
|
||||||
|
InvoiceStamp::PartiallyPaid => "PARTIALLY PAID",
|
||||||
|
InvoiceStamp::PastDue => "PAST DUE",
|
||||||
|
InvoiceStamp::Void => "VOID",
|
||||||
|
InvoiceStamp::Draft => "DRAFT",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Everything an invoice prints.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct InvoiceDoc {
|
||||||
|
/// Its number, as printed ("Invoice 7"; "INV-0007" once numbered).
|
||||||
|
pub number: String,
|
||||||
|
/// The issue date, as printed.
|
||||||
|
pub issue_date: String,
|
||||||
|
/// The due date, as printed.
|
||||||
|
pub due_date: String,
|
||||||
|
/// The payment terms, as printed ("Net 30"), when known.
|
||||||
|
pub terms: Option<String>,
|
||||||
|
/// The company.
|
||||||
|
pub seller: InvoiceParty,
|
||||||
|
/// The customer.
|
||||||
|
pub bill_to: InvoiceParty,
|
||||||
|
/// Its lines.
|
||||||
|
pub items: Vec<InvoiceItem>,
|
||||||
|
/// The sum of its lines.
|
||||||
|
pub total: Decimal,
|
||||||
|
/// What was settled against it, each with its label (a payment's
|
||||||
|
/// date, method and reference: "Payment Feb 10, 2026 · ACH · ref
|
||||||
|
/// tr_8812"), positive.
|
||||||
|
pub settled: Vec<(String, Decimal)>,
|
||||||
|
/// What is still due.
|
||||||
|
pub balance: Decimal,
|
||||||
|
/// A stamp, when there is one.
|
||||||
|
pub stamp: Option<InvoiceStamp>,
|
||||||
|
/// 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).
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
static TEMPLATE: &str = include_str!("../templates/invoice.typ");
|
||||||
|
|
||||||
|
fn engine() -> &'static TypstEngine<TypstTemplateMainFile> {
|
||||||
|
static ENGINE: OnceLock<TypstEngine<TypstTemplateMainFile>> = OnceLock::new();
|
||||||
|
ENGINE.get_or_init(|| {
|
||||||
|
TypstEngine::builder()
|
||||||
|
.main_file(TEMPLATE)
|
||||||
|
.fonts(crate::fonts::typst_fonts())
|
||||||
|
.build()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An amount as printed: "$1,234.56".
|
||||||
|
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}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The template's input.
|
||||||
|
pub fn template_data(doc: &InvoiceDoc) -> Result<serde_json::Value, PdfError> {
|
||||||
|
if doc.items.is_empty() {
|
||||||
|
return Err(PdfError::Compile(
|
||||||
|
"an invoice needs at least one line".into(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
let party = |p: &InvoiceParty| serde_json::json!({"name": p.name, "lines": p.lines});
|
||||||
|
Ok(serde_json::json!({
|
||||||
|
"title": "INVOICE",
|
||||||
|
"number": doc.number,
|
||||||
|
"issue_date": doc.issue_date,
|
||||||
|
"due_date": doc.due_date,
|
||||||
|
"terms": doc.terms,
|
||||||
|
"seller": party(&doc.seller),
|
||||||
|
"bill_to": party(&doc.bill_to),
|
||||||
|
"lines": doc.items.iter().map(|i| serde_json::json!({
|
||||||
|
"description": i.description,
|
||||||
|
"amount": dollars(i.amount),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"total": dollars(doc.total),
|
||||||
|
"settled": doc.settled.iter().map(|(label, a)| serde_json::json!({
|
||||||
|
"label": label,
|
||||||
|
"amount": dollars(-*a),
|
||||||
|
})).collect::<Vec<_>>(),
|
||||||
|
"balance_label": "Balance due",
|
||||||
|
"balance": dollars(doc.balance),
|
||||||
|
"stamp": doc.stamp.map(InvoiceStamp::text),
|
||||||
|
"stamp_draft": doc.stamp == Some(InvoiceStamp::Draft),
|
||||||
|
"stamp_line": doc.stamp_line,
|
||||||
|
"note": doc.note,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Typeset an invoice into pages, before PDF encoding (for inspection).
|
||||||
|
pub fn typeset_invoice(doc: &InvoiceDoc) -> Result<PagedDocument, PdfError> {
|
||||||
|
let mut inputs = Dict::new();
|
||||||
|
inputs.insert("data".into(), template_data(doc)?.to_string().into_value());
|
||||||
|
engine()
|
||||||
|
.compile_with_input(inputs)
|
||||||
|
.output
|
||||||
|
.map_err(|e| PdfError::Compile(format!("{e:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An invoice as PDF bytes. See the module documentation for the spec.
|
||||||
|
pub fn invoice_pdf(doc: &InvoiceDoc) -> Result<Vec<u8>, PdfError> {
|
||||||
|
let pages = typeset_invoice(doc)?;
|
||||||
|
typst_pdf::pdf(&pages, &typst_pdf::PdfOptions::default())
|
||||||
|
.map_err(|e| PdfError::Save(format!("{e:?}")))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn doc() -> InvoiceDoc {
|
||||||
|
InvoiceDoc {
|
||||||
|
number: "Invoice 7".into(),
|
||||||
|
issue_date: "Feb 1, 2026".into(),
|
||||||
|
due_date: "Mar 3, 2026".into(),
|
||||||
|
terms: Some("Net 30".into()),
|
||||||
|
seller: InvoiceParty {
|
||||||
|
name: "Fernhill Labs LLC".into(),
|
||||||
|
lines: vec!["12 Orchard Lane".into(), "Springfield, UT 84000".into()],
|
||||||
|
},
|
||||||
|
bill_to: InvoiceParty {
|
||||||
|
name: "Harbor Client".into(),
|
||||||
|
lines: vec!["1 Pier Way".into()],
|
||||||
|
},
|
||||||
|
items: vec![
|
||||||
|
InvoiceItem {
|
||||||
|
description: "Consulting, January".into(),
|
||||||
|
amount: Decimal::new(120_000, 2),
|
||||||
|
},
|
||||||
|
InvoiceItem {
|
||||||
|
description: "#panic(\"no\") *not bold* $x$".into(),
|
||||||
|
amount: Decimal::new(3_450, 2),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: Decimal::new(123_450, 2),
|
||||||
|
settled: vec![("Payments received".into(), Decimal::new(20_000, 2))],
|
||||||
|
balance: Decimal::new(103_450, 2),
|
||||||
|
stamp: None,
|
||||||
|
stamp_line: None,
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_renders_deterministically() {
|
||||||
|
let a = invoice_pdf(&doc()).unwrap();
|
||||||
|
assert!(a.starts_with(b"%PDF"));
|
||||||
|
// SHOT_DIR set: the invoice, 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.pdf"), &a).unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(a, invoice_pdf(&doc()).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn text_is_data_and_money_is_formatted_here() {
|
||||||
|
let data = template_data(&doc()).unwrap();
|
||||||
|
assert_eq!(data["total"], "$1,234.50");
|
||||||
|
assert_eq!(data["balance"], "$1,034.50");
|
||||||
|
assert_eq!(data["settled"][0]["amount"], "-$200.00");
|
||||||
|
// The Typst-looking description renders (it would fail to compile
|
||||||
|
// as markup: #panic stops the document).
|
||||||
|
assert!(typeset_invoice(&doc()).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn stamps_render_and_no_lines_is_refused() {
|
||||||
|
let mut d = doc();
|
||||||
|
for st in [
|
||||||
|
InvoiceStamp::Paid,
|
||||||
|
InvoiceStamp::PartiallyPaid,
|
||||||
|
InvoiceStamp::PastDue,
|
||||||
|
InvoiceStamp::Draft,
|
||||||
|
] {
|
||||||
|
d.stamp = Some(st);
|
||||||
|
d.stamp_line = Some("Feb 20, 2026".into());
|
||||||
|
assert!(invoice_pdf(&d).is_ok(), "{st:?}");
|
||||||
|
}
|
||||||
|
d.stamp = Some(InvoiceStamp::Void);
|
||||||
|
d.note = Some("Re-created from the books; not the copy sent.".into());
|
||||||
|
assert!(invoice_pdf(&d).is_ok());
|
||||||
|
d.items.clear();
|
||||||
|
assert!(invoice_pdf(&d).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
//! - [`pdf`]: generate PDF financial statements (CPA handoff).
|
||||||
//! - [`asset_register`]: the fixed asset register as XLSX and PDF.
|
//! - [`asset_register`]: the fixed asset register as XLSX and PDF.
|
||||||
//! - [`labels`]: print asset label sheets with QR codes.
|
//! - [`labels`]: print asset label sheets with QR codes.
|
||||||
|
//! - [`invoice_pdf`](mod@invoice_pdf): an invoice as PDF.
|
||||||
//! - [`fonts`]: the bundled fonts the PDFs and the viewer share.
|
//! - [`fonts`]: the bundled fonts the PDFs and the viewer share.
|
||||||
//!
|
//!
|
||||||
//! All money stays `Decimal`; no floats in money paths.
|
//! All money stays `Decimal`; no floats in money paths.
|
||||||
@@ -17,6 +18,7 @@ pub mod bank_csv;
|
|||||||
pub mod chart_csv;
|
pub mod chart_csv;
|
||||||
pub mod fonts;
|
pub mod fonts;
|
||||||
pub mod iif;
|
pub mod iif;
|
||||||
|
pub mod invoice_pdf;
|
||||||
pub mod labels;
|
pub mod labels;
|
||||||
pub mod pdf;
|
pub mod pdf;
|
||||||
pub mod report;
|
pub mod report;
|
||||||
@@ -30,6 +32,7 @@ pub use iif::{
|
|||||||
Iif, IifAccount, IifChange, IifClass, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX,
|
Iif, IifAccount, IifChange, IifClass, IifError, IifField, LeftOut, MEMO_MAX, NAME_MAX,
|
||||||
NUMBER_MAX, QbAccountType, iif_accounts, iif_classes, journal_to_iif,
|
NUMBER_MAX, QbAccountType, iif_accounts, iif_classes, journal_to_iif,
|
||||||
};
|
};
|
||||||
|
pub use invoice_pdf::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp, invoice_pdf};
|
||||||
pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf};
|
pub use labels::{AssetLabel, LabelSheet, asset_labels_pdf};
|
||||||
pub use pdf::{PdfError, statement_pdf};
|
pub use pdf::{PdfError, statement_pdf};
|
||||||
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
pub use report::{Member, ReportMeta, Row, RowKind, Statement, YearEarnings, format_amount};
|
||||||
|
|||||||
@@ -0,0 +1,128 @@
|
|||||||
|
// An invoice: what the company billed a customer, one US-letter page or
|
||||||
|
// more.
|
||||||
|
//
|
||||||
|
// Everything arrives as data in sys.inputs.data (JSON), built by
|
||||||
|
// ledger-import::invoice_pdf, every amount already formatted. The template
|
||||||
|
// only typesets: it does no arithmetic, and no text is ever parsed as
|
||||||
|
// 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,
|
||||||
|
// settled: [{ label, amount }], balance_label, balance,
|
||||||
|
// stamp: null | str, stamp_draft: bool, stamp_line: null | str,
|
||||||
|
// note: null | str }
|
||||||
|
|
||||||
|
#let d = json(bytes(sys.inputs.data))
|
||||||
|
#let ink = luma(25)
|
||||||
|
#let quiet = luma(110)
|
||||||
|
#let hair = 0.5pt + luma(170)
|
||||||
|
#let firm = 0.8pt + ink
|
||||||
|
|
||||||
|
#set document(title: d.seller.name + " — " + d.number, author: d.seller.name)
|
||||||
|
#set text(font: ("Inter", "Noto Sans CJK SC", "Noto Sans Arabic UI", "Noto Sans Hebrew", "Noto Sans Thai", "Noto Sans Devanagari"), size: 10pt, fill: ink, number-width: "tabular")
|
||||||
|
#set par(leading: 0.45em, spacing: 0.6em)
|
||||||
|
#set page(
|
||||||
|
paper: "us-letter",
|
||||||
|
margin: (x: 0.8in, top: 0.8in, bottom: 0.9in),
|
||||||
|
footer: context {
|
||||||
|
set text(7.5pt, fill: quiet)
|
||||||
|
[#d.seller.name · #d.number #h(1fr) Page #counter(page).display() of #counter(page).final().first()]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// A block of lines, each set as a string (always literal).
|
||||||
|
#let lines(ls) = for l in ls [#l \ ]
|
||||||
|
|
||||||
|
#grid(
|
||||||
|
columns: (1fr, auto),
|
||||||
|
align: (left + top, right + top),
|
||||||
|
[
|
||||||
|
#text(15pt, weight: "bold", d.seller.name) \
|
||||||
|
#set text(fill: quiet)
|
||||||
|
#lines(d.seller.lines)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
#text(22pt, weight: "bold", tracking: 0.08em, d.title) \
|
||||||
|
#v(2pt)
|
||||||
|
#text(11pt, weight: "semibold", d.number)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
#v(0.35in)
|
||||||
|
#grid(
|
||||||
|
columns: (1fr, auto),
|
||||||
|
column-gutter: 24pt,
|
||||||
|
[
|
||||||
|
#text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "BILL TO") \
|
||||||
|
#v(2pt)
|
||||||
|
#text(weight: "semibold", d.bill_to.name) \
|
||||||
|
#lines(d.bill_to.lines)
|
||||||
|
],
|
||||||
|
grid(
|
||||||
|
columns: (auto, auto),
|
||||||
|
column-gutter: 14pt,
|
||||||
|
row-gutter: 6pt,
|
||||||
|
align: (right, left),
|
||||||
|
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 { () },
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
#v(0.3in)
|
||||||
|
#table(
|
||||||
|
columns: (1fr, auto),
|
||||||
|
stroke: none,
|
||||||
|
inset: (x: 0pt, y: 7pt),
|
||||||
|
align: (left, right),
|
||||||
|
table.hline(stroke: firm),
|
||||||
|
table.header(
|
||||||
|
text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "DESCRIPTION"),
|
||||||
|
text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "AMOUNT"),
|
||||||
|
),
|
||||||
|
table.hline(stroke: hair),
|
||||||
|
..d.lines.map(l => (l.description, l.amount)).flatten(),
|
||||||
|
table.hline(stroke: firm),
|
||||||
|
)
|
||||||
|
|
||||||
|
// The stamp, if any, sits in the space left of the totals, as a rubber
|
||||||
|
// stamp would.
|
||||||
|
#let stamp = if d.stamp == none { none } else {
|
||||||
|
let ink-stamp = if d.stamp_draft { luma(130) } else { rgb("#c62828") }
|
||||||
|
rotate(-10deg, reflow: true, box(
|
||||||
|
stroke: 2.5pt + ink-stamp,
|
||||||
|
inset: (x: 12pt, y: 6pt),
|
||||||
|
radius: 4pt,
|
||||||
|
align(center, {
|
||||||
|
text(24pt, weight: "bold", tracking: 0.1em, fill: ink-stamp, d.stamp)
|
||||||
|
if d.stamp_line != none {
|
||||||
|
linebreak()
|
||||||
|
text(9pt, weight: "semibold", fill: ink-stamp, d.stamp_line)
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
#v(6pt)
|
||||||
|
#grid(
|
||||||
|
columns: (1fr, auto),
|
||||||
|
column-gutter: 16pt,
|
||||||
|
align: (center + horizon, right + top),
|
||||||
|
stamp,
|
||||||
|
grid(
|
||||||
|
columns: (auto, 1.2in),
|
||||||
|
column-gutter: 16pt,
|
||||||
|
row-gutter: 8pt,
|
||||||
|
align: (right, right),
|
||||||
|
text(fill: quiet, "Total"), d.total,
|
||||||
|
..d.settled.map(s => (text(fill: quiet, s.label), s.amount)).flatten(),
|
||||||
|
grid.hline(stroke: hair),
|
||||||
|
text(weight: "bold", d.balance_label), text(12pt, weight: "bold", d.balance),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
#if d.note != none {
|
||||||
|
v(0.3in)
|
||||||
|
text(8.5pt, fill: quiet, d.note)
|
||||||
|
}
|
||||||
@@ -8826,7 +8826,7 @@ impl BooksServer {
|
|||||||
name = "books.document",
|
name = "books.document",
|
||||||
title = "An invoice or bill",
|
title = "An invoice or bill",
|
||||||
description = described(
|
description = described(
|
||||||
"One invoice or bill (ref \"invoice:7\" or \"bill:12\", from books.documents): party, dates, lines with their projects, total, what was paid, discounted and written off, what it still owes and its status, all as of a day; and its timeline: every ledger line tagged with it in posting order (its own entry, each payment, each reversal), each saying whether its entry still stands. A reversed payment shows twice: the payment (no longer standing) and its reversal (standing), which net to nothing.",
|
"One invoice or bill (ref \"invoice:7\" or \"bill:12\", from books.documents) as of a day: party, dates, lines with projects, total, paid, discounted, written off, still owed, status; and its timeline, every ledger line tagged with it in posting order, each saying whether its entry still stands (a reversed payment shows as the payment, not standing, and its reversal, standing: they net to nothing). pdf: true also hands over an invoice's PDF to download, re-created from the books.",
|
||||||
ledger_core::calendar::PeriodKind::AsOf
|
ledger_core::calendar::PeriodKind::AsOf
|
||||||
),
|
),
|
||||||
annotations(read_only_hint = true, open_world_hint = false)
|
annotations(read_only_hint = true, open_world_hint = false)
|
||||||
@@ -8866,6 +8866,33 @@ impl BooksServer {
|
|||||||
.doc_state(doc, as_of)
|
.doc_state(doc, as_of)
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.ok_or_else(|| format!("{} is dated after {as_of}", doc_ref_text(doc)))?;
|
.ok_or_else(|| format!("{} is dated after {as_of}", doc_ref_text(doc)))?;
|
||||||
|
let pdf = match (p.pdf, doc) {
|
||||||
|
(false, _) => None,
|
||||||
|
(true, ledger_core::DocRef::Bill(_)) => {
|
||||||
|
return Err(
|
||||||
|
"a bill is the vendor's document: its PDF is the file filed with it (books.list_receipts)"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
(true, ledger_core::DocRef::Invoice(id)) => {
|
||||||
|
let settings = self.settings();
|
||||||
|
let printed = ledger_package::invoice_document(
|
||||||
|
ledger,
|
||||||
|
&settings.company,
|
||||||
|
id,
|
||||||
|
as_of,
|
||||||
|
today(),
|
||||||
|
)?;
|
||||||
|
let bytes =
|
||||||
|
ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?;
|
||||||
|
Some(self.hand_over(
|
||||||
|
&book_name(&store),
|
||||||
|
&format!("invoice-{id}.pdf"),
|
||||||
|
"application/pdf",
|
||||||
|
bytes,
|
||||||
|
)?)
|
||||||
|
}
|
||||||
|
};
|
||||||
let timeline = ledger
|
let timeline = ledger
|
||||||
.doc_lines(doc)
|
.doc_lines(doc)
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -8904,6 +8931,7 @@ impl BooksServer {
|
|||||||
status: st.status.key().to_string(),
|
status: st.status.key().to_string(),
|
||||||
overdue_days: st.overdue_days,
|
overdue_days: st.overdue_days,
|
||||||
timeline,
|
timeline,
|
||||||
|
pdf,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3195,6 +3195,10 @@ pub struct DocumentParams {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
#[schemars(description = AS_OF_DOC)]
|
#[schemars(description = AS_OF_DOC)]
|
||||||
pub as_of: Option<String>,
|
pub as_of: Option<String>,
|
||||||
|
/// For an invoice: also hand over its PDF (as of the day, re-created
|
||||||
|
/// from the books) to download.
|
||||||
|
#[serde(default)]
|
||||||
|
pub pdf: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One line of a document.
|
/// One line of a document.
|
||||||
@@ -3259,6 +3263,9 @@ pub struct DocumentOut {
|
|||||||
pub overdue_days: i64,
|
pub overdue_days: i64,
|
||||||
/// Every ledger line tagged with it, in posting order.
|
/// Every ledger line tagged with it, in posting order.
|
||||||
pub timeline: Vec<DocumentEventOut>,
|
pub timeline: Vec<DocumentEventOut>,
|
||||||
|
/// The invoice's PDF, handed over (with `pdf`): run its download.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pdf: Option<HandedFile>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for `books.void_document`.
|
/// Parameters for `books.void_document`.
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -25,6 +25,7 @@
|
|||||||
|
|
||||||
mod books;
|
mod books;
|
||||||
mod iif;
|
mod iif;
|
||||||
|
mod invoice;
|
||||||
mod k1_tax;
|
mod k1_tax;
|
||||||
mod package;
|
mod package;
|
||||||
mod reports;
|
mod reports;
|
||||||
@@ -34,6 +35,7 @@ mod vendor_1099;
|
|||||||
|
|
||||||
pub use books::PackageBooks;
|
pub use books::PackageBooks;
|
||||||
pub use iif::export_iif;
|
pub use iif::export_iif;
|
||||||
|
pub use invoice::invoice_document;
|
||||||
pub use k1_tax::{open_text, tax_basis_csv};
|
pub use k1_tax::{open_text, tax_basis_csv};
|
||||||
pub use package::{Package, PackageRequest, build_package, package_file_name};
|
pub use package::{Package, PackageRequest, build_package, package_file_name};
|
||||||
pub use reports::{
|
pub use reports::{
|
||||||
|
|||||||
@@ -2027,13 +2027,17 @@ accounting only: the caller supplies every figure), tax filing.
|
|||||||
anything still owed), `overdue`, `paid`, `void`, `written_off`,
|
anything still owed), `overdue`, `paid`, `void`, `written_off`,
|
||||||
`overpaid` or `all`; `party` (a customer's or vendor's name or id) and
|
`overpaid` or `all`; `party` (a customer's or vendor's name or id) and
|
||||||
`due_by` narrow the list (P4b S2).
|
`due_by` narrow the list (P4b S2).
|
||||||
- `books.document { ref, as_of? }` → `{ ref, party, date, due_date,
|
- `books.document { ref, as_of?, pdf? }` → `{ ref, party, date, due_date,
|
||||||
entry_id, lines: [{ description, amount, project? }], total, paid,
|
entry_id, lines: [{ description, amount, project? }], total, paid,
|
||||||
discounted, written_off, outstanding, status, overdue_days, timeline:
|
discounted, written_off, outstanding, status, overdue_days, timeline:
|
||||||
[{ date, entry_id, kind, amount, reversal, stands }] }`. `ref` is
|
[{ date, entry_id, kind, amount, reversal, stands }] }`. `ref` is
|
||||||
`invoice:N` or `bill:N`; the timeline is every ledger line tagged with
|
`invoice:N` or `bill:N`; the timeline is every ledger line tagged with
|
||||||
the document in posting order, each saying whether its entry still
|
the document in posting order, each saying whether its entry still
|
||||||
stands as of the day (P4b S2).
|
stands as of the day (P4b S2). With `pdf: true` an invoice's PDF comes
|
||||||
|
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.
|
||||||
- `books.control_check { as_of? }` → `{ as_of, agrees, accounts: [{ role,
|
- `books.control_check { as_of? }` → `{ as_of, agrees, accounts: [{ role,
|
||||||
code, name, balance, documents_total, unattached: [{ entry_id, line,
|
code, name, balance, documents_total, unattached: [{ entry_id, line,
|
||||||
date, amount }], difference, attention: [{ kind, id, status, total,
|
date, amount }], difference, attention: [{ kind, id, status, total,
|
||||||
|
|||||||
+9
-1
@@ -235,7 +235,15 @@ tie-out in `books.verify` and `control_problems` in `books.digest`; the
|
|||||||
over-the-wire test `documents_void_and_tie_out_over_the_wire`. Still for
|
over-the-wire test `documents_void_and_tie_out_over_the_wire`. Still for
|
||||||
later slices: the processor fee on a payment (S6, with multi-document
|
later slices: the processor fee on a payment (S6, with multi-document
|
||||||
payments and discounts), Needs attention in the viewer for the control
|
payments and discounts), Needs attention in the viewer for the control
|
||||||
check, and S2 to S7.
|
check, and S2 to S7. S2 (2026-10-24): `books.documents`,
|
||||||
|
`books.document`, and the viewer's Invoices & Bills (Money in, Money out,
|
||||||
|
a document's page, overview tiles). S3 (2026-10-24): the invoice PDF
|
||||||
|
(`ledger-import` `invoice_pdf`, `templates/invoice.typ`; `ledger-package`
|
||||||
|
`invoice_document`): re-created from the books, each payment on its own
|
||||||
|
line with its date, method and reference, stamped PAID with the day,
|
||||||
|
PARTIALLY PAID, PAST DUE or VOID (DRAFT, gray, for S4's preview); by
|
||||||
|
download from `books.document pdf`, and Save invoice PDF in the viewer.
|
||||||
|
The copy issued is kept from S4 on and never changes.
|
||||||
**Still open after P3 (each for the phase named, or the lead):**
|
**Still open after P3 (each for the phase named, or the lead):**
|
||||||
- Invoices and bills take a project now (above), behind the new journal
|
- Invoices and bills take a project now (above), behind the new journal
|
||||||
feature `document_line_projects`: the first company's books declared
|
feature `document_line_projects`: the first company's books declared
|
||||||
|
|||||||
Reference in new issue
Block a user