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:
1 parent
ed637f0e27
commit
d5b9e5cf99
28 files changed
+2604
-190
No files matched your search
@@ -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),
|
||||
}))
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user