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:
1 parent
ce69d9d7bc
commit
400d0cdcab
8 files changed
+706
-169
No files matched your search
@@ -658,7 +658,6 @@ fn pdf_actions(ui: &mut Ui, b: &Books, id: ledger_core::InvoiceId) {
|
|||||||
&b.settings.company,
|
&b.settings.company,
|
||||||
id,
|
id,
|
||||||
b.today,
|
b.today,
|
||||||
b.today,
|
|
||||||
)
|
)
|
||||||
.and_then(|doc| {
|
.and_then(|doc| {
|
||||||
ledger_import::invoice_pdf::invoice_pdf(&doc).map_err(|e| e.to_string())
|
ledger_import::invoice_pdf::invoice_pdf(&doc).map_err(|e| e.to_string())
|
||||||
|
|||||||
@@ -17,6 +17,14 @@
|
|||||||
//! 3. Money is formatted here ([`format_amount`], with "$"); the template
|
//! 3. Money is formatted here ([`format_amount`], with "$"); the template
|
||||||
//! does no arithmetic, so the printed total is the total given.
|
//! does no arithmetic, so the printed total is the total given.
|
||||||
//! 4. Refused: no lines.
|
//! 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:
|
//! SCENARIOS:
|
||||||
//! - An open invoice: total, nothing settled, balance due = total.
|
//! - An open invoice: total, nothing settled, balance due = total.
|
||||||
//! - Part paid: a "Payments received" line, the balance due the rest.
|
//! - Part paid: a "Payments received" line, the balance due the rest.
|
||||||
@@ -26,7 +34,8 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::pdf::PdfError;
|
use crate::pdf::PdfError;
|
||||||
use crate::report::format_amount;
|
use crate::report::amount_parts;
|
||||||
|
use chrono::NaiveDate;
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use std::sync::OnceLock;
|
use std::sync::OnceLock;
|
||||||
use typst::foundations::{Dict, IntoValue};
|
use typst::foundations::{Dict, IntoValue};
|
||||||
@@ -49,6 +58,38 @@ pub struct InvoiceItem {
|
|||||||
pub description: String,
|
pub description: String,
|
||||||
/// Its amount.
|
/// Its amount.
|
||||||
pub amount: Decimal,
|
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.
|
/// A stamp across the invoice, as a rubber stamp would mark it.
|
||||||
@@ -80,7 +121,7 @@ impl InvoiceStamp {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Everything an invoice prints.
|
/// Everything an invoice prints.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct InvoiceDoc {
|
pub struct InvoiceDoc {
|
||||||
/// Its number, as printed ("Invoice 7"; "INV-0007" once numbered).
|
/// Its number, as printed ("Invoice 7"; "INV-0007" once numbered).
|
||||||
pub number: String,
|
pub number: String,
|
||||||
@@ -92,8 +133,25 @@ pub struct InvoiceDoc {
|
|||||||
pub terms: Option<String>,
|
pub terms: Option<String>,
|
||||||
/// The company.
|
/// The company.
|
||||||
pub seller: InvoiceParty,
|
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.
|
/// The customer.
|
||||||
pub bill_to: InvoiceParty,
|
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.
|
/// Its lines.
|
||||||
pub items: Vec<InvoiceItem>,
|
pub items: Vec<InvoiceItem>,
|
||||||
/// The sum of its lines.
|
/// 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
|
/// A line under the stamp's word: for PAID, the day it was paid in
|
||||||
/// full.
|
/// full.
|
||||||
pub stamp_line: Option<String>,
|
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>,
|
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 {
|
fn dollars(d: Decimal) -> String {
|
||||||
let s = format_amount(d);
|
let p = amount_parts(d);
|
||||||
match s.strip_prefix('(').and_then(|s| s.strip_suffix(')')) {
|
if p.negative {
|
||||||
Some(inner) => format!("-${inner}"),
|
format!("-${}", p.digits)
|
||||||
None => format!("${s}"),
|
} 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,
|
"due_date": doc.due_date,
|
||||||
"terms": doc.terms,
|
"terms": doc.terms,
|
||||||
"seller": party(&doc.seller),
|
"seller": party(&doc.seller),
|
||||||
|
"seller_email": doc.seller_email,
|
||||||
|
"seller_website": doc.seller_website,
|
||||||
"bill_to": party(&doc.bill_to),
|
"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!({
|
"lines": doc.items.iter().map(|i| serde_json::json!({
|
||||||
"description": i.description,
|
"description": i.description,
|
||||||
|
"detail": quantity_detail(i),
|
||||||
"amount": dollars(i.amount),
|
"amount": dollars(i.amount),
|
||||||
})).collect::<Vec<_>>(),
|
})).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),
|
"total": dollars(doc.total),
|
||||||
"settled": doc.settled.iter().map(|(label, a)| serde_json::json!({
|
"settled": doc.settled.iter().map(|(label, a)| serde_json::json!({
|
||||||
"label": label,
|
"label": label,
|
||||||
@@ -164,7 +273,7 @@ pub fn template_data(doc: &InvoiceDoc) -> Result<serde_json::Value, PdfError> {
|
|||||||
"stamp": doc.stamp.map(InvoiceStamp::text),
|
"stamp": doc.stamp.map(InvoiceStamp::text),
|
||||||
"stamp_draft": doc.stamp == Some(InvoiceStamp::Draft),
|
"stamp_draft": doc.stamp == Some(InvoiceStamp::Draft),
|
||||||
"stamp_line": doc.stamp_line,
|
"stamp_line": doc.stamp_line,
|
||||||
"note": doc.note,
|
"note": note_text(doc),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,10 +316,14 @@ mod tests {
|
|||||||
InvoiceItem {
|
InvoiceItem {
|
||||||
description: "Consulting, January".into(),
|
description: "Consulting, January".into(),
|
||||||
amount: Decimal::new(120_000, 2),
|
amount: Decimal::new(120_000, 2),
|
||||||
|
quantity: None,
|
||||||
|
unit_price: None,
|
||||||
},
|
},
|
||||||
InvoiceItem {
|
InvoiceItem {
|
||||||
description: "#panic(\"no\") *not bold* $x$".into(),
|
description: "#panic(\"no\") *not bold* $x$".into(),
|
||||||
amount: Decimal::new(3_450, 2),
|
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),
|
total: Decimal::new(123_450, 2),
|
||||||
@@ -219,6 +332,7 @@ mod tests {
|
|||||||
stamp: None,
|
stamp: None,
|
||||||
stamp_line: None,
|
stamp_line: None,
|
||||||
note: None,
|
note: None,
|
||||||
|
..InvoiceDoc::default()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -264,4 +378,83 @@ mod tests {
|
|||||||
d.items.clear();
|
d.items.clear();
|
||||||
assert!(invoice_pdf(&d).is_err());
|
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"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -35,7 +35,10 @@ 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 invoice_pdf::{
|
||||||
|
InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp, InvoiceVersion, PaymentMethodDoc,
|
||||||
|
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};
|
||||||
|
|||||||
@@ -7,9 +7,14 @@
|
|||||||
// markup (a description holding Typst syntax prints literally).
|
// markup (a description holding Typst syntax prints literally).
|
||||||
//
|
//
|
||||||
// Shape: { title: "INVOICE", number, issue_date, due_date, terms: null | str,
|
// Shape: { title: "INVOICE", number, issue_date, due_date, terms: null | str,
|
||||||
// seller: { name, lines: [str] }, bill_to: { name, lines: [str] },
|
// seller: { name, lines: [str] }, seller_email: null | str,
|
||||||
// lines: [{ description, amount }], total,
|
// 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,
|
// 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,
|
// stamp: null | str, stamp_draft: bool, stamp_line: null | str,
|
||||||
// note: null | str }
|
// note: null | str }
|
||||||
|
|
||||||
@@ -41,6 +46,8 @@
|
|||||||
#text(15pt, weight: "bold", d.seller.name) \
|
#text(15pt, weight: "bold", d.seller.name) \
|
||||||
#set text(fill: quiet)
|
#set text(fill: quiet)
|
||||||
#lines(d.seller.lines)
|
#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) \
|
#text(22pt, weight: "bold", tracking: 0.08em, d.title) \
|
||||||
@@ -58,6 +65,12 @@
|
|||||||
#v(2pt)
|
#v(2pt)
|
||||||
#text(weight: "semibold", d.bill_to.name) \
|
#text(weight: "semibold", d.bill_to.name) \
|
||||||
#lines(d.bill_to.lines)
|
#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(
|
grid(
|
||||||
columns: (auto, auto),
|
columns: (auto, auto),
|
||||||
@@ -67,6 +80,7 @@
|
|||||||
text(fill: quiet, "Invoice date"), d.issue_date,
|
text(fill: quiet, "Invoice date"), d.issue_date,
|
||||||
text(fill: quiet, "Due date"), text(weight: "semibold", d.due_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.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"),
|
text(8pt, weight: "semibold", fill: quiet, tracking: 0.06em, "AMOUNT"),
|
||||||
),
|
),
|
||||||
table.hline(stroke: hair),
|
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),
|
table.hline(stroke: firm),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -126,3 +149,36 @@
|
|||||||
v(0.3in)
|
v(0.3in)
|
||||||
text(8.5pt, fill: quiet, d.note)
|
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))
|
||||||
|
}
|
||||||
@@ -5205,3 +5205,120 @@ proptest! {
|
|||||||
prop_assert_eq!(&iif.changes, &want);
|
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}");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1038,13 +1038,8 @@ impl BooksServer {
|
|||||||
}
|
}
|
||||||
(true, ledger_core::DocRef::Invoice(id)) => {
|
(true, ledger_core::DocRef::Invoice(id)) => {
|
||||||
let settings = self.settings();
|
let settings = self.settings();
|
||||||
let printed = ledger_package::invoice_document(
|
let printed =
|
||||||
ledger,
|
ledger_package::invoice_document(ledger, &settings.company, id, as_of)?;
|
||||||
&settings.company,
|
|
||||||
id,
|
|
||||||
as_of,
|
|
||||||
today(),
|
|
||||||
)?;
|
|
||||||
let bytes =
|
let bytes =
|
||||||
ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?;
|
ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?;
|
||||||
Some(self.hand_over(
|
Some(self.hand_over(
|
||||||
|
|||||||
@@ -19,8 +19,15 @@
|
|||||||
//! 4. Stamps: void VOID; paid in full PAID with the day the last
|
//! 4. Stamps: void VOID; paid in full PAID with the day the last
|
||||||
//! payment came in; owing and past due PAST DUE (over PARTIALLY
|
//! payment came in; owing and past due PAST DUE (over PARTIALLY
|
||||||
//! PAID); part paid PARTIALLY PAID; otherwise none.
|
//! PAID); part paid PARTIALLY PAID; otherwise none.
|
||||||
//! 5. Until invoices keep the copy issued (P4b S4), every render says it
|
//! 5. The current copy says it was re-created from the books, and as of
|
||||||
//! was re-created from the books, and on which day.
|
//! 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
|
//! 6. The seller is the company (`company.name`, `company.address`); the
|
||||||
//! customer is its party's display name and address when it has
|
//! customer is its party's display name and address when it has
|
||||||
//! them, else the customer's name.
|
//! them, else the customer's name.
|
||||||
@@ -28,8 +35,10 @@
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::settings::Company;
|
use ledger_core::settings::Company;
|
||||||
use ledger_core::{DocRef, DocStatus, Invoice, InvoiceId, Ledger};
|
use ledger_core::{DocRef, DocStatus, Invoice, InvoiceId, Ledger, PartySnapshot};
|
||||||
use ledger_import::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp};
|
use ledger_import::{
|
||||||
|
InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp, InvoiceVersion, PaymentMethodDoc, labels,
|
||||||
|
};
|
||||||
|
|
||||||
/// A date as an invoice prints it: "Feb 1, 2026".
|
/// A date as an invoice prints it: "Feb 1, 2026".
|
||||||
fn printed(d: NaiveDate) -> String {
|
fn printed(d: NaiveDate) -> String {
|
||||||
@@ -47,13 +56,184 @@ fn address_lines(address: Option<&str>) -> Vec<String> {
|
|||||||
.collect()
|
.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(
|
pub fn invoice_document(
|
||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
company: &Company,
|
company: &Company,
|
||||||
id: InvoiceId,
|
id: InvoiceId,
|
||||||
as_of: NaiveDate,
|
as_of: NaiveDate,
|
||||||
today: NaiveDate,
|
|
||||||
) -> Result<InvoiceDoc, String> {
|
) -> Result<InvoiceDoc, String> {
|
||||||
let inv = ledger
|
let inv = ledger
|
||||||
.get_invoice(id)
|
.get_invoice(id)
|
||||||
@@ -63,27 +243,6 @@ pub fn invoice_document(
|
|||||||
.doc_state(doc, as_of.max(inv.date))
|
.doc_state(doc, as_of.max(inv.date))
|
||||||
.map_err(|e| e.to_string())?
|
.map_err(|e| e.to_string())?
|
||||||
.ok_or_else(|| format!("invoice {id} is not in the ledger"))?;
|
.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
|
// Each payment that stands, oldest first (INVARIANT 3): a payment
|
||||||
// reversed later, and its reversal, print nothing.
|
// reversed later, and its reversal, print nothing.
|
||||||
let mut settled = Vec::new();
|
let mut settled = Vec::new();
|
||||||
@@ -128,46 +287,29 @@ pub fn invoice_document(
|
|||||||
DocStatus::Partial => (Some(InvoiceStamp::PartiallyPaid), None),
|
DocStatus::Partial => (Some(InvoiceStamp::PartiallyPaid), None),
|
||||||
_ => (None, None),
|
_ => (None, None),
|
||||||
};
|
};
|
||||||
Ok(InvoiceDoc {
|
let mut page = page_of(ledger, company, inv)?;
|
||||||
number: format!("Invoice {id}"),
|
page.total = st.total;
|
||||||
issue_date: printed(inv.date),
|
page.settled = settled;
|
||||||
due_date: printed(inv.due_date),
|
page.balance = st.outstanding;
|
||||||
terms: None,
|
page.stamp = stamp;
|
||||||
seller,
|
page.stamp_line = stamp_line;
|
||||||
bill_to,
|
page.version = InvoiceVersion::Current { as_of };
|
||||||
items: inv
|
Ok(page)
|
||||||
.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)
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The copy of an invoice that is being issued (plan P4b S4.4; A-7): the
|
/// 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
|
/// 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
|
/// files with it. No stamp, no re-created note and nothing settled,
|
||||||
/// customer gets. Its number is the one the issue will take (A-2), its
|
/// because this is the copy the customer gets. Its number is the one the
|
||||||
/// seller and bill-to the snapshots the issue will store (A-13) — read
|
/// issue will take (A-2), its seller and bill-to the snapshots the issue
|
||||||
/// from `ledger` when a document made before snapshots has none — and
|
/// will store (A-13) -- read from `ledger` when a document made before
|
||||||
/// nothing is settled on it yet.
|
/// snapshots has none.
|
||||||
pub fn issued_invoice_document(
|
pub fn issued_invoice_document(
|
||||||
ledger: &Ledger,
|
ledger: &Ledger,
|
||||||
company: &Company,
|
company: &Company,
|
||||||
invoice: &Invoice,
|
invoice: &Invoice,
|
||||||
) -> Result<InvoiceDoc, String> {
|
) -> 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
|
/// 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,
|
company: &Company,
|
||||||
invoice: &Invoice,
|
invoice: &Invoice,
|
||||||
) -> Result<InvoiceDoc, String> {
|
) -> Result<InvoiceDoc, String> {
|
||||||
unissued_document(
|
let mut page = page_of(ledger, company, invoice)?;
|
||||||
ledger,
|
page.stamp = Some(InvoiceStamp::Draft);
|
||||||
company,
|
page.note =
|
||||||
invoice,
|
Some("Draft for review: nothing is posted, and this is not the copy sent.".to_string());
|
||||||
Some(InvoiceStamp::Draft),
|
Ok(page)
|
||||||
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,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
@@ -305,10 +368,10 @@ mod tests {
|
|||||||
company
|
company
|
||||||
.profile
|
.profile
|
||||||
.push(("address".into(), "12 Orchard Lane\nSpringfield, UT".into()));
|
.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_eq!(before.balance, Decimal::new(50_000, 2));
|
||||||
assert!(before.settled.is_empty() && before.stamp.is_none());
|
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.balance, Decimal::ZERO);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
after.settled,
|
after.settled,
|
||||||
@@ -319,7 +382,12 @@ mod tests {
|
|||||||
assert_eq!(after.seller.lines, ["12 Orchard Lane", "Springfield, UT"]);
|
assert_eq!(after.seller.lines, ["12 Orchard Lane", "Springfield, UT"]);
|
||||||
assert_eq!(after.bill_to.name, "Harbor Client");
|
assert_eq!(after.bill_to.name, "Harbor Client");
|
||||||
assert_eq!(after.issue_date, "Feb 1, 2026");
|
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());
|
assert!(ledger_import::invoice_pdf(&after).is_ok());
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,13 +484,13 @@ mod tests {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
let today = d(2026, 10, 24);
|
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.stamp, Some(InvoiceStamp::PastDue));
|
||||||
assert_eq!(part.balance, Decimal::new(453_900, 2));
|
assert_eq!(part.balance, Decimal::new(453_900, 2));
|
||||||
assert_eq!(part.settled[0].0, "Payment Oct 6, 2026 · ACH · ref tr_8812");
|
assert_eq!(part.settled[0].0, "Payment Oct 6, 2026 · ACH · ref tr_8812");
|
||||||
save("sample-part-paid.pdf", &part);
|
save("sample-part-paid.pdf", &part);
|
||||||
pay(&mut l, 20, 453_900, "tr_9310");
|
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, Some(InvoiceStamp::Paid));
|
||||||
assert_eq!(paid.stamp_line.as_deref(), Some("Oct 20, 2026"));
|
assert_eq!(paid.stamp_line.as_deref(), Some("Oct 20, 2026"));
|
||||||
assert_eq!(paid.balance, Decimal::ZERO);
|
assert_eq!(paid.balance, Decimal::ZERO);
|
||||||
@@ -437,7 +505,7 @@ mod tests {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
l.void_document(DocRef::Invoice(spare), d(2026, 10, 2), "entered twice")
|
l.void_document(DocRef::Invoice(spare), d(2026, 10, 2), "entered twice")
|
||||||
.unwrap();
|
.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));
|
assert_eq!(void.stamp, Some(InvoiceStamp::Void));
|
||||||
save("sample-void.pdf", &void);
|
save("sample-void.pdf", &void);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
first document, holding lines from before, is outside it (those lines are
|
||||||
listed as unattached).
|
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
|
## B-41 A debt that will not be collected is written off, and a recovery is income
|
||||||
|
|
||||||
**bookspec:** `BS-arap-5`
|
**bookspec:** `BS-arap-5`
|
||||||
|
|||||||
Reference in new issue
Block a user