feat(mcp): the create tools preview and confirm, and the party keeps its contact block (P4b S4.4)

An invoice is issued, not edited. Without `confirmed` the create tools now
preview: the posting (with the accounts' codes, which the live chart may
not hold yet), the number it will take, its due date, terms and early
discount, warnings, and for an invoice the DRAFT copy of the PDF, handed
over as a file. Nothing is written and no number is used up. With
`confirmed` the invoice posts, takes the number the preview showed, and
files the issued copy with itself in the same call: `receipt.attached` on
its entry plus the link to the invoice, its SHA-256 in the event (A-1,
A-7, B-40). The copy the customer received is therefore the copy in the
books, and `books.document`'s re-created page can never be mistaken for
it. The result also carries a `mailto` link for a human to send it; the
books never send (A-4).

A bill keeps what the plan says a bill is: the vendor's own invoice
number, the vendor's invoice filed on its entry as its evidence, and, in
books that have declared `documents_v2`, an account on every line, so no
bill can land in General Expenses by default any more (A-9, A-10, A-13).
A vendor number entered twice is refused unless the caller says why, and
the reason is journaled with the second bill (B-40 item 4). Books that
have not declared the feature keep recording bills exactly as before, so
the owner's books keep working between units (D-39, 8.0.4).

Terms are resolved in one place and in one order: the due date given,
else the terms given, else the customer's or vendor's own default, else
the books' `invoicing.default_terms`; a document given only a due date
stores no terms, so an old-shape invoice is still written byte for byte
as before (B-39, A-13).

D-55: a party carries the owner's full contact block (phone, contact
person, billing and shipping addresses, billing email). It rides the same
`party.updated` event behind `documents_v2`, and an invoice's bill-to is
snapshotted at issue with the billing address and billing email in place
of the general ones when the party has them — `PartySnapshot::from_party`
is the one place that rule lives, so a later edit cannot change what was
sent, and the next invoice takes today's details.

Tests: the wire tests are the new `crates/ledger-cli/tests/documents.rs`
(preview writes nothing, the DRAFT copy, the issued copy's SHA-256 equals
`books.get_receipt`'s, numbers run, a bill's file and terms, the duplicate
refusal, the line-account rules, the terms fallback, the `party_id` alias
and the ambiguity it refuses, the contact block's set and clear, and the
bill-to at issue); `format_upgrade.rs` gains
`documents_v2_refused_until_consent` and the cut-over test now refuses and
then makes a document carrying a new field (8.0.6 item 3); the store
tests are `the_contact_block_round_trips_and_replays` and
`an_invoice_keeps_the_billing_details_it_was_issued_with`, and
`books_written_under_older_rules_still_open` gains a hand-written
contact-block event (8.0.6 item 1).

Merge does not need the owner's go-ahead: the live books refuse a write
that needs `documents_v2` until he gives it, and take every other write as
before (8.0.6 item 6). No figure moved: S4 moves none.
This commit is contained in:
swaits committed 2026-10-27 12:23:32 -06:00
1 parent ed637f0e27
commit d5b9e5cf99
28 files changed
+2604 -190

No files matched your search

+9 -5
View File
@@ -160,9 +160,9 @@ pub const TABLE: [(&str, Needs); 122] = [
("books.parties", R),
("books.party", R),
("books.set_entry_party", W),
// These post at once, unchecked.
("books.create_invoice", P),
("books.create_bill", P),
// These preview, then post when confirmed (P4b S4.4, A-1).
("books.create_invoice", C),
("books.create_bill", C),
("books.receive_payment", P),
("books.pay_bill", P),
("books.void_document", P),
@@ -371,7 +371,7 @@ mod tests {
);
assert_eq!(
needs(
"books.create_invoice",
"books.receive_payment",
args(json!({"confirmed": false})).as_ref()
),
Some(Scope::Post),
@@ -421,7 +421,11 @@ mod tests {
assert!(!shown("books.access_log", &read));
let write = set(&[Scope::Read, Scope::Write]);
assert!(shown("books.post_entry", &write), "it may preview");
assert!(!shown("books.create_invoice", &write));
assert!(
shown("books.create_invoice", &write),
"it may preview an invoice"
);
assert!(!shown("books.receive_payment", &write));
let post = set(&[Scope::Post]);
assert!(shown("books.post_entry", &post));
assert!(!shown("books.chart", &post));
+677 -38
View File
@@ -6,6 +6,8 @@
//! (`docs/mcp-interface.md`; rules.md B-38).
use super::*;
use ledger_core::Terms;
use ledger_core::arap::{self, PartySnapshot};
/// A payment's method and reference from its parameters (the method by
/// id or label, books.instruments).
@@ -78,8 +80,9 @@ fn doc_label(doc: ledger_core::DocRef) -> String {
}
/// An invoice's or bill's lines as the tool takes them: each amount
/// parsed and each project resolved ([`resolve_class`]) before anything is
/// written.
/// parsed, each project and account resolved ([`resolve_class`],
/// [`resolve_account`]), and quantity and unit price together, before
/// anything is written (plan P4b S4.4).
fn document_lines(
store: &Store,
lines: Vec<DocumentLineParam>,
@@ -87,6 +90,14 @@ fn document_lines(
lines
.into_iter()
.map(|l| {
let quantity = l.quantity.as_deref().map(parse_amount).transpose()?;
let unit_price = l.unit_price.as_deref().map(parse_amount).transpose()?;
if quantity.is_some() != unit_price.is_some() {
return Err(format!(
"line {:?}: quantity and unit_price go together, both or neither",
l.description
));
}
Ok(DocumentLine {
description: l.description,
amount: parse_amount(&l.amount)?,
@@ -95,9 +106,13 @@ fn document_lines(
.as_deref()
.map(|c| resolve_class(store, c))
.transpose()?,
account: None,
quantity: None,
unit_price: None,
account: l
.account
.as_deref()
.map(|a| resolve_account(store, a))
.transpose()?,
quantity,
unit_price,
})
})
.collect()
@@ -128,12 +143,349 @@ fn document_notes(
notes
}
/// Resolve a customer the way [`resolve_vendor`] resolves a vendor: a
/// customer id, one of its party's ids, or its name (ignoring case and
/// surrounding space); a number that is one customer's id and another's
/// name is refused, never guessed (plan P4b S4.4).
fn resolve_customer(store: &Store, customer: &str) -> Result<u64, String> {
let ledger = store.ledger();
let by_id = customer.trim().parse::<u64>().ok().and_then(|id| {
ledger.get_customer(id).map(|_| id).or_else(|| {
ledger
.get_party(id)
.and_then(|p| p.customer)
.filter(|c| ledger.get_customer(*c).is_some())
})
});
let matches: Vec<u64> = ledger
.list_customers()
.into_iter()
.filter(|c| c.name.eq_ignore_ascii_case(customer.trim()))
.map(|c| c.id)
.collect();
if let Some(id) = by_id {
return match matches.as_slice() {
[] => Ok(id),
[m] if *m == id => Ok(id),
_ => Err(format!(
"customer {customer:?} is ambiguous: it is customer {id}'s id and another customer's name; pass the id"
)),
};
}
match matches.as_slice() {
[id] => Ok(*id),
[] => Err(format!(
"unknown customer {customer:?}: add it with books.add_customer"
)),
_ => Err(format!(
"several customers are named {customer:?}: pass the id"
)),
}
}
/// Resolve a vendor the way [`resolve_customer`] resolves a customer: a
/// vendor id, one of its party's ids, or its name (plan P4b S4.4).
fn resolve_vendor_arg(store: &Store, vendor: &str) -> Result<u64, String> {
if let Ok(id) = vendor.trim().parse::<u64>() {
let ledger = store.ledger();
if ledger.get_vendor(id).is_none()
&& let Some(v) = ledger.get_party(id).and_then(|p| p.vendor)
&& ledger.get_vendor(v).is_some()
{
return Ok(v);
}
}
resolve_vendor(store, vendor)
}
/// Whether these books declared the journal feature `documents_v2`: the
/// bills and invoices carrying a number, terms, snapshots, references, a
/// line's account or a quantity need it (rules.md B-27, D-52), and the
/// bill rules A-10, A-13 and the file apply only where it is declared.
fn documents_v2(store: &Store) -> bool {
store
.journal_format()
.has_feature(ledger_store::format::DOCUMENTS_V2)
}
/// The customer's or vendor's name to give a snapshot: its display name,
/// else its record's name.
fn party_label(store: &Store, customer: Option<u64>, vendor: Option<u64>) -> String {
let ledger = store.ledger();
if let Some(c) = customer {
if let Some(p) = ledger
.party_of_customer(c)
.and_then(|p| ledger.get_party(p))
{
return p.display_name.clone().unwrap_or_else(|| p.name.clone());
}
return ledger
.get_customer(c)
.map(|c| c.name.clone())
.unwrap_or_default();
}
if let Some(v) = vendor {
if let Some(p) = ledger.party_of_vendor(v).and_then(|p| ledger.get_party(p)) {
return p.display_name.clone().unwrap_or_else(|| p.name.clone());
}
return ledger
.get_vendor(v)
.map(|v| v.name.clone())
.unwrap_or_default();
}
String::new()
}
/// Us as the document prints us (A-13, 8.3.4): the company's name, address
/// and email from its settings.
fn seller_snapshot(settings: &Settings) -> PartySnapshot {
let profile = |key: &str| {
settings
.company
.profile
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
.filter(|v| !v.trim().is_empty())
};
PartySnapshot {
name: settings.company.name.clone(),
address: profile("address"),
email: profile("email"),
// The contact block is the party's (D-55): the company has none
// yet (its own details are settings the page prints separately).
phone: None,
contact: None,
billing_address: None,
shipping_address: None,
billing_email: None,
}
}
/// The customer's snapshot at issue: what the caller gives, each part
/// omitted the party record's (A-13, D-55). The billing address and
/// billing email, when there are any, are what the page prints (D-55);
/// [`PartySnapshot::from_party`] is where that rule lives.
fn customer_snapshot(
store: &Store,
customer: u64,
given: Option<BillToParam>,
) -> Result<PartySnapshot, String> {
let ledger = store.ledger();
let party = ledger
.party_of_customer(customer)
.and_then(|p| ledger.get_party(p));
let mut snapshot = match party {
Some(p) => PartySnapshot::from_party(p),
None => PartySnapshot {
name: party_label(store, Some(customer), None),
address: None,
email: None,
phone: None,
contact: None,
billing_address: None,
shipping_address: None,
billing_email: None,
},
};
if let Some(g) = given {
let text = |v: String| {
let t = v.trim().to_string();
Some(t).filter(|t| !t.is_empty())
};
if let Some(v) = g.name {
snapshot.name = v.trim().to_string();
}
if let Some(v) = g.address {
snapshot.address = text(v);
}
if let Some(v) = g.email {
snapshot.email = text(v);
}
if let Some(v) = g.phone {
snapshot.phone = text(v);
}
if let Some(v) = g.contact {
snapshot.contact = text(v);
}
if let Some(v) = g.shipping_address {
snapshot.shipping_address = text(v);
}
if let Some(v) = g.billing_address {
snapshot.billing_address = text(v);
snapshot.address = snapshot.billing_address.clone();
}
if let Some(v) = g.billing_email {
snapshot.billing_email = text(v);
snapshot.email = snapshot.billing_email.clone();
}
}
ledger_core::parties::check_text("the bill-to name", &snapshot.name)?;
if let Some(a) = &snapshot.address {
ledger_core::parties::check_text("the bill-to address", a)?;
}
for (field, value) in [
("the bill-to contact", &snapshot.contact),
("the bill-to phone", &snapshot.phone),
("the billing address", &snapshot.billing_address),
("the shipping address", &snapshot.shipping_address),
] {
if let Some(v) = value {
ledger_core::parties::check_text(field, v)?;
}
}
for (field, value) in [
("the bill-to email", &snapshot.email),
("the billing email", &snapshot.billing_email),
] {
if let Some(v) = value {
ledger_core::parties::check_email(field, v)?;
}
}
Ok(snapshot)
}
/// The terms an invoice takes when neither it nor the customer gives any:
/// `invoicing.default_terms`, else its default "Net 30" (plan P4b S4.2,
/// S4.4). None in books that haven't declared `documents_v2`: their
/// invoices keep the legacy shape, and a due date is given instead.
fn books_default_terms(store: &Store) -> Result<Option<Terms>, String> {
if !documents_v2(store) {
return Ok(None);
}
let text = store
.ledger()
.settings()
.get("invoicing.default_terms")
.and_then(|r| r.value.as_str().map(str::to_string))
.unwrap_or_else(|| "Net 30".to_string());
Ok(Some(Terms::parse(&text).map_err(|e| e.to_string())?))
}
/// A party's default terms on one side (A-13).
fn party_default_terms(
store: &Store,
party: Option<ledger_core::PartyId>,
customer: bool,
) -> Option<Terms> {
let p = store.ledger().get_party(party?)?;
if customer {
p.default_terms_as_customer.clone()
} else {
p.default_terms_as_vendor.clone()
}
}
/// The due date a document takes and the terms to store with it: the due
/// date given; else the terms given; else the party's default terms; else
/// the books' own default (invoice only); else refused, naming the ways
/// to give one (plan P4b S4.4 item 14).
///
/// Terms are stored only when they came from the caller or a default: a
/// document given only a due date keeps the legacy shape (`terms: None`)
/// and so is never refused for a new field on books that haven't declared
/// `documents_v2`.
fn due_and_terms(
date: NaiveDate,
due_date: Option<&str>,
terms: Option<&str>,
defaults: Vec<Option<Terms>>,
what: &str,
ways: &str,
) -> Result<(NaiveDate, Option<Terms>), String> {
let given = terms
.map(Terms::parse)
.transpose()
.map_err(|e| e.to_string())?;
if let Some(d) = due_date {
return Ok((parse_date(d)?, given));
}
if let Some(t) = given {
let due = t.due_date(date).map_err(|e| e.to_string())?;
return Ok((due, Some(t)));
}
if let Some(t) = defaults.into_iter().flatten().next() {
let due = t.due_date(date).map_err(|e| e.to_string())?;
return Ok((due, Some(t)));
}
Err(format!("give {what} a due date: {ways}"))
}
/// A posting line as the preview shows it, with its project's name.
fn posting_out(ledger: &Ledger, lines: &[ledger_store::PreviewPosting]) -> Vec<DocumentPostingOut> {
lines
.iter()
.map(|l| DocumentPostingOut {
account: l.account.clone(),
name: l.name.clone(),
side: match l.side {
Side::Debit => "debit".to_string(),
Side::Credit => "credit".to_string(),
},
amount: money(l.amount),
class: l
.class_id
.and_then(|c| ledger.get_class(c))
.map(|c| c.name.clone()),
})
.collect()
}
/// A word of `text` as a URL carries it: unreserved ASCII as it is, every
/// other byte `%XX` (UTF-8).
fn url_encode(text: &str) -> String {
let mut out = String::new();
for b in text.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
out.push(b as char)
}
_ => out.push_str(&format!("%{b:02X}")),
}
}
out
}
/// The `mailto:` convenience link for a human to send an invoice from
/// their own mail program (A-4; the books never send): "Invoice INV-0007
/// from {company}" as the subject, a short body naming the file to
/// attach, the body URL-encoded and cut to 1,800 bytes at the last whole
/// word (plan P4b S4.4 item 14).
fn mailto_link(settings: &Settings, invoice: &arap::Invoice) -> String {
let number = invoice
.number
.clone()
.unwrap_or_else(|| format!("Invoice {}", invoice.id));
let to = invoice
.bill_to
.as_ref()
.and_then(|b| b.email.clone())
.unwrap_or_default();
let subject = url_encode(&format!("Invoice {number} from {}", settings.company.name));
let body = format!(
"Hello,\n\nInvoice {number} for {} is due {}. It is attached as {number}.pdf.\n\nThank you,\n{}",
money(invoice.total().unwrap_or_default()),
invoice.due_date,
settings.company.name
);
let mut encoded = url_encode(&body);
const LIMIT: usize = 1_800;
if encoded.len() > LIMIT {
// The last whole word that fits: everything before a "%20".
match encoded[..LIMIT].rfind("%20") {
Some(at) => encoded.truncate(at),
None => encoded.truncate(LIMIT),
}
}
format!("mailto:{to}?subject={subject}&body={encoded}")
}
#[tool_router(router = documents_router, vis = "pub(super)")]
impl BooksServer {
#[tool(
name = "books.create_invoice",
title = "Create and post an invoice",
description = "Create a sales invoice. Auto-posts Dr Accounts Receivable / Cr Revenue. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's revenue: one revenue line per project, the untagged lines' as one; the receivable line is never tagged. Its result gains notes for a finished, abandoned or on-spec project (on spec: is there now an agreement?), tagged anyway.",
description = "Create a sales invoice: without confirmed it is a preview (the posting, the number and due date it will take, its DRAFT PDF handed over) and nothing is written; with confirmed=true it posts Dr Accounts Receivable / Cr each line's account, numbers it, files the issued PDF with it, and returns the attachment and its sha256. Lines are {description, amount, quantity?, unit_price?, account?, class?}. Due date: from terms (\"Net 30\", \"2/10 net 30\") or given; else the customer's default, else invoicing.default_terms. customer is an id or a name. mailto is a link for a human to send it; the books never send (A-4).",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -142,37 +494,143 @@ impl BooksServer {
)]
async fn create_invoice(
&self,
params: Parameters<CreateDocumentParams>,
) -> Result<Json<CreateDocumentOut>, String> {
params: Parameters<CreateInvoiceParams>,
) -> Result<Json<InvoiceOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let customer = match (&p.customer, p.party_id) {
(Some(c), None) => c.trim().to_string(),
(None, Some(id)) => id.to_string(),
(Some(c), Some(id)) if c.trim() == id.to_string() => c.trim().to_string(),
(Some(_), Some(_)) => {
return Err("give the customer once: customer or party_id, not both".into());
}
(None, None) => {
return Err("give customer: a customer id, a party id or a name".into());
}
};
let customer_id = resolve_customer(&store, &customer)?;
let date = parse_date(&p.date)?;
let lines = document_lines(&store, p.lines)?;
let first_new = store.ledger().next_account_id();
let id = store
.create_invoice(
p.party_id,
parse_date(&p.date)?,
parse_date(&p.due_date)?,
lines,
)
let v2 = documents_v2(&store);
let party = store.ledger().party_of_customer(customer_id);
let (due_date, terms) = due_and_terms(
date,
p.due_date.as_deref(),
p.terms.as_deref(),
vec![
party_default_terms(&store, party, true),
books_default_terms(&store)?,
],
"the invoice",
"due_date, or terms, or the customer's default terms, or invoicing.default_terms",
)?;
if let Some(r) = &p.customer_ref {
ledger_core::parties::check_text("the customer reference", r)?;
}
if let Some(m) = &p.memo {
ledger_core::parties::check_text("the memo", m)?;
}
let settings = self.settings();
let fields = arap::InvoiceFields {
number: None,
terms,
bill_to: if v2 {
Some(customer_snapshot(&store, customer_id, p.bill_to)?)
} else {
None
},
seller: if v2 {
Some(seller_snapshot(&settings))
} else {
None
},
customer_ref: p.customer_ref.clone(),
memo: p.memo.clone(),
};
let preview = store
.preview_invoice(customer_id, date, due_date, lines.clone(), fields.clone())
.map_err(|e| e.to_string())?;
let inv = store
let terms_text = preview
.invoice
.terms
.as_ref()
.map(ledger_core::Terms::describe);
if !p.confirmed.unwrap_or(false) {
let printed = ledger_package::draft_invoice_document(
store.ledger(),
&settings.company,
&preview.invoice,
)?;
let bytes =
ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?;
let file_name = format!(
"{}-draft.pdf",
preview.number.as_deref().unwrap_or("invoice")
);
let pdf = self.hand_over(&book_name(&store), &file_name, "application/pdf", bytes)?;
return Ok(Json(InvoiceOut {
preview: Some(true),
id: None,
number: preview.number,
total: money(preview.total),
due_date: preview.due_date.to_string(),
terms: terms_text,
entry_id: None,
lines: posting_out(store.ledger(), &preview.lines),
pdf: Some(DocumentPdfOut::Draft(pdf)),
mailto: None,
warnings: Vec::new(),
notes: Vec::new(),
}));
}
// The number the preview showed is the one it issues (A-1: an
// abandoned preview uses up no number).
let fields = arap::InvoiceFields {
number: preview.number.clone(),
..fields
};
// Rendered before anything is journaled: a Typst failure leaves
// the books as they were (8.3.11).
let printed = ledger_package::issued_invoice_document(
store.ledger(),
&settings.company,
&preview.invoice,
)?;
let bytes = ledger_import::invoice_pdf::invoice_pdf(&printed).map_err(|e| e.to_string())?;
let sha256 = ledger_store::journal::sha256_hex(&bytes);
let first_new = store.ledger().next_account_id();
let (id, entry_id, attachment_id) = store
.create_invoice_issued(customer_id, date, due_date, lines.clone(), fields, bytes)
.map_err(|e| e.to_string())?;
let invoice = store
.ledger()
.get_invoice(id)
.ok_or_else(|| format!("invoice {id} not found after creation"))?;
Ok(Json(CreateDocumentOut {
id,
total: inv.total().map_err(|e| e.to_string())?.to_string(),
entry_id: inv.entry_id,
.ok_or_else(|| format!("invoice {id} not found after creation"))?
.clone();
Ok(Json(InvoiceOut {
preview: None,
id: Some(id),
number: invoice.number.clone(),
total: money(invoice.total().map_err(|e| e.to_string())?),
due_date: invoice.due_date.to_string(),
terms: invoice.terms.as_ref().map(ledger_core::Terms::describe),
entry_id: Some(entry_id),
lines: Vec::new(),
pdf: Some(DocumentPdfOut::Issued(IssuedFileOut {
attachment_id,
sha256,
})),
mailto: Some(mailto_link(&settings, &invoice)),
warnings: created_uncategorized(store.ledger(), first_new),
notes: document_notes(store.ledger(), &inv.lines, true),
notes: document_notes(store.ledger(), &lines, true),
}))
}
#[tool(
name = "books.create_bill",
title = "Create and post a bill",
description = "Create a vendor bill. Auto-posts Dr General Expenses / Cr Accounts Payable. Lines are {description, amount, class?} with decimal-string amounts; class (a project: name or id, books.projects) tags that line's cost: one expense line per project, the untagged lines' as one; the payable line is never tagged. Its result gains notes for a finished or abandoned project, tagged anyway.",
description = "Create a vendor bill: without confirmed it is a preview (the posting, due date and early discount) and nothing is written; with confirmed=true it posts Dr each line's account / Cr Accounts Payable and files the vendor's invoice (upload_id, or an existing_attachment_id) on its entry. The vendor's invoice number is vendor_ref; the same vendor and number twice is refused unless duplicate_reason says why. In books with documents_v2 every line names its account (A-10) and the file is required. account: an expense, or a non-cash asset; class: a project. Terms or a due date as for invoices.",
annotations(
read_only_hint = false,
destructive_hint = true,
@@ -181,30 +639,211 @@ impl BooksServer {
)]
async fn create_bill(
&self,
params: Parameters<CreateDocumentParams>,
) -> Result<Json<CreateDocumentOut>, String> {
params: Parameters<CreateBillParams>,
) -> Result<Json<BillOut>, String> {
let p = params.0;
let mut store = self.lock()?;
let vendor = match (&p.vendor, p.party_id) {
(Some(v), None) => v.trim().to_string(),
(None, Some(id)) => id.to_string(),
(Some(v), Some(id)) if v.trim() == id.to_string() => v.trim().to_string(),
(Some(_), Some(_)) => {
return Err("give the vendor once: vendor or party_id, not both".into());
}
(None, None) => {
return Err("give vendor: a vendor id, a party id or a name".into());
}
};
let vendor_id = resolve_vendor_arg(&store, &vendor)?;
let date = parse_date(&p.date)?;
let received_on = p.received_on.as_deref().map(parse_date).transpose()?;
let lines = document_lines(&store, p.lines)?;
let v2 = documents_v2(&store);
let vendor_ref = p
.vendor_ref
.as_deref()
.map(str::trim)
.filter(|r| !r.is_empty())
.map(str::to_string);
if p.upload_id.is_some() && p.existing_attachment_id.is_some() {
return Err(
"give the vendor's invoice once: upload_id or existing_attachment_id, not both"
.into(),
);
}
if let Some(m) = &p.memo {
ledger_core::parties::check_text("the memo", m)?;
}
if let Some(r) = &p.duplicate_reason {
ledger_core::parties::check_text("the duplicate reason", r)?;
}
// The file is taken before anything is written: an upload is used
// up only by the call that files it (files.rs INVARIANT 1).
let taken = match &p.upload_id {
Some(id) => Some(self.take_file(files::Given {
upload_id: id,
purposes: files::FILED,
})?),
None => None,
};
if let Some(f) = &taken {
self.refuse_tin_file(&store, &f.bytes, &f.mime, &f.file_name)?;
}
if let Some(aid) = p.existing_attachment_id {
let kept = store
.list_attachments(None)
.map_err(|e| e.to_string())?
.into_iter()
.any(|a| a.id == aid && a.withdrawn_at.is_none());
if !kept {
return Err(format!(
"no attachment {aid} (or it was withdrawn): books.attachments lists them"
));
}
}
let party = store.ledger().party_of_vendor(vendor_id);
let (due_date, terms) = due_and_terms(
date,
p.due_date.as_deref(),
p.terms.as_deref(),
vec![party_default_terms(&store, party, false)],
"the bill",
"due_date, or terms, or the vendor's default terms",
)?;
let fields = arap::BillFields {
vendor_ref: vendor_ref.clone(),
received_on,
terms,
memo: p.memo.clone(),
duplicate_reason: p.duplicate_reason.clone(),
};
let preview = store
.preview_bill(vendor_id, date, due_date, lines.clone(), fields.clone())
.map_err(|e| e.to_string())?;
let discount_by = fields
.terms
.as_ref()
.and_then(|t| t.discount_by(date))
.map(|d| d.to_string());
let max_discount = fields.terms.as_ref().and_then(|t| {
let bp = t.discount()?.percent_bp;
ledger_core::kernel::discount_max(preview.total, bp)
});
// What these books require of a new bill, after the preview has
// run the issue's own checks: so a line naming a project the books
// haven't declared (document_line_projects) still says so, rather
// than being answered with a missing vendor number.
if v2 {
if vendor_ref.is_none() {
return Err(
"give vendor_ref: the vendor's own invoice number. A second bill with the same vendor and number is refused unless duplicate_reason says why"
.into(),
);
}
if let Some(n) = lines.iter().position(|l| l.account.is_none()) {
return Err(format!(
"every bill line names its account in these books (A-10): line {} ({:?}) does not; name the expense, equipment or prepaid account it belongs to (books.chart)",
n + 1,
lines[n].description
));
}
if p.upload_id.is_none() && p.existing_attachment_id.is_none() {
return Err(
"give the vendor's invoice: upload_id (books.begin_upload, then `wanderbooks upload`) or existing_attachment_id. Every bill keeps the invoice it was entered from (A-9)"
.into(),
);
}
}
if !p.confirmed.unwrap_or(false) {
return Ok(Json(BillOut {
preview: Some(true),
id: None,
total: money(preview.total),
due_date: preview.due_date.to_string(),
terms: fields.terms.as_ref().map(ledger_core::Terms::describe),
entry_id: None,
lines: posting_out(store.ledger(), &preview.lines),
discount_by,
max_discount: max_discount.map(money),
file: None,
warnings: Vec::new(),
notes: Vec::new(),
}));
}
let first_new = store.ledger().next_account_id();
let id = store
.create_bill(
p.party_id,
parse_date(&p.date)?,
parse_date(&p.due_date)?,
lines,
)
.create_bill_with(vendor_id, date, due_date, lines.clone(), fields)
.map_err(|e| e.to_string())?;
let bill = store
.ledger()
.get_bill(id)
.ok_or_else(|| format!("bill {id} not found after creation"))?;
Ok(Json(CreateDocumentOut {
id,
total: bill.total().map_err(|e| e.to_string())?.to_string(),
entry_id: bill.entry_id,
.ok_or_else(|| format!("bill {id} not found after creation"))?
.clone();
let entry_id = bill.entry_id;
// The vendor's invoice, filed on the bill's entry and linked to the
// bill (A-9, A-13).
let (attachment_id, sha256) = match (taken, p.existing_attachment_id) {
(Some(f), _) => {
let (aid, sha, _) = store
.attach_receipt(
Some(entry_id),
&f.file_name,
&f.mime,
&f.bytes,
Some("Vendor invoice"),
)
.map_err(|e| e.to_string())?;
files::filed(f.claim);
store
.link_attachment(aid, ledger_store::Subject::Bill(id))
.map_err(|e| e.to_string())?;
(aid, sha)
}
(None, Some(aid)) => {
store
.link_attachment(aid, ledger_store::Subject::Bill(id))
.map_err(|e| e.to_string())?;
let linked = store
.attachments_for(ledger_store::Subject::Entry(entry_id))
.map_err(|e| e.to_string())?
.iter()
.any(|a| a.id == aid);
if !linked {
store
.link_attachment(aid, ledger_store::Subject::Entry(entry_id))
.map_err(|e| e.to_string())?;
}
let sha = store
.list_attachments(None)
.map_err(|e| e.to_string())?
.into_iter()
.find(|a| a.id == aid)
.map(|a| a.blob_sha)
.unwrap_or_default();
(aid, sha)
}
(None, None) => (0, String::new()),
};
Ok(Json(BillOut {
preview: None,
id: Some(id),
total: money(bill.total().map_err(|e| e.to_string())?),
due_date: bill.due_date.to_string(),
terms: bill.terms.as_ref().map(ledger_core::Terms::describe),
entry_id: Some(entry_id),
lines: Vec::new(),
discount_by,
max_discount: max_discount.map(money),
file: if attachment_id == 0 {
None
} else {
Some(IssuedFileOut {
attachment_id,
sha256,
})
},
warnings: created_uncategorized(store.ledger(), first_new),
notes: document_notes(store.ledger(), &bill.lines, false),
notes: document_notes(store.ledger(), &lines, false),
}))
}
+57 -4
View File
@@ -63,6 +63,20 @@ fn party_out(ledger: &Ledger, p: &ledger_core::Party) -> PartyOut {
website: p.website.clone(),
address: p.address.clone(),
notes: p.notes.clone(),
email: p.email.clone(),
phone: p.phone.clone(),
contact: p.contact.clone(),
billing_address: p.billing_address.clone(),
shipping_address: p.shipping_address.clone(),
billing_email: p.billing_email.clone(),
default_terms_as_customer: p
.default_terms_as_customer
.as_ref()
.map(ledger_core::Terms::describe),
default_terms_as_vendor: p
.default_terms_as_vendor
.as_ref()
.map(ledger_core::Terms::describe),
archived: p.archived,
vendor_id: p.vendor,
customer_id: p.customer,
@@ -540,7 +554,7 @@ impl BooksServer {
#[tool(
name = "books.update_party",
title = "Update a party",
description = "Change a party's facts: name, display_name, website, address, notes, archived, creditor_of. Give only the fields to change; \"\" clears display_name, website, address or notes. creditor_of replaces the list of liabilities the company owes the party itself (its card, its loan, what the members advanced): a credit to one never counts as paying it. Every field is checked before any is written, so a bad one (a blank or taken name, a full TIN, over 500 characters, not a liability) changes nothing; each is journaled as party.updated or party.creditor_set. Archive a party you no longer deal with (its history stays; it then goes by id). Returns the party.",
description = "Change a party's facts: name, display_name, website, address, notes, email, default_terms_as_customer, default_terms_as_vendor, archived, creditor_of. Give only the fields to change; \"\" clears display_name, website, address, notes, email or the terms. email and the terms are copied onto each invoice or bill at issue (A-13); terms (\"Net 30\") set a due date when the document gives none. creditor_of replaces the list of liabilities the company owes the party itself. Every field is checked before any is written; each is journaled as party.updated. Returns the party.",
annotations(
read_only_hint = false,
destructive_hint = false,
@@ -571,6 +585,40 @@ impl BooksServer {
if let Some(v) = p.notes {
fields.push(F::Notes(optional_text(&v)));
}
if let Some(v) = p.email {
fields.push(F::Email(optional_text(&v)));
}
// "" clears the terms; any other text is trade terms this party
// grants or is granted (B-39).
let terms = |v: String| -> Result<Option<ledger_core::Terms>, String> {
if v.trim().is_empty() {
return Ok(None);
}
ledger_core::Terms::parse(&v)
.map(Some)
.map_err(|e| e.to_string())
};
if let Some(v) = p.default_terms_as_customer {
fields.push(F::DefaultTermsAsCustomer(terms(v)?));
}
if let Some(v) = p.default_terms_as_vendor {
fields.push(F::DefaultTermsAsVendor(terms(v)?));
}
if let Some(v) = p.phone {
fields.push(F::Phone(optional_text(&v)));
}
if let Some(v) = p.contact {
fields.push(F::Contact(optional_text(&v)));
}
if let Some(v) = p.billing_address {
fields.push(F::BillingAddress(optional_text(&v)));
}
if let Some(v) = p.shipping_address {
fields.push(F::ShippingAddress(optional_text(&v)));
}
if let Some(v) = p.billing_email {
fields.push(F::BillingEmail(optional_text(&v)));
}
if let Some(a) = p.archived {
fields.push(F::Archived(a));
}
@@ -588,7 +636,7 @@ impl BooksServer {
}
if fields.is_empty() && p.creditor_of.is_none() {
return Err(
"give at least one field to change: name, display_name, website, address, notes, archived, creditor_of"
"give at least one field to change: name, display_name, website, address, notes, email, default_terms_as_customer, default_terms_as_vendor, phone, contact, billing_address, shipping_address, billing_email, archived, creditor_of"
.into(),
);
}
@@ -604,10 +652,15 @@ impl BooksServer {
.set_party_creditor(id, *a, *on)
.map_err(|e| e.to_string())?;
}
// The creditor lines' journal feature, asked for before this
// The journal features these lines need, asked for before this
// command's first line, never between its lines (rules.md B-27:
// books waiting for their format upgrade must not be left with the
// name changed and the creditors not).
// name changed and the rest not).
if fields.iter().any(|f| f.needs_documents_v2()) {
store
.ensure_feature(ledger_store::format::DOCUMENTS_V2)
.map_err(|e| e.to_string())?;
}
if !creditor.is_empty() {
store
.ensure_feature(ledger_store::format::PARTY_CREDITORS)
+274 -26
View File
@@ -3056,39 +3056,118 @@ pub struct DocumentLineParam {
/// receivable or payable line never does.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
/// The account the line's revenue, expense or asset goes to (a code or
/// an id): an invoice line a revenue account or a liability holding no
/// role (deferred revenue), a bill line an expense account or an asset
/// that is not cash and holds no role. Omitted: the kind's own role
/// account (revenue for an invoice, general expenses for a bill). A
/// bill's line must name one in books that have declared the journal
/// feature documents_v2 (A-10).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub account: Option<String>,
/// How many units the line bills, with unit_price, when the line is
/// priced rather than summed: quantity × unit_price must equal amount
/// exactly. Both or neither.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub quantity: Option<String>,
/// The price of one unit, with quantity.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub unit_price: Option<String>,
}
/// Parameters for `books.create_invoice` / `books.create_bill`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CreateDocumentParams {
/// Customer id (invoice) or vendor id (bill).
pub party_id: u64,
/// Document date, YYYY-MM-DD.
/// A document's party as the page prints it: the customer's or vendor's
/// name, address and email at issue (A-13), and its contact block (D-55).
/// Every part omitted is the party record's.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct BillToParam {
/// Its name at issue.
pub name: Option<String>,
/// Its address at issue (the general one), one line per line.
pub address: Option<String>,
/// Its email at issue (the general one).
pub email: Option<String>,
/// Its telephone number at issue.
pub phone: Option<String>,
/// The person there to write to at issue.
pub contact: Option<String>,
/// Where its bills go: the bill-to address when set, else `address`.
pub billing_address: Option<String>,
/// Where goods go: the page prints a Ship-to line for it.
pub shipping_address: Option<String>,
/// Where its bills are emailed: the bill-to email when set, else
/// `email`.
pub billing_email: Option<String>,
}
/// Parameters for `books.create_invoice`: it previews without
/// `confirmed`, and posts with it (A-1).
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct CreateInvoiceParams {
/// The customer: a customer id, its party's id, or its name. A name
/// that two customers answer to is refused.
pub customer: Option<String>,
/// The old spelling of `customer` (accepted until P5): a customer id.
#[serde(default)]
pub party_id: Option<u64>,
/// The invoice date, YYYY-MM-DD.
pub date: String,
/// Due date, YYYY-MM-DD.
pub due_date: String,
/// Its payment terms ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on
/// receipt", "Due 2026-11-15"). The due date is derived from them.
pub terms: Option<String>,
/// Its due date, YYYY-MM-DD: used as given, and it overrides `terms`.
pub due_date: Option<String>,
/// Line items.
pub lines: Vec<DocumentLineParam>,
/// The customer's own reference (their purchase order).
pub customer_ref: Option<String>,
/// A memo printed on the invoice.
pub memo: Option<String>,
/// The customer as the page prints it (omitted: the party's name,
/// address and email at issue).
pub bill_to: Option<BillToParam>,
/// True: post it. Absent or false: preview it and post nothing.
pub confirmed: Option<bool>,
}
/// Output of `books.create_invoice` / `books.create_bill`.
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
pub struct CreateDocumentOut {
/// The new document's id.
pub id: u64,
/// Total amount, decimal string.
pub total: String,
/// The auto-posted journal entry's id.
pub entry_id: u64,
/// What to tell the human: a revenue or expense account this created
/// (Revenue 4000, General Expenses 6999) has no tax category yet.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// One per finished or abandoned project a line names, as
/// `books.add_line`'s `note`, and on an invoice one per on-spec
/// project a line names (is there now an agreement?): tagged anyway.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
/// Parameters for `books.create_bill`: it previews without `confirmed`,
/// and posts with it.
#[derive(Debug, Clone, Deserialize, JsonSchema)]
pub struct CreateBillParams {
/// The vendor: a vendor id, its party's id, or its name. A name that
/// two vendors answer to is refused.
pub vendor: Option<String>,
/// The old spelling of `vendor` (accepted until P5): a vendor id.
#[serde(default)]
pub party_id: Option<u64>,
/// The bill's date, YYYY-MM-DD.
pub date: String,
/// The day the bill was received, when it differs from its date.
pub received_on: Option<String>,
/// The vendor's own invoice number: required in books that have
/// declared documents_v2 (a second bill with the same vendor and
/// number is refused unless duplicate_reason says why).
pub vendor_ref: Option<String>,
/// Its payment terms; the due date is derived from them.
pub terms: Option<String>,
/// Its due date, YYYY-MM-DD: used as given, and it overrides `terms`.
pub due_date: Option<String>,
/// Line items.
pub lines: Vec<DocumentLineParam>,
/// A memo about the bill.
pub memo: Option<String>,
/// The vendor's invoice itself: an upload made with books.begin_upload
/// and `wanderbooks upload` (D-46). Required in books that have
/// declared documents_v2: it is filed on the bill's entry as its
/// evidence.
pub upload_id: Option<String>,
/// The vendor's invoice, stored already (books.attachments): filed
/// like an upload.
pub existing_attachment_id: Option<u64>,
/// Why a vendor invoice number that already names a standing bill is
/// entered again anyway.
pub duplicate_reason: Option<String>,
/// True: post it. Absent or false: preview it and post nothing.
pub confirmed: Option<bool>,
}
/// Parameters for `books.receive_payment` / `books.pay_bill`.
@@ -3945,6 +4024,28 @@ pub struct UpdatePartyParams {
pub address: Option<String>,
/// Notes; "" clears them.
pub notes: Option<String>,
/// Email address, where the company writes to them (A-13): copied onto
/// an invoice's bill-to or a bill's vendor at issue. "" clears it.
pub email: Option<String>,
/// The payment terms this party grants the company as a customer
/// ("Net 30", "2/10 net 30"): an invoice's due date falls back to them
/// when neither it nor the call gives terms. "" clears them.
pub default_terms_as_customer: Option<String>,
/// The payment terms this party, as a vendor, gives the company: a
/// bill's due date falls back to them. "" clears them.
pub default_terms_as_vendor: Option<String>,
/// Telephone number (D-55). "" clears it.
pub phone: Option<String>,
/// The named person there to write to (D-55). "" clears it.
pub contact: Option<String>,
/// Where its bills go (D-55): an invoice's bill-to address when set,
/// else `address`. "" clears it.
pub billing_address: Option<String>,
/// Where goods go (D-55). "" clears it.
pub shipping_address: Option<String>,
/// Where its bills are emailed (D-55): an invoice's bill-to email when
/// set, else `email`. "" clears it.
pub billing_email: Option<String>,
/// True: no longer dealt with (its history stays; it no longer
/// resolves by name, and its name is free for another party). False:
/// dealt with again (refused while another party has its name).
@@ -4289,6 +4390,25 @@ pub struct PartyOut {
pub address: Option<String>,
/// Notes.
pub notes: Option<String>,
/// Email address (A-13): the general one, where the company writes to
/// it.
pub email: Option<String>,
/// Telephone number (D-55).
pub phone: Option<String>,
/// The named person there to write to (D-55).
pub contact: Option<String>,
/// Where its bills go (D-55): an invoice's bill-to address when set,
/// else `address`.
pub billing_address: Option<String>,
/// Where goods go (D-55).
pub shipping_address: Option<String>,
/// Where its bills are emailed (D-55): the bill-to email when set,
/// else `email`.
pub billing_email: Option<String>,
/// The terms it grants the company as a customer ("Net 30"), when set.
pub default_terms_as_customer: Option<String>,
/// The terms it gives the company as a vendor, when set.
pub default_terms_as_vendor: Option<String>,
/// No longer dealt with.
pub archived: bool,
/// Its vendor record's id, if it bills the company.
@@ -4758,3 +4878,131 @@ pub struct HandbookOut {
/// The section asked for (null: none was).
pub section: Option<HandbookTextOut>,
}
// ---------------------------------------------------------------------------
// P4b S4.4: invoices and bills preview, then confirm
// ---------------------------------------------------------------------------
/// One line of a document's posting, as a preview shows it: the account's
/// code and name, its side, the amount, and the project it is tagged with.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct DocumentPostingOut {
/// The account's code ("1200").
pub account: String,
/// Its name ("Accounts Receivable").
pub name: String,
/// "debit" or "credit".
pub side: String,
/// The amount, decimal string.
pub amount: String,
/// The project the line is tagged with, if any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub class: Option<String>,
}
/// An invoice's PDF: the DRAFT copy a preview hands over, or the issued
/// copy a confirm files, named by its attachment (A-7).
#[derive(Debug, Clone, Serialize, JsonSchema)]
#[serde(untagged)]
pub enum DocumentPdfOut {
/// The DRAFT copy, handed over: run its `download` to save it (D-46).
Draft(HandedFile),
/// The issued copy, filed with the invoice in the call that issued it.
Issued(IssuedFileOut),
}
/// A file filed with a document: its attachment and SHA-256.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct IssuedFileOut {
/// The attachment (books.get_receipt).
pub attachment_id: u64,
/// The file's SHA-256, hex: the same one books.get_receipt reports.
pub sha256: String,
}
/// Result of `books.create_invoice`: a preview (nothing posted) or the
/// posted invoice.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct InvoiceOut {
/// True: a preview, and nothing was posted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<bool>,
/// The invoice's id (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
/// Its number (confirm: the one issued; preview: the one it would
/// take). Absent in books that don't number their invoices yet.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub number: Option<String>,
/// The total, decimal string.
pub total: String,
/// The due date, YYYY-MM-DD.
pub due_date: String,
/// The terms' text ("Net 30"), when it has them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terms: Option<String>,
/// The auto-posted entry's id (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<u64>,
/// The posting, line by line (preview).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lines: Vec<DocumentPostingOut>,
/// The PDF: DRAFT and handed over on a preview, the issued copy filed
/// with the invoice on a confirm.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf: Option<DocumentPdfOut>,
/// A `mailto:` link for a human to send it from their own mail
/// program (the books never send, A-4). The file is attached by hand.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mailto: Option<String>,
/// What to tell the human: a revenue account this created (4000) has
/// no tax category yet.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// One per finished or abandoned project a line names, and one per
/// on-spec project a line names (is there now an agreement?).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}
/// Result of `books.create_bill`: a preview (nothing posted) or the
/// posted bill.
#[derive(Debug, Clone, Serialize, JsonSchema)]
pub struct BillOut {
/// True: a preview, and nothing was posted.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub preview: Option<bool>,
/// The bill's id (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub id: Option<u64>,
/// The total, decimal string.
pub total: String,
/// The due date, YYYY-MM-DD.
pub due_date: String,
/// The terms' text ("2/10 net 30"), when it has them.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terms: Option<String>,
/// The auto-posted entry's id (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub entry_id: Option<u64>,
/// The posting, line by line (preview).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub lines: Vec<DocumentPostingOut>,
/// The last day its early-payment discount is open, when it has one.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discount_by: Option<String>,
/// The largest discount its terms allow, decimal string (the caller
/// gives the exact cents when it takes one).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_discount: Option<String>,
/// The vendor's invoice, filed with the bill (confirm).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub file: Option<IssuedFileOut>,
/// What to tell the human: an expense account this created (6999) has
/// no tax category yet.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
/// One per finished or abandoned project a line names.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub notes: Vec<String>,
}