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
@@ -0,0 +1,711 @@
|
||||
//! Invoices and bills through the books server, as an agent reaches them
|
||||
//! (common.rs): the preview that writes nothing, the confirm that posts,
|
||||
//! numbers, terms, line accounts, party snapshots and the issued copy filed
|
||||
//! with the invoice (plan/served.md 8.2 P4b S4 item 17; docs/mcp-interface.md
|
||||
//! "Tools — A/R and A/P"; rules.md B-39, B-40; D-55).
|
||||
//!
|
||||
//! Every refusal is checked to leave the journal byte for byte as it was.
|
||||
|
||||
use common::{Books, Wire};
|
||||
use serde_json::{Value, json};
|
||||
|
||||
mod common;
|
||||
|
||||
/// One agent's session with the test's books, over the books server's
|
||||
/// socket (common.rs).
|
||||
struct Mcp {
|
||||
wire: Wire,
|
||||
next_id: u64,
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// A new, initialized session with `books`.
|
||||
fn spawn(books: &Books) -> Self {
|
||||
Self {
|
||||
wire: books.session("documents"),
|
||||
next_id: 1,
|
||||
}
|
||||
}
|
||||
|
||||
fn request(&mut self, method: &str, params: Value) -> Value {
|
||||
let id = self.next_id;
|
||||
self.next_id += 1;
|
||||
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||
self.wire.send(&msg);
|
||||
let resp = self
|
||||
.wire
|
||||
.recv(common::TIMEOUT)
|
||||
.unwrap_or_else(|| panic!("no answer to {method}: {:?}", self.wire.refused));
|
||||
assert_eq!(resp["id"], id, "id mismatch in {resp}");
|
||||
resp
|
||||
}
|
||||
|
||||
/// Call a tool; panic on a tool error. Returns structuredContent.
|
||||
fn call(&mut self, tool: &str, args: Value) -> Value {
|
||||
match self.try_call(tool, args) {
|
||||
Ok(v) => v,
|
||||
Err(e) => panic!("{tool}: {e}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Call a tool: its result, or the refusal's text.
|
||||
fn try_call(&mut self, tool: &str, args: Value) -> Result<Value, String> {
|
||||
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
|
||||
let result = &resp["result"];
|
||||
if result.get("isError") == Some(&json!(true)) {
|
||||
let text = result["content"]
|
||||
.as_array()
|
||||
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
return Err(text);
|
||||
}
|
||||
Ok(result.get("structuredContent").cloned().unwrap_or_default())
|
||||
}
|
||||
|
||||
/// The bytes of a file a tool handed over (a `HandedFile`).
|
||||
fn handed(&mut self, file: &Value) -> Vec<u8> {
|
||||
assert!(file["expires_at"].is_string(), "{file}");
|
||||
self.wire.fetch(file)
|
||||
}
|
||||
|
||||
fn upload(&mut self, bytes: &[u8], file_name: &str, purpose: &str) -> String {
|
||||
self.wire
|
||||
.upload(bytes, file_name, "application/pdf", purpose)
|
||||
}
|
||||
|
||||
/// A customer with `name`: its id.
|
||||
fn customer(&mut self, name: &str) -> u64 {
|
||||
self.call("books.add_customer", json!({"name": name}))["id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// A vendor with `name`: its id.
|
||||
fn vendor(&mut self, name: &str) -> u64 {
|
||||
self.call("books.add_vendor", json!({"name": name}))["id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
/// An expense account, so a bill's line can name one (A-10).
|
||||
fn expense(&mut self, code: &str, name: &str) {
|
||||
self.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": "expense"}),
|
||||
);
|
||||
}
|
||||
|
||||
/// The journal's `kind` events, oldest first, as `{type, payload}`.
|
||||
fn events(books: &Books, kind: &str) -> Vec<Value> {
|
||||
String::from_utf8(books.journal())
|
||||
.unwrap()
|
||||
.lines()
|
||||
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
|
||||
.filter(|v| v["type"] == json!(kind))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Every invoice and bill figure and the copies they keep, from the
|
||||
/// preview that writes nothing to the confirm that files the issued copy.
|
||||
#[test]
|
||||
fn an_invoice_from_issue_to_paid() {
|
||||
let books = Books::new("documents-issue");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let harbor = m.customer("Harbor Coffee");
|
||||
let issue = json!({
|
||||
"customer": harbor.to_string(),
|
||||
"date": "2026-02-01",
|
||||
"terms": "Net 30",
|
||||
"lines": [{"description": "Design", "amount": "500.00"}],
|
||||
});
|
||||
|
||||
// The preview: nothing written, a DRAFT copy handed over, the number
|
||||
// and due date the confirm will take.
|
||||
let before = books.journal();
|
||||
let preview = m.call("books.create_invoice", issue.clone());
|
||||
assert_eq!(preview["preview"], json!(true), "{preview}");
|
||||
assert_eq!(preview["number"], "INV-0001", "{preview}");
|
||||
assert_eq!(preview["due_date"], "2026-03-03", "{preview}");
|
||||
assert_eq!(preview["terms"], "Net 30", "{preview}");
|
||||
assert_eq!(preview["total"], "500.00", "{preview}");
|
||||
let posting: Vec<(String, String, String)> = preview["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
l["account"].as_str().unwrap().to_string(),
|
||||
l["side"].as_str().unwrap().to_string(),
|
||||
l["amount"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
posting,
|
||||
[
|
||||
("1200".into(), "debit".into(), "500.00".into()),
|
||||
("4000".into(), "credit".into(), "500.00".into()),
|
||||
]
|
||||
);
|
||||
let draft = m.handed(&preview["pdf"]);
|
||||
assert!(draft.starts_with(b"%PDF"), "a PDF, not bytes inline");
|
||||
assert!(draft.len() > 1_000, "the DRAFT copy is a page: {preview}");
|
||||
assert_eq!(books.journal(), before, "a preview journals nothing");
|
||||
assert!(books.journal().len() == before.len());
|
||||
|
||||
// The confirm: the same number, the issued copy filed with the invoice.
|
||||
let mut confirmed = issue.clone();
|
||||
confirmed["confirmed"] = json!(true);
|
||||
let issued = m.call("books.create_invoice", confirmed);
|
||||
assert_eq!(issued["number"], "INV-0001", "{issued}");
|
||||
assert_eq!(issued["due_date"], "2026-03-03", "{issued}");
|
||||
assert_eq!(issued["total"], "500.00", "{issued}");
|
||||
let entry = issued["entry_id"].as_u64().unwrap();
|
||||
let attachment = issued["pdf"]["attachment_id"].as_u64().unwrap();
|
||||
let sha = issued["pdf"]["sha256"].as_str().unwrap().to_string();
|
||||
let receipt = m.call("books.get_receipt", json!({"attachment_id": attachment}));
|
||||
assert_eq!(receipt["sha256"], json!(sha), "the same SHA-256");
|
||||
assert_eq!(receipt["entry_id"], json!(entry), "filed on its entry");
|
||||
assert_eq!(receipt["file_name"], "INV-0001.pdf", "{receipt}");
|
||||
assert_eq!(receipt["mime_type"], "application/pdf", "{receipt}");
|
||||
assert!(
|
||||
receipt["links"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|l| l["kind"] == json!("invoice") && l["id"] == issued["id"]),
|
||||
"linked to the invoice: {receipt}"
|
||||
);
|
||||
// The entry carries the issued copy as its evidence.
|
||||
let got = m.call("books.get_entry", json!({"entry_id": entry}));
|
||||
assert!(
|
||||
got["receipts"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|r| r["attachment_id"] == json!(attachment)),
|
||||
"{got}"
|
||||
);
|
||||
// A convenience for a human sender; the books never send (A-4).
|
||||
let mailto = issued["mailto"].as_str().unwrap();
|
||||
assert!(mailto.starts_with("mailto:"), "{mailto}");
|
||||
assert!(
|
||||
mailto.contains("Invoice%20INV-0001%20from%20"),
|
||||
"the subject names the number and the company: {mailto}"
|
||||
);
|
||||
|
||||
// The next one takes the next number; a preview between them takes
|
||||
// none.
|
||||
let second = m.call(
|
||||
"books.create_invoice",
|
||||
json!({"customer": "Harbor Coffee", "date": "2026-02-02", "terms": "Net 30",
|
||||
"lines": [{"description": "Build", "amount": "250.00"}], "confirmed": true}),
|
||||
);
|
||||
assert_eq!(second["number"], "INV-0002", "{second}");
|
||||
let third = m.call(
|
||||
"books.create_invoice",
|
||||
json!({"customer": harbor.to_string(), "date": "2026-02-03", "terms": "Net 30",
|
||||
"lines": [{"description": "Abandoned", "amount": "10.00"}],
|
||||
"confirmed": true}),
|
||||
);
|
||||
assert_eq!(third["number"], "INV-0003", "{third}");
|
||||
m.call("books.verify", json!({}));
|
||||
}
|
||||
|
||||
/// A bill keeps the vendor's invoice, names its own accounts, and carries
|
||||
/// its terms' discount dates (A-10, A-13; B-39).
|
||||
#[test]
|
||||
fn a_bill_with_its_vendor_invoice_and_terms() {
|
||||
let books = Books::new("documents-bill");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let coast = m.vendor("Coastline Hosting");
|
||||
m.expense("6500", "Hosting");
|
||||
let file = m.upload(
|
||||
b"%PDF-1.4 Coastline's own invoice",
|
||||
"coastline.pdf",
|
||||
"receipt",
|
||||
);
|
||||
let bill = json!({
|
||||
"vendor": coast.to_string(),
|
||||
"date": "2026-02-01",
|
||||
"terms": "2/10 net 30",
|
||||
"vendor_ref": "CH-4471",
|
||||
"lines": [{"description": "Hosting, February", "amount": "1000.00",
|
||||
"account": "6500"}],
|
||||
});
|
||||
|
||||
let before = books.journal();
|
||||
let mut preview = bill.clone();
|
||||
preview["upload_id"] = json!(file);
|
||||
let p = m.call("books.create_bill", preview.clone());
|
||||
assert_eq!(p["preview"], json!(true), "{p}");
|
||||
assert_eq!(p["due_date"], "2026-03-03", "{p}");
|
||||
assert_eq!(p["terms"], "2/10 net 30", "{p}");
|
||||
assert_eq!(p["discount_by"], "2026-02-11", "{p}");
|
||||
assert_eq!(p["max_discount"], "20.00", "2% of 1,000.00");
|
||||
assert_eq!(books.journal(), before, "a preview journals nothing");
|
||||
|
||||
let mut confirm = preview;
|
||||
confirm["confirmed"] = json!(true);
|
||||
let made = m.call("books.create_bill", confirm);
|
||||
assert_eq!(made["terms"], "2/10 net 30", "{made}");
|
||||
assert_eq!(made["discount_by"], "2026-02-11", "{made}");
|
||||
let attachment = made["file"]["attachment_id"].as_u64().unwrap();
|
||||
let sha = made["file"]["sha256"].as_str().unwrap().to_string();
|
||||
assert_eq!(
|
||||
sha,
|
||||
ledger_store::journal::sha256_hex(b"%PDF-1.4 Coastline's own invoice"),
|
||||
"the vendor's own bytes"
|
||||
);
|
||||
let receipt = m.call("books.get_receipt", json!({"attachment_id": attachment}));
|
||||
assert_eq!(receipt["entry_id"], made["entry_id"], "{receipt}");
|
||||
assert_eq!(receipt["description"], "Vendor invoice", "{receipt}");
|
||||
assert!(
|
||||
receipt["links"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.any(|l| l["kind"] == json!("bill") && l["id"] == made["id"]),
|
||||
"linked to the bill: {receipt}"
|
||||
);
|
||||
// The posting: the expense the line names, the payable, tagged.
|
||||
let entry = m.call("books.get_entry", json!({"entry_id": made["entry_id"]}));
|
||||
let lines: Vec<(String, String, String)> = entry["lines"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.map(|l| {
|
||||
(
|
||||
l["code"].as_str().unwrap().to_string(),
|
||||
l["side"].as_str().unwrap().to_string(),
|
||||
l["amount"].as_str().unwrap().to_string(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
lines,
|
||||
[
|
||||
("6500".into(), "debit".into(), "1000.00".into()),
|
||||
("2000".into(), "credit".into(), "1000.00".into()),
|
||||
]
|
||||
);
|
||||
// The vendor's number is the bill's, and the document names it.
|
||||
let doc = m.call(
|
||||
"books.document",
|
||||
json!({"ref": format!("bill:{}", made["id"])}),
|
||||
);
|
||||
assert_eq!(doc["total"], "1000.00", "{doc}");
|
||||
m.call("books.verify", json!({}));
|
||||
}
|
||||
|
||||
/// One vendor's invoice number, entered twice: refused unless a reason is
|
||||
/// given, which is journaled with the second bill (B-40).
|
||||
#[test]
|
||||
fn duplicate_vendor_invoice_refused() {
|
||||
let books = Books::new("documents-duplicate");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let coast = m.vendor("Coastline Hosting");
|
||||
m.expense("6500", "Hosting");
|
||||
let first_file = m.upload(b"first copy", "ch-1.pdf", "receipt");
|
||||
let bill = |file: &str, date: &str| {
|
||||
json!({"vendor": coast.to_string(), "date": date, "due_date": "2026-03-03",
|
||||
"vendor_ref": "CH-4471",
|
||||
"lines": [{"description": "Hosting", "amount": "100.00", "account": "6500"}],
|
||||
"upload_id": file, "confirmed": true})
|
||||
};
|
||||
m.call("books.create_bill", bill(&first_file, "2026-02-01"));
|
||||
// The same number (however written) is the same vendor invoice.
|
||||
let second_file = m.upload(b"second copy", "ch-1-again.pdf", "receipt");
|
||||
let before = books.journal();
|
||||
let refused = m
|
||||
.try_call("books.create_bill", bill(&second_file, "2026-02-02"))
|
||||
.unwrap_err();
|
||||
assert!(refused.contains("CH-4471"), "{refused}");
|
||||
assert!(refused.contains("duplicate_reason"), "{refused}");
|
||||
assert_eq!(books.journal(), before, "a refusal journals nothing");
|
||||
// The upload was given back, not used up (an upload is spent only by
|
||||
// the call that files it).
|
||||
let mut with_reason = bill(&second_file, "2026-02-02");
|
||||
with_reason["duplicate_reason"] =
|
||||
json!("the vendor sent it twice; this one has the credit line");
|
||||
let made = m.call("books.create_bill", with_reason);
|
||||
let created = Mcp::events(&books, "bill.created");
|
||||
let second = created.last().unwrap();
|
||||
assert_eq!(
|
||||
second["payload"]["duplicate_reason"],
|
||||
"the vendor sent it twice; this one has the credit line",
|
||||
"{second}"
|
||||
);
|
||||
assert_eq!(made["total"], "100.00");
|
||||
}
|
||||
|
||||
/// A line's account obeys the document's kind: revenue or a role-free
|
||||
/// liability on an invoice, an expense or a non-cash role-free asset on a
|
||||
/// bill; a role account, cash, a placeholder or none at all is refused
|
||||
/// (A-10, A-11; rules.md B-39).
|
||||
#[test]
|
||||
fn a_line_account_must_be_allowed() {
|
||||
let books = Books::new("documents-accounts");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let harbor = m.customer("Harbor Coffee");
|
||||
let coast = m.vendor("Coastline Hosting");
|
||||
for (code, name, kind) in [
|
||||
("1000", "Checking", "asset"),
|
||||
("2000", "Accounts Payable", "liability"),
|
||||
("6999", "General Expenses", "expense"),
|
||||
] {
|
||||
m.call(
|
||||
"books.add_account",
|
||||
json!({"code": code, "name": name, "account_type": kind}),
|
||||
);
|
||||
}
|
||||
m.call(
|
||||
"books.add_account",
|
||||
json!({"code": "2400", "name": "Deferred Revenue", "account_type": "liability"}),
|
||||
);
|
||||
m.call(
|
||||
"books.add_account",
|
||||
json!({"code": "1500", "name": "Equipment", "account_type": "asset"}),
|
||||
);
|
||||
let invoice = |account: &str| {
|
||||
json!({"customer": harbor.to_string(), "date": "2026-02-01", "terms": "Net 30",
|
||||
"lines": [{"description": "Work", "amount": "100.00", "account": account}],
|
||||
"confirmed": true})
|
||||
};
|
||||
let before = books.journal();
|
||||
// An invoice line may not post to the receivable control or a role
|
||||
// account.
|
||||
let why = m
|
||||
.try_call("books.create_invoice", invoice("1200"))
|
||||
.unwrap_err();
|
||||
assert!(why.contains("1200"), "{why}");
|
||||
assert_eq!(books.journal(), before, "a refusal journals nothing");
|
||||
// Nor to an expense account.
|
||||
let why = m
|
||||
.try_call("books.create_invoice", invoice("6999"))
|
||||
.unwrap_err();
|
||||
assert!(why.contains("6999"), "{why}");
|
||||
// A liability holding no role (deferred revenue) is allowed.
|
||||
let deferred = m.call("books.create_invoice", invoice("2400"));
|
||||
assert_eq!(deferred["total"], "100.00", "{deferred}");
|
||||
|
||||
// A bill's line names its account in these books, and it must be an
|
||||
// expense or a non-cash asset holding no role.
|
||||
let file = m.upload(b"coastline", "coastline.pdf", "receipt");
|
||||
let bill = |account: &str| {
|
||||
json!({"vendor": coast.to_string(), "date": "2026-02-01", "due_date": "2026-03-03",
|
||||
"vendor_ref": "CH-1",
|
||||
"lines": [{"description": "Hosting", "amount": "100.00", "account": account}],
|
||||
"upload_id": file, "confirmed": true})
|
||||
};
|
||||
let why = m.try_call("books.create_bill", bill("1000")).unwrap_err();
|
||||
assert!(why.contains("1000") && why.contains("cash"), "{why}");
|
||||
let why = m.try_call("books.create_bill", bill("2000")).unwrap_err();
|
||||
assert!(why.contains("2000"), "{why}");
|
||||
// A line naming no account is refused in books with documents_v2.
|
||||
let mut unnamed = bill("1500");
|
||||
unnamed["lines"] = json!([{"description": "Hosting", "amount": "100.00"}]);
|
||||
let why = m.try_call("books.create_bill", unnamed).unwrap_err();
|
||||
assert!(why.contains("A-10"), "{why}");
|
||||
// Equipment is an asset that is not cash and holds no role.
|
||||
let equipment = m.call("books.create_bill", bill("1500"));
|
||||
assert_eq!(equipment["total"], "100.00", "{equipment}");
|
||||
}
|
||||
|
||||
/// The due date falls back in one order: the date given, the terms given,
|
||||
/// the party's default terms, the books' invoicing default (plan 8.2 S4.4
|
||||
/// item 14; A-13).
|
||||
#[test]
|
||||
fn terms_fall_back_in_order() {
|
||||
let books = Books::new("documents-terms");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let harbor = m.customer("Harbor Coffee");
|
||||
let line = json!([{"description": "Work", "amount": "100.00"}]);
|
||||
let invoice = |extra: Value| {
|
||||
let mut args = json!({"customer": harbor.to_string(), "date": "2026-10-23",
|
||||
"lines": line, "confirmed": true});
|
||||
for (k, v) in extra.as_object().unwrap() {
|
||||
args[k] = v.clone();
|
||||
}
|
||||
args
|
||||
};
|
||||
|
||||
// The books' default, unset: Net 30.
|
||||
let plain = m.call("books.create_invoice", invoice(json!({})));
|
||||
assert_eq!(plain["due_date"], "2026-11-22", "{plain}");
|
||||
assert_eq!(plain["terms"], "Net 30", "{plain}");
|
||||
|
||||
// The customer's own terms come before the books'.
|
||||
let party = m.party_of("Harbor Coffee");
|
||||
m.call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(), "default_terms_as_customer": "Net 15 EOM"}),
|
||||
);
|
||||
let by_party = m.call("books.create_invoice", invoice(json!({})));
|
||||
assert_eq!(by_party["due_date"], "2026-11-15", "{by_party}");
|
||||
assert_eq!(by_party["terms"], "Net 15 EOM", "{by_party}");
|
||||
|
||||
// Terms given come before the customer's.
|
||||
let given = m.call("books.create_invoice", invoice(json!({"terms": "Net 45"})));
|
||||
assert_eq!(given["due_date"], "2026-12-07", "{given}");
|
||||
assert_eq!(given["terms"], "Net 45", "{given}");
|
||||
|
||||
// A due date given wins over all of them, and prints no terms.
|
||||
let dated = m.call(
|
||||
"books.create_invoice",
|
||||
invoice(json!({"due_date": "2026-12-31"})),
|
||||
);
|
||||
assert_eq!(dated["due_date"], "2026-12-31", "{dated}");
|
||||
assert!(dated.get("terms").is_none(), "{dated}");
|
||||
|
||||
// The books' setting, when the customer has no terms.
|
||||
m.call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(), "default_terms_as_customer": ""}),
|
||||
);
|
||||
m.call(
|
||||
"books.set_setting",
|
||||
json!({"decided_by": "Avery Quill",
|
||||
"changes": [{"key": "invoicing.default_terms", "value": "Net 60"}]}),
|
||||
);
|
||||
let setting = m.call("books.create_invoice", invoice(json!({})));
|
||||
assert_eq!(setting["due_date"], "2026-12-22", "{setting}");
|
||||
assert_eq!(setting["terms"], "Net 60", "{setting}");
|
||||
|
||||
// A bill falls back to its vendor's terms, and no further.
|
||||
let coast = m.vendor("Coastline Hosting");
|
||||
m.expense("6500", "Hosting");
|
||||
let coast_party = m.party_of("Coastline Hosting");
|
||||
m.call(
|
||||
"books.update_party",
|
||||
json!({"party": coast_party.to_string(), "default_terms_as_vendor": "Net 10"}),
|
||||
);
|
||||
let file = m.upload(b"coastline", "coastline.pdf", "receipt");
|
||||
let bill = m.call(
|
||||
"books.create_bill",
|
||||
json!({"vendor": coast.to_string(), "date": "2026-10-23", "vendor_ref": "CH-9",
|
||||
"lines": [{"description": "Hosting", "amount": "100.00", "account": "6500"}],
|
||||
"upload_id": file, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(bill["due_date"], "2026-11-02", "{bill}");
|
||||
assert_eq!(bill["terms"], "Net 10", "{bill}");
|
||||
}
|
||||
|
||||
/// `party_id` is the old spelling of `customer`/`vendor` and still names
|
||||
/// the same record (until P5); a number that is one record's id and
|
||||
/// another's name is refused, never guessed.
|
||||
#[test]
|
||||
fn party_id_alias_still_works() {
|
||||
let books = Books::new("documents-alias");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let harbor = m.customer("Harbor Coffee");
|
||||
let line = json!([{"description": "Work", "amount": "10.00"}]);
|
||||
let by_alias = m.call(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": harbor, "date": "2026-02-01", "terms": "Net 30",
|
||||
"lines": line, "confirmed": true}),
|
||||
);
|
||||
let by_name = m.call(
|
||||
"books.create_invoice",
|
||||
json!({"customer": "harbor coffee", "date": "2026-02-01", "terms": "Net 30",
|
||||
"lines": line, "confirmed": true}),
|
||||
);
|
||||
let invoices = Mcp::events(&books, "invoice.created");
|
||||
let parties: Vec<u64> = invoices
|
||||
.iter()
|
||||
.map(|e| e["payload"]["customer_id"].as_u64().unwrap())
|
||||
.collect();
|
||||
assert_eq!(parties, [harbor, harbor], "one customer, two spellings");
|
||||
assert_eq!(by_alias["total"], by_name["total"]);
|
||||
|
||||
// A customer named "2" that is not customer 2: the number is
|
||||
// ambiguous, and is refused rather than guessed.
|
||||
m.customer("Zeta");
|
||||
m.customer("2");
|
||||
let before = books.journal();
|
||||
let why = m
|
||||
.try_call(
|
||||
"books.create_invoice",
|
||||
json!({"customer": "2", "date": "2026-02-01", "terms": "Net 30",
|
||||
"lines": line, "confirmed": true}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(why.contains("ambiguous"), "{why}");
|
||||
assert_eq!(books.journal(), before, "a refusal journals nothing");
|
||||
// Nothing at all names no customer.
|
||||
let why = m
|
||||
.try_call(
|
||||
"books.create_invoice",
|
||||
json!({"date": "2026-02-01", "terms": "Net 30", "lines": line, "confirmed": true}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(why.contains("customer"), "{why}");
|
||||
}
|
||||
|
||||
impl Mcp {
|
||||
/// The party a name belongs to (its id).
|
||||
fn party_of(&mut self, name: &str) -> u64 {
|
||||
self.call("books.party", json!({"party": name}))["party"]["party_id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
/// D-55: `books.update_party` sets each contact field and `""` clears it.
|
||||
#[test]
|
||||
fn update_party_sets_and_clears_the_contact_block() {
|
||||
let books = Books::new("documents-contact");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
m.customer("Harbor Coffee");
|
||||
let party = m.party_of("Harbor Coffee");
|
||||
for (key, value) in [
|
||||
("phone", "555-0142"),
|
||||
("contact", "Dana Reed"),
|
||||
("billing_address", "12 Billing Way\nPortland, OR 97209"),
|
||||
("shipping_address", "Dock 4, 9 Wharf Street"),
|
||||
("billing_email", "ap@harbor.example"),
|
||||
] {
|
||||
let out = m.call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(), key: value}),
|
||||
);
|
||||
assert_eq!(out[key], json!(value), "{key}: {out}");
|
||||
}
|
||||
// Its general email is still its own field, not the billing one.
|
||||
m.call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(), "email": "hello@harbor.example"}),
|
||||
);
|
||||
let shown = m.call("books.party", json!({"party": party.to_string()}));
|
||||
assert_eq!(shown["party"]["email"], "hello@harbor.example", "{shown}");
|
||||
assert_eq!(
|
||||
shown["party"]["billing_email"], "ap@harbor.example",
|
||||
"{shown}"
|
||||
);
|
||||
// "" clears each, one party.updated apiece.
|
||||
let before = Mcp::events(&books, "party.updated").len();
|
||||
let cleared = m.call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(), "phone": "", "contact": "", "billing_address": "",
|
||||
"shipping_address": "", "billing_email": ""}),
|
||||
);
|
||||
for key in [
|
||||
"phone",
|
||||
"contact",
|
||||
"billing_address",
|
||||
"shipping_address",
|
||||
"billing_email",
|
||||
] {
|
||||
assert_eq!(cleared[key], Value::Null, "{key}: {cleared}");
|
||||
}
|
||||
assert_eq!(
|
||||
Mcp::events(&books, "party.updated").len(),
|
||||
before + 5,
|
||||
"one event per field"
|
||||
);
|
||||
// A full TIN never goes into the block, and a bad email is refused.
|
||||
let why = m
|
||||
.try_call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(), "phone": "123-45-6789"}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(why.contains("taxpayer"), "{why}");
|
||||
let why = m
|
||||
.try_call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(), "billing_email": "not an address"}),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert!(why.contains("address"), "{why}");
|
||||
}
|
||||
|
||||
/// D-55: an invoice's bill-to is the party's billing details when it has
|
||||
/// them, else the general ones, and it is fixed at issue: a later edit
|
||||
/// does not change the copy sent, though the next invoice takes it.
|
||||
#[test]
|
||||
fn an_invoice_bill_to_uses_the_billing_details() {
|
||||
let books = Books::new("documents-bill-to");
|
||||
let mut m = Mcp::spawn(&books);
|
||||
let harbor = m.customer("Harbor Coffee");
|
||||
let party = m.party_of("Harbor Coffee");
|
||||
m.call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(),
|
||||
"address": "PO Box 9, Portland, OR 97209",
|
||||
"email": "hello@harbor.example",
|
||||
"phone": "555-0142",
|
||||
"contact": "Dana Reed",
|
||||
"billing_address": "12 Billing Way, Portland, OR 97209",
|
||||
"billing_email": "ap@harbor.example",
|
||||
"shipping_address": "Dock 4, 9 Wharf Street"}),
|
||||
);
|
||||
let issue = |date: &str| {
|
||||
json!({"customer": harbor.to_string(), "date": date, "terms": "Net 30",
|
||||
"lines": [{"description": "Coffee", "amount": "100.00"}],
|
||||
"confirmed": true})
|
||||
};
|
||||
m.call("books.create_invoice", issue("2026-02-01"));
|
||||
let created = Mcp::events(&books, "invoice.created");
|
||||
let bill_to = &created.last().unwrap()["payload"]["bill_to"];
|
||||
assert_eq!(
|
||||
bill_to["address"], "12 Billing Way, Portland, OR 97209",
|
||||
"the billing address, not the general one: {bill_to}"
|
||||
);
|
||||
assert_eq!(bill_to["email"], "ap@harbor.example", "{bill_to}");
|
||||
assert_eq!(bill_to["billing_email"], "ap@harbor.example", "{bill_to}");
|
||||
assert_eq!(
|
||||
bill_to["billing_address"],
|
||||
"12 Billing Way, Portland, OR 97209"
|
||||
);
|
||||
assert_eq!(bill_to["phone"], "555-0142", "{bill_to}");
|
||||
assert_eq!(bill_to["contact"], "Dana Reed", "{bill_to}");
|
||||
assert_eq!(bill_to["shipping_address"], "Dock 4, 9 Wharf Street");
|
||||
|
||||
// A later edit: the issued snapshot is what it was.
|
||||
m.call(
|
||||
"books.update_party",
|
||||
json!({"party": party.to_string(), "billing_address": "99 New Way", "phone": "555-9999"}),
|
||||
);
|
||||
let again = Mcp::events(&books, "invoice.created");
|
||||
assert_eq!(
|
||||
again.last().unwrap()["payload"]["bill_to"]["address"],
|
||||
"12 Billing Way, Portland, OR 97209",
|
||||
"an issued copy never changes"
|
||||
);
|
||||
// The next invoice takes today's details.
|
||||
m.call("books.create_invoice", issue("2026-03-01"));
|
||||
let third = Mcp::events(&books, "invoice.created");
|
||||
assert_eq!(
|
||||
third.last().unwrap()["payload"]["bill_to"]["address"],
|
||||
"99 New Way"
|
||||
);
|
||||
assert_eq!(
|
||||
third.last().unwrap()["payload"]["bill_to"]["phone"],
|
||||
"555-9999"
|
||||
);
|
||||
|
||||
// A one-off bill-to override carries the whole block, for this
|
||||
// document alone.
|
||||
m.call(
|
||||
"books.create_invoice",
|
||||
json!({"customer": harbor.to_string(), "date": "2026-04-01", "terms": "Net 30",
|
||||
"lines": [{"description": "Coffee", "amount": "100.00"}],
|
||||
"bill_to": {"name": "Harbor Coffee — Accounts Payable",
|
||||
"billing_address": "1 One-Off Plaza",
|
||||
"billing_email": "oneoff@harbor.example",
|
||||
"contact": "Sam Ortiz", "phone": "555-0100",
|
||||
"shipping_address": "Gate 2"},
|
||||
"confirmed": true}),
|
||||
);
|
||||
let overridden = Mcp::events(&books, "invoice.created");
|
||||
let bill_to = &overridden.last().unwrap()["payload"]["bill_to"];
|
||||
assert_eq!(bill_to["name"], "Harbor Coffee — Accounts Payable");
|
||||
assert_eq!(bill_to["address"], "1 One-Off Plaza", "{bill_to}");
|
||||
assert_eq!(bill_to["email"], "oneoff@harbor.example", "{bill_to}");
|
||||
assert_eq!(bill_to["contact"], "Sam Ortiz", "{bill_to}");
|
||||
assert_eq!(bill_to["phone"], "555-0100", "{bill_to}");
|
||||
assert_eq!(bill_to["shipping_address"], "Gate 2", "{bill_to}");
|
||||
}
|
||||
@@ -611,6 +611,63 @@ fn format_upgrade_waits_for_consent() {
|
||||
assert_eq!(format_lines(&dir), 1);
|
||||
}
|
||||
|
||||
/// plan P4b S4.4 (R-A step 4, 8.0.6 item 3): on books that haven't
|
||||
/// declared `documents_v2`, an invoice carrying a new field (terms) is
|
||||
/// refused with the upgrade remedy and journals nothing, while one
|
||||
/// carrying none posts as before; after the owner's go-ahead the same call
|
||||
/// is made, and takes the first number.
|
||||
#[test]
|
||||
fn documents_v2_refused_until_consent() {
|
||||
let home = Home::new("documents-v2");
|
||||
let dir = old_books(&home, "acme");
|
||||
let _held = serve(&home);
|
||||
let out = home
|
||||
.cmd("token")
|
||||
.args(["create", "--book", "acme"])
|
||||
.stdin(Stdio::null())
|
||||
.output()
|
||||
.unwrap();
|
||||
assert!(out.status.success(), "{out:?}");
|
||||
let mut a = Agent::start(&home, "acme");
|
||||
let customer = a
|
||||
.call("books.add_customer", json!({"name": "Harbor Coffee"}))
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let line = json!([{"description": "Design", "amount": "500.00"}]);
|
||||
let with_terms = json!({"customer": customer.to_string(), "date": "2026-02-10",
|
||||
"terms": "Net 30", "lines": line, "confirmed": true});
|
||||
let before = journal(&dir);
|
||||
let refused = a
|
||||
.call("books.create_invoice", with_terms.clone())
|
||||
.unwrap_err();
|
||||
assert!(refused.starts_with("format_upgrade_pending"), "{refused}");
|
||||
assert!(refused.contains("documents_v2"), "{refused}");
|
||||
assert_eq!(journal(&dir), before, "a refusal journals nothing");
|
||||
// A document carrying none of the new fields posts as before, and
|
||||
// files its copy; it takes no number (an older version reads it).
|
||||
let plain = a
|
||||
.call(
|
||||
"books.create_invoice",
|
||||
json!({"customer": customer.to_string(), "date": "2026-02-10", "due_date": "2026-03-12",
|
||||
"lines": line, "confirmed": true}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(plain.get("number").is_none(), "{plain}");
|
||||
assert_eq!(plain["total"], "500.00");
|
||||
|
||||
// The go-ahead.
|
||||
let mut words = home.words("books");
|
||||
words.extend(["upgrade".into(), "acme".into()]);
|
||||
let (code, shown) = home.at_terminal(&words, &[PASS]);
|
||||
assert_eq!(code, 0, "{shown}");
|
||||
assert_eq!(format_lines(&dir), 1, "the format moved once");
|
||||
let made = a.call("books.create_invoice", with_terms).unwrap();
|
||||
assert_eq!(made["number"], "INV-0001", "{made}");
|
||||
assert_eq!(made["terms"], "Net 30", "{made}");
|
||||
assert_eq!(made["due_date"], "2026-03-12", "{made}");
|
||||
a.call("books.verify", json!({})).unwrap();
|
||||
}
|
||||
|
||||
/// Copy the tree `from` into `to`, private as the server keeps a book
|
||||
/// (folders 0700, files 0600).
|
||||
fn copy_private(from: &Path, to: &Path) {
|
||||
@@ -705,17 +762,24 @@ fn books_p2_served_cut_over_whole() {
|
||||
.unwrap();
|
||||
let waiting = journal(&dir);
|
||||
let refused: Vec<(&str, Value)> = vec![
|
||||
// An invoice's or bill's line naming its project
|
||||
// (document_line_projects, rules.md B-37 item 8).
|
||||
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and
|
||||
// its line naming its project (document_line_projects, rules.md
|
||||
// B-37 item 8).
|
||||
(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": 1, "date": "2026-02-10", "due_date": "2026-03-12",
|
||||
"lines": [{"description": "Design", "amount": "500.00", "class": "Lighthouse"}]}),
|
||||
json!({"customer": "1", "date": "2026-02-10", "terms": "Net 30",
|
||||
"lines": [{"description": "Design", "amount": "500.00", "class": "Lighthouse"}],
|
||||
"confirmed": true}),
|
||||
),
|
||||
// A bill carrying documents_v2's fields (the vendor's number, the
|
||||
// line's account, its invoice: A-10, A-13) and its line's project.
|
||||
(
|
||||
"books.create_bill",
|
||||
json!({"party_id": 1, "date": "2026-02-11", "due_date": "2026-03-13",
|
||||
"lines": [{"description": "Drafting", "amount": "120.00", "class": "Lighthouse"}]}),
|
||||
json!({"vendor": "1", "date": "2026-02-11", "due_date": "2026-03-13",
|
||||
"vendor_ref": "L-77",
|
||||
"lines": [{"description": "Drafting", "amount": "120.00",
|
||||
"account": "6600", "class": "Lighthouse"}],
|
||||
"existing_attachment_id": 1, "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.set_setting",
|
||||
|
||||
@@ -1019,18 +1019,20 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
|
||||
"books.create_invoice",
|
||||
json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01",
|
||||
"lines": [{"description": "a", "amount": MAX},
|
||||
{"description": "b", "amount": "1"}]}),
|
||||
{"description": "b", "amount": "1"}], "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.create_bill",
|
||||
json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01",
|
||||
"lines": [{"description": "a", "amount": MAX},
|
||||
{"description": "b", "amount": "1"}]}),
|
||||
"vendor_ref": "H-1",
|
||||
"lines": [{"description": "a", "amount": MAX, "account": "6000"},
|
||||
{"description": "b", "amount": "1", "account": "6000"}],
|
||||
"existing_attachment_id": 1, "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": client, "date": "2026-05-01", "due_date": "+262142-12-31",
|
||||
"lines": [{"description": "a", "amount": "1"}]}),
|
||||
"lines": [{"description": "a", "amount": "1"}], "confirmed": true}),
|
||||
),
|
||||
(
|
||||
"books.record_payout",
|
||||
|
||||
@@ -1178,7 +1178,10 @@ async fn a_token_does_only_what_its_scopes_allow() {
|
||||
tools.contains(&"books.post_entry".to_string()),
|
||||
"it may preview"
|
||||
);
|
||||
assert!(!tools.contains(&"books.create_invoice".to_string()));
|
||||
assert!(
|
||||
!tools.contains(&"books.receive_payment".to_string()),
|
||||
"it may preview an invoice, but not post a payment"
|
||||
);
|
||||
let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await;
|
||||
if let Err(e) = &preview {
|
||||
assert!(
|
||||
|
||||
@@ -230,7 +230,14 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
||||
server.tool("books.add_project", json!({"name": "Heron"}));
|
||||
let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
|
||||
let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone();
|
||||
let doc = |party: &Value, date: &str, lines: Value| json!({"party_id": party, "date": date, "due_date": date, "lines": lines});
|
||||
let doc = |party: &Value, date: &str, lines: Value| {
|
||||
json!({"party_id": party, "date": date, "due_date": date, "lines": lines,
|
||||
"confirmed": true})
|
||||
};
|
||||
server.tool(
|
||||
"books.add_account",
|
||||
json!({"code": "6500", "name": "Supplies", "account_type": "expense"}),
|
||||
);
|
||||
let inv = server.tool(
|
||||
"books.create_invoice",
|
||||
doc(
|
||||
@@ -251,17 +258,20 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
||||
json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]),
|
||||
),
|
||||
);
|
||||
let bill = server.tool(
|
||||
"books.create_bill",
|
||||
doc(
|
||||
&dana,
|
||||
"2026-03-09",
|
||||
json!([
|
||||
{"description": "Illustration", "amount": "987.65", "class": "Kestrel"},
|
||||
{"description": "Postage", "amount": "12.00"},
|
||||
]),
|
||||
),
|
||||
let bill_file = server
|
||||
.wire
|
||||
.upload(b"Dana's invoice", "dana.pdf", "application/pdf", "receipt");
|
||||
let mut bill_args = doc(
|
||||
&dana,
|
||||
"2026-03-09",
|
||||
json!([
|
||||
{"description": "Illustration", "amount": "987.65", "account": "6500", "class": "Kestrel"},
|
||||
{"description": "Postage", "amount": "12.00", "account": "6500"},
|
||||
]),
|
||||
);
|
||||
bill_args["vendor_ref"] = json!("DANA-9");
|
||||
bill_args["upload_id"] = json!(bill_file);
|
||||
let bill = server.tool("books.create_bill", bill_args);
|
||||
server.tool(
|
||||
"books.receive_payment",
|
||||
json!({"document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}),
|
||||
@@ -329,7 +339,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|l| l["code"] == "4000" || l["code"] == "6999")
|
||||
.filter(|l| matches!(l["code"].as_str(), Some("4000" | "6500" | "6999")))
|
||||
.map(|l| l["project"].as_str().map(str::to_string))
|
||||
.collect();
|
||||
assert_eq!(viewer, served, "entry {entry}");
|
||||
|
||||
@@ -421,16 +421,20 @@ fn a_file_links_to_every_kind_of_subject() {
|
||||
m.add_party("Acme Design", &["contractor"]);
|
||||
let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"].clone();
|
||||
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"].clone();
|
||||
let rent_invoice = m.upload(b"rent invoice", "rent.pdf", "application/pdf", "receipt");
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
|
||||
"lines": [{"description": "Rent", "amount": "300.00"}]}),
|
||||
"vendor_ref": "RENT-2",
|
||||
"lines": [{"description": "Rent", "amount": "300.00", "account": "6000"}],
|
||||
"upload_id": rent_invoice, "confirmed": true}),
|
||||
)["id"]
|
||||
.clone();
|
||||
let invoice = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "Work", "amount": "500.00"}]}),
|
||||
"lines": [{"description": "Work", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
.clone();
|
||||
let period = m.ok(
|
||||
|
||||
@@ -857,7 +857,8 @@ fn happy_path(name: &str) -> Mcp {
|
||||
let inv = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -865,10 +866,18 @@ fn happy_path(name: &str) -> Mcp {
|
||||
"books.receive_payment",
|
||||
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
|
||||
);
|
||||
let vendor_invoice = m.upload(
|
||||
b"vendor invoice",
|
||||
"vendor-invoice.pdf",
|
||||
"application/pdf",
|
||||
"receipt",
|
||||
);
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
|
||||
"lines": [{"description": "Office rent", "amount": "300.00"}]}),
|
||||
"vendor_ref": "V-1001",
|
||||
"lines": [{"description": "Office rent", "amount": "300.00", "account": "6000"}],
|
||||
"upload_id": vendor_invoice, "confirmed": true}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -882,7 +891,8 @@ fn happy_path(name: &str) -> Mcp {
|
||||
let dup = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}),
|
||||
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -1297,6 +1307,9 @@ struct Known {
|
||||
periods: Vec<u64>,
|
||||
vendors: Vec<u64>,
|
||||
bills: Vec<u64>,
|
||||
/// The vendor's invoice every bill filed in this run names (P4b S4.4:
|
||||
/// a bill keeps the file it was entered from).
|
||||
vendor_invoice: u64,
|
||||
}
|
||||
|
||||
/// One step of the random walk.
|
||||
@@ -1363,6 +1376,9 @@ enum Op {
|
||||
CreateBill {
|
||||
party: Pick,
|
||||
amount: String,
|
||||
/// The vendor's own invoice number: two bills by one vendor with
|
||||
/// the same number are refused (B-40).
|
||||
vendor_ref: String,
|
||||
},
|
||||
PayBill {
|
||||
doc: Pick,
|
||||
@@ -1444,7 +1460,8 @@ fn op_strategy() -> impl Strategy<Value = Op> {
|
||||
prop::sample::select(vec!["asset", "expense", "revenue", "bogus"]))
|
||||
.prop_map(|(c, t)| Op::AddAccount { code: c.to_string(), ty: t.to_string() }),
|
||||
1 => prop::sample::select(vec!["Acme", "Acme", "", "1"]).prop_map(|n| Op::AddVendor { name: n.to_string() }),
|
||||
1 => (pick(), amount_strategy()).prop_map(|(party, amount)| Op::CreateBill { party, amount }),
|
||||
1 => (pick(), amount_strategy(), 0u64..1000)
|
||||
.prop_map(|(party, amount, n)| Op::CreateBill { party, amount, vendor_ref: format!("V-{n}") }),
|
||||
1 => (pick(), amount_strategy()).prop_map(|(doc, amount)| Op::PayBill { doc, amount }),
|
||||
1 => amount_strategy().prop_map(|deposit| Op::Payout { deposit }),
|
||||
1 => (account_strategy(), prop::sample::select(vec!["8000", "1000", ""]))
|
||||
@@ -1578,10 +1595,16 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
|
||||
json!({"code": code, "name": format!("Acct {code}"), "account_type": ty}),
|
||||
),
|
||||
Op::AddVendor { name } => ("books.add_vendor", json!({"name": name})),
|
||||
Op::CreateBill { party, amount } => (
|
||||
Op::CreateBill {
|
||||
party,
|
||||
amount,
|
||||
vendor_ref,
|
||||
} => (
|
||||
"books.create_bill",
|
||||
json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01",
|
||||
"due_date": "2026-03-31", "lines": [{"description": "thing", "amount": amount}]}),
|
||||
"due_date": "2026-03-31", "vendor_ref": vendor_ref,
|
||||
"lines": [{"description": "thing", "amount": amount, "account": "6000"}],
|
||||
"existing_attachment_id": k.vendor_invoice, "confirmed": true}),
|
||||
),
|
||||
Op::PayBill { doc, amount } => (
|
||||
"books.pay_bill",
|
||||
@@ -1647,7 +1670,10 @@ proptest! {
|
||||
let toml = books_toml(&dir, "25.00", "");
|
||||
let mut m = Mcp::start(&dir, &toml);
|
||||
m.accounts(BASE_CHART);
|
||||
let mut known = Known::default();
|
||||
let mut known = Known {
|
||||
vendor_invoice: m.attach(None, b"a vendor's invoice"),
|
||||
..Known::default()
|
||||
};
|
||||
for op in &ops {
|
||||
run_op(&mut m, &mut known, op);
|
||||
}
|
||||
@@ -2482,7 +2508,8 @@ fn assigned_roles_steer_invoices() {
|
||||
m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
);
|
||||
let tb = m.ok("books.trial_balance", json!({})).to_string();
|
||||
assert!(tb.contains("1110") && tb.contains("4100"), "{tb}");
|
||||
@@ -2544,7 +2571,8 @@ fn documents_void_and_tie_out_over_the_wire() {
|
||||
let inv = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
|
||||
"lines": [{"description": "Consulting", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -2574,7 +2602,8 @@ fn documents_void_and_tie_out_over_the_wire() {
|
||||
let dup = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}),
|
||||
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
@@ -2641,7 +2670,8 @@ fn documents_list_and_timeline_over_the_wire() {
|
||||
m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": customer, "date": date, "due_date": due,
|
||||
"lines": [{"description": "Consulting", "amount": amount}]}),
|
||||
"lines": [{"description": "Consulting", "amount": amount}],
|
||||
"confirmed": true}),
|
||||
)["id"]
|
||||
.as_u64()
|
||||
.unwrap()
|
||||
|
||||
@@ -827,10 +827,18 @@ fn party_totals_match_a_hand_computed_year() {
|
||||
.as_u64()
|
||||
.unwrap();
|
||||
// A bill of 800 (not paid until paid), 300 of it paid from the bank.
|
||||
let logo_invoice = m.wire.upload(
|
||||
b"logo invoice",
|
||||
"logo-invoice.pdf",
|
||||
"application/pdf",
|
||||
"receipt",
|
||||
);
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": v, "date": "2026-07-01", "due_date": "2026-07-31",
|
||||
"lines": [{"description": "Logo", "amount": "800.00"}]}),
|
||||
"vendor_ref": "D-1041",
|
||||
"lines": [{"description": "Logo", "amount": "800.00", "account": "6000"}],
|
||||
"upload_id": logo_invoice, "confirmed": true}),
|
||||
);
|
||||
let pay = m.ok(
|
||||
"books.pay_bill",
|
||||
@@ -910,7 +918,8 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
|
||||
let inv = m.ok(
|
||||
"books.create_invoice",
|
||||
json!({"party_id": c, "date": "2026-03-01", "due_date": "2026-03-31",
|
||||
"lines": [{"description": "Work", "amount": "1000.00"}]}),
|
||||
"lines": [{"description": "Work", "amount": "1000.00"}],
|
||||
"confirmed": true}),
|
||||
);
|
||||
let pay = m.ok(
|
||||
"books.receive_payment",
|
||||
|
||||
@@ -1236,7 +1236,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
|
||||
{"description": "Hosting", "amount": "200.00", "class": harbor.to_string()},
|
||||
{"description": "Rush fee", "amount": "100.00"},
|
||||
{"description": "Revisions", "amount": "500.00", "class": " zeta SITE "},
|
||||
]}),
|
||||
], "confirmed": true}),
|
||||
);
|
||||
assert_eq!(inv["total"], "3800.00");
|
||||
assert!(inv.get("notes").is_none(), "{inv}");
|
||||
@@ -1256,19 +1256,27 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
|
||||
m.entry_class_ids(inv_entry),
|
||||
vec![None, Some(zeta), Some(harbor), None]
|
||||
);
|
||||
let dana_invoice = m.wire.upload(
|
||||
b"Dana's invoice",
|
||||
"dana-invoice.pdf",
|
||||
"application/pdf",
|
||||
"receipt",
|
||||
);
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08", "lines": [
|
||||
{"description": "Illustration", "amount": "1200.00", "class": "Zeta site"},
|
||||
{"description": "Postage", "amount": "50.00"},
|
||||
]}),
|
||||
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08",
|
||||
"vendor_ref": "DANA-77",
|
||||
"lines": [
|
||||
{"description": "Illustration", "amount": "1200.00", "account": "6000", "class": "Zeta site"},
|
||||
{"description": "Postage", "amount": "50.00", "account": "6000"},
|
||||
], "upload_id": dana_invoice, "confirmed": true}),
|
||||
);
|
||||
let bill_entry = bill["entry_id"].as_u64().unwrap();
|
||||
assert_eq!(
|
||||
m.entry_lines(bill_entry),
|
||||
vec![
|
||||
l4("6999", "debit", "1200.00", Some("Zeta site")),
|
||||
l4("6999", "debit", "50.00", None),
|
||||
l4("6000", "debit", "1200.00", Some("Zeta site")),
|
||||
l4("6000", "debit", "50.00", None),
|
||||
l4("2000", "credit", "1250.00", None),
|
||||
]
|
||||
);
|
||||
@@ -1375,7 +1383,7 @@ fn a_document_for_a_finished_project_says_so() {
|
||||
{"description": "Final", "amount": "10.00", "class": "Harbor"},
|
||||
{"description": "Again", "amount": "10.00", "class": "harbor"},
|
||||
{"description": "Old", "amount": "10.00", "class": "Dock"},
|
||||
]}),
|
||||
], "confirmed": true}),
|
||||
);
|
||||
let notes: Vec<&str> = out["notes"]
|
||||
.as_array()
|
||||
@@ -1420,7 +1428,7 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
|
||||
{"description": "Build", "amount": "10.00", "class": "Kestrel"},
|
||||
{"description": "More", "amount": "5.00", "class": "kestrel"},
|
||||
{"description": "Site", "amount": "7.00", "class": "Zeta"},
|
||||
]}),
|
||||
], "confirmed": true}),
|
||||
);
|
||||
let notes = out["notes"].as_array().unwrap();
|
||||
assert_eq!(notes.len(), 1, "{out}");
|
||||
@@ -1434,11 +1442,19 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
|
||||
m.entry_lines(e)[1],
|
||||
l4("4000", "credit", "15.00", Some("Kestrel"))
|
||||
);
|
||||
let icons_invoice = m.wire.upload(
|
||||
b"icons invoice",
|
||||
"icons-invoice.pdf",
|
||||
"application/pdf",
|
||||
"receipt",
|
||||
);
|
||||
let bill = m.ok(
|
||||
"books.create_bill",
|
||||
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
|
||||
{"description": "Icons", "amount": "10.00", "class": "Kestrel"},
|
||||
]}),
|
||||
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04",
|
||||
"vendor_ref": "V-512",
|
||||
"lines": [
|
||||
{"description": "Icons", "amount": "10.00", "account": "6000", "class": "Kestrel"},
|
||||
], "upload_id": icons_invoice, "confirmed": true}),
|
||||
);
|
||||
assert!(bill.get("notes").is_none(), "{bill}");
|
||||
}
|
||||
@@ -1473,7 +1489,7 @@ fn books_without_the_feature_refuse_a_tagged_document() {
|
||||
"books.create_invoice",
|
||||
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||
{"description": "a", "amount": "10.00"},
|
||||
]}),
|
||||
], "confirmed": true}),
|
||||
);
|
||||
let text = m.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"]
|
||||
.as_str()
|
||||
|
||||
@@ -270,10 +270,18 @@ fn require_tax_category_refuses_defect_8() {
|
||||
// A bill creates General Expenses 6999 without a category: never
|
||||
// refused, named in the result's warnings and the digest.
|
||||
let vendor = m.call("books.add_vendor", json!({"name": "Coastline Hosting"}));
|
||||
let first_invoice = m.wire.upload(
|
||||
b"Coastline's invoice",
|
||||
"coastline-1.pdf",
|
||||
"application/pdf",
|
||||
"receipt",
|
||||
);
|
||||
let bill = m.call(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01",
|
||||
"lines": [{"description": "hosting", "amount": "42.00"}]}),
|
||||
"vendor_ref": "CH-1",
|
||||
"lines": [{"description": "hosting", "amount": "42.00", "account": "6150"}],
|
||||
"upload_id": first_invoice, "confirmed": true}),
|
||||
);
|
||||
assert_eq!(
|
||||
bill["warnings"],
|
||||
@@ -287,10 +295,18 @@ fn require_tax_category_refuses_defect_8() {
|
||||
json!(["6999 General Expenses"])
|
||||
);
|
||||
// A second bill creates nothing, and warns of nothing.
|
||||
let second_invoice = m.wire.upload(
|
||||
b"Coastline's second invoice",
|
||||
"coastline-2.pdf",
|
||||
"application/pdf",
|
||||
"receipt",
|
||||
);
|
||||
let again = m.call(
|
||||
"books.create_bill",
|
||||
json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02",
|
||||
"lines": [{"description": "hosting", "amount": "1.00"}]}),
|
||||
"vendor_ref": "CH-2",
|
||||
"lines": [{"description": "hosting", "amount": "1.00", "account": "6150"}],
|
||||
"upload_id": second_invoice, "confirmed": true}),
|
||||
);
|
||||
assert!(again.get("warnings").is_none(), "{again}");
|
||||
// Back to warn (cleared): created, with the warning.
|
||||
|
||||
@@ -711,18 +711,65 @@ fn last_day_of_month(date: NaiveDate) -> Result<NaiveDate, LedgerError> {
|
||||
}
|
||||
|
||||
/// The parties on a document, as they were when it was issued (plan P4b
|
||||
/// S4.1, A-13): a name, and the address and email that were on the
|
||||
/// document. Never today's party record when a snapshot exists.
|
||||
/// S4.1, A-13; the contact block, D-55): a name, and the address and email
|
||||
/// that were on the document, with the phone, contact person, billing and
|
||||
/// shipping details when the party had them. Never today's party record
|
||||
/// when a snapshot exists.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct PartySnapshot {
|
||||
/// The party's name at issue.
|
||||
pub name: String,
|
||||
/// Its address at issue, when the document printed one.
|
||||
/// Its address at issue, as the document printed it: the billing
|
||||
/// address when the party has one, else its general one (D-55).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub address: Option<String>,
|
||||
/// Its email at issue, when the document printed one.
|
||||
/// Its email at issue, as the document printed it: the billing email
|
||||
/// when the party has one, else its general one (D-55).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub email: Option<String>,
|
||||
/// Its telephone number at issue (D-55).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub phone: Option<String>,
|
||||
/// The person there to write to, at issue (D-55).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub contact: Option<String>,
|
||||
/// Where its bills go, when it said so (D-55). [`Self::address`] is
|
||||
/// this one whenever it is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub billing_address: Option<String>,
|
||||
/// Where goods go, when it said so (D-55): the issued page prints a
|
||||
/// Ship-to line for it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub shipping_address: Option<String>,
|
||||
/// Where its bills are emailed, when it said so (D-55). [`Self::email`]
|
||||
/// is this one whenever it is set.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub billing_email: Option<String>,
|
||||
}
|
||||
|
||||
impl PartySnapshot {
|
||||
/// The snapshot of `party` at issue (A-13, D-55): its name, and the
|
||||
/// contact details as they stood, with the billing address and billing
|
||||
/// email in place of the general ones when it has them. The one place
|
||||
/// the bill-to rule lives, so every document agrees.
|
||||
pub fn from_party(party: &crate::Party) -> Self {
|
||||
PartySnapshot {
|
||||
name: party
|
||||
.display_name
|
||||
.clone()
|
||||
.unwrap_or_else(|| party.name.clone()),
|
||||
address: party
|
||||
.billing_address
|
||||
.clone()
|
||||
.or_else(|| party.address.clone()),
|
||||
email: party.billing_email.clone().or_else(|| party.email.clone()),
|
||||
phone: party.phone.clone(),
|
||||
contact: party.contact.clone(),
|
||||
billing_address: party.billing_address.clone(),
|
||||
shipping_address: party.shipping_address.clone(),
|
||||
billing_email: party.billing_email.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// How an invoice was sent (plan P4b S5; the type S4.1 adds so
|
||||
|
||||
@@ -4453,6 +4453,11 @@ impl Ledger {
|
||||
email: None,
|
||||
default_terms_as_customer: None,
|
||||
default_terms_as_vendor: None,
|
||||
phone: None,
|
||||
contact: None,
|
||||
billing_address: None,
|
||||
shipping_address: None,
|
||||
billing_email: None,
|
||||
address: None,
|
||||
notes: None,
|
||||
archived: false,
|
||||
@@ -4582,6 +4587,11 @@ impl Ledger {
|
||||
email: None,
|
||||
default_terms_as_customer: None,
|
||||
default_terms_as_vendor: None,
|
||||
phone: None,
|
||||
contact: None,
|
||||
billing_address: None,
|
||||
shipping_address: None,
|
||||
billing_email: None,
|
||||
address: None,
|
||||
notes: None,
|
||||
archived: false,
|
||||
@@ -4656,6 +4666,11 @@ impl Ledger {
|
||||
Email(v) => p.email = v,
|
||||
DefaultTermsAsCustomer(v) => p.default_terms_as_customer = v,
|
||||
DefaultTermsAsVendor(v) => p.default_terms_as_vendor = v,
|
||||
Phone(v) => p.phone = v,
|
||||
Contact(v) => p.contact = v,
|
||||
BillingAddress(v) => p.billing_address = v,
|
||||
ShippingAddress(v) => p.shipping_address = v,
|
||||
BillingEmail(v) => p.billing_email = v,
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -4707,6 +4722,18 @@ impl Ledger {
|
||||
crate::parties::check_email("email", e).map_err(LedgerError::InvalidParty)?;
|
||||
}
|
||||
}
|
||||
// The D-55 contact block: two addresses and a phone like any
|
||||
// other text (no full TIN), the billing email an address.
|
||||
Phone(v) => text("phone", v)?,
|
||||
Contact(v) => text("contact", v)?,
|
||||
BillingAddress(v) => text("billing_address", v)?,
|
||||
ShippingAddress(v) => text("shipping_address", v)?,
|
||||
BillingEmail(v) => {
|
||||
if let Some(e) = v {
|
||||
crate::parties::check_email("billing_email", e)
|
||||
.map_err(LedgerError::InvalidParty)?;
|
||||
}
|
||||
}
|
||||
// Terms are typed and already canonical (`crate::Terms`), so
|
||||
// there is nothing here a string could get wrong.
|
||||
DefaultTermsAsCustomer(_) | DefaultTermsAsVendor(_) => {}
|
||||
|
||||
@@ -234,6 +234,24 @@ pub struct Party {
|
||||
/// an invoice's bill-to snapshot at issue.
|
||||
#[serde(default)]
|
||||
pub email: Option<String>,
|
||||
/// Telephone number (D-55): printed on the invoice's bill-to.
|
||||
#[serde(default)]
|
||||
pub phone: Option<String>,
|
||||
/// The named person there to write to (D-55).
|
||||
#[serde(default)]
|
||||
pub contact: Option<String>,
|
||||
/// Where its bills go (D-55): the invoice's bill-to address when set,
|
||||
/// else [`Self::address`].
|
||||
#[serde(default)]
|
||||
pub billing_address: Option<String>,
|
||||
/// Where goods go (D-55): the issued invoice prints a Ship-to line
|
||||
/// when it is set.
|
||||
#[serde(default)]
|
||||
pub shipping_address: Option<String>,
|
||||
/// Where its bills are emailed (D-55): the bill-to email when set,
|
||||
/// else [`Self::email`].
|
||||
#[serde(default)]
|
||||
pub billing_email: Option<String>,
|
||||
/// The terms this party grants the company as a customer: an invoice's
|
||||
/// due date falls back to these (A-13).
|
||||
#[serde(default)]
|
||||
@@ -778,11 +796,21 @@ pub enum PartyField {
|
||||
DefaultTermsAsCustomer(Option<crate::Terms>),
|
||||
/// The terms it gives the company as a vendor (A-13; `None` clears).
|
||||
DefaultTermsAsVendor(Option<crate::Terms>),
|
||||
/// The telephone number (D-55; `None` clears it).
|
||||
Phone(Option<String>),
|
||||
/// The named person there to write to (D-55; `None` clears it).
|
||||
Contact(Option<String>),
|
||||
/// Where its bills go (D-55; `None` clears it).
|
||||
BillingAddress(Option<String>),
|
||||
/// Where goods go (D-55; `None` clears it).
|
||||
ShippingAddress(Option<String>),
|
||||
/// Where its bills are emailed (D-55; `None` clears it).
|
||||
BillingEmail(Option<String>),
|
||||
}
|
||||
|
||||
impl PartyField {
|
||||
/// Every field's wire name, as `party.updated` journals it.
|
||||
pub const FIELDS: [&'static str; 9] = [
|
||||
pub const FIELDS: [&'static str; 14] = [
|
||||
"name",
|
||||
"display_name",
|
||||
"website",
|
||||
@@ -792,6 +820,11 @@ impl PartyField {
|
||||
"email",
|
||||
"default_terms_as_customer",
|
||||
"default_terms_as_vendor",
|
||||
"phone",
|
||||
"contact",
|
||||
"billing_address",
|
||||
"shipping_address",
|
||||
"billing_email",
|
||||
];
|
||||
|
||||
/// Whether `field` is a field this version knows. A `party.updated`
|
||||
@@ -800,14 +833,19 @@ impl PartyField {
|
||||
Self::FIELDS.contains(&field)
|
||||
}
|
||||
|
||||
/// Whether the field needs the `documents_v2` feature: the three A-13
|
||||
/// facts an older version would drop.
|
||||
/// Whether the field needs the `documents_v2` feature: the A-13 facts
|
||||
/// and the D-55 contact block, which an older version would drop.
|
||||
pub fn needs_documents_v2(&self) -> bool {
|
||||
matches!(
|
||||
self,
|
||||
PartyField::Email(_)
|
||||
| PartyField::DefaultTermsAsCustomer(_)
|
||||
| PartyField::DefaultTermsAsVendor(_)
|
||||
| PartyField::Phone(_)
|
||||
| PartyField::Contact(_)
|
||||
| PartyField::BillingAddress(_)
|
||||
| PartyField::ShippingAddress(_)
|
||||
| PartyField::BillingEmail(_)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,6 +127,11 @@ fn bare_party(name: &str) -> Party {
|
||||
email: None,
|
||||
default_terms_as_customer: None,
|
||||
default_terms_as_vendor: None,
|
||||
phone: None,
|
||||
contact: None,
|
||||
billing_address: None,
|
||||
shipping_address: None,
|
||||
billing_email: None,
|
||||
address: None,
|
||||
notes: None,
|
||||
archived: false,
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
- A new version of wanderbooks can keep the books in a newer journal format than the version that wrote them. Books an older version wrote wait for a format upgrade: books.start says so (format_upgrade) and puts it first among its next steps, in every session until it is done.
|
||||
- Until then they work on their old format: everything that needs nothing new works as before, and whatever would need the new format is refused with format_upgrade_pending, changing nothing. Never retry it, work around it, or post it another way.
|
||||
- An invoice's or bill's number, payment terms, seller and bill-to, references and memo, a line's account, quantity and unit price, the copy of an invoice kept exactly as it was issued, a party's email and default terms, and the invoicing and bills settings all wait for the documents_v2 upgrade. A document that names none of them is written exactly as before and is never refused; the work that needs one waits, and the books say so.
|
||||
- An invoice's or bill's number, payment terms, seller and bill-to, references and memo, a line's account, quantity and unit price, the copy of an invoice kept exactly as it was issued, a party's email, default terms and contact details (phone, contact person, billing and shipping addresses, billing email), and the invoicing and bills settings all wait for the documents_v2 upgrade. A document that names none of them is written exactly as before and is never refused; the work that needs one waits, and the books say so.
|
||||
- Only the owner can agree to the upgrade, with the owner passphrase: in the app, which asks each time it opens the books, or with `wanderbooks books upgrade ID` in a terminal. No tool can, and you never ask for, take or type the passphrase. Tell the human, in plain words: the books wait for their go-ahead, what was refused, and that saying no keeps the books working as they are.
|
||||
- Before it upgrades, the server makes a backup of the books, their vault and keys included, and proves it whole; a backup that can't be made or proven upgrades nothing. Then the books move to the new format in one step, on the record.
|
||||
- After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours.
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
+274
-26
@@ -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>,
|
||||
}
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::settings::Company;
|
||||
use ledger_core::{DocRef, DocStatus, InvoiceId, Ledger};
|
||||
use ledger_core::{DocRef, DocStatus, Invoice, InvoiceId, Ledger};
|
||||
use ledger_import::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp};
|
||||
|
||||
/// A date as an invoice prints it: "Feb 1, 2026".
|
||||
@@ -155,6 +155,115 @@ pub fn invoice_document(
|
||||
})
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// files with it. No stamp and no note, because this is the copy the
|
||||
/// customer gets. Its number is the one the issue will take (A-2), its
|
||||
/// seller and bill-to the snapshots the issue will store (A-13) — read
|
||||
/// from `ledger` when a document made before snapshots has none — and
|
||||
/// nothing is settled on it yet.
|
||||
pub fn issued_invoice_document(
|
||||
ledger: &Ledger,
|
||||
company: &Company,
|
||||
invoice: &Invoice,
|
||||
) -> Result<InvoiceDoc, String> {
|
||||
unissued_document(ledger, company, invoice, None, None)
|
||||
}
|
||||
|
||||
/// The DRAFT copy of an invoice not yet issued (plan P4b S4.4): the PDF a
|
||||
/// preview hands over. The issued copy's page, stamped DRAFT, saying that
|
||||
/// nothing is posted and that it is not the copy sent.
|
||||
pub fn draft_invoice_document(
|
||||
ledger: &Ledger,
|
||||
company: &Company,
|
||||
invoice: &Invoice,
|
||||
) -> Result<InvoiceDoc, String> {
|
||||
unissued_document(
|
||||
ledger,
|
||||
company,
|
||||
invoice,
|
||||
Some(InvoiceStamp::Draft),
|
||||
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)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -40,7 +40,7 @@ pub use attention::{
|
||||
};
|
||||
pub use books::PackageBooks;
|
||||
pub use iif::export_iif;
|
||||
pub use invoice::invoice_document;
|
||||
pub use invoice::{draft_invoice_document, invoice_document, issued_invoice_document};
|
||||
pub use k1_tax::{open_text, tax_basis_csv};
|
||||
pub use package::{Package, PackageRequest, build_package, package_file_name};
|
||||
pub use reports::{
|
||||
|
||||
@@ -48,7 +48,7 @@ pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||
pub use snapshot::{DiskCheck, Snapshot};
|
||||
pub use store::{
|
||||
Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview,
|
||||
JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation,
|
||||
Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for,
|
||||
JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, PreviewPosting,
|
||||
Reconciliation, Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for,
|
||||
is_database_file,
|
||||
};
|
||||
@@ -322,6 +322,24 @@ pub struct Attachment {
|
||||
/// write-path signature reads as input rather than stored state.
|
||||
pub type PayrollLineInput = PayrollLine;
|
||||
|
||||
/// One line of a previewed posting, the way the page shows it: the
|
||||
/// account's code and name (resolved in the probe, so an account the
|
||||
/// preview would create is named even though the live books don't have it
|
||||
/// yet), its side, amount and project (plan P4b S4.4).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct PreviewPosting {
|
||||
/// The account's code ("1200").
|
||||
pub account: String,
|
||||
/// Its name ("Accounts Receivable").
|
||||
pub name: String,
|
||||
/// Debit or credit.
|
||||
pub side: Side,
|
||||
/// The amount.
|
||||
pub amount: Decimal,
|
||||
/// The project it is tagged with, if any.
|
||||
pub class_id: Option<ClassId>,
|
||||
}
|
||||
|
||||
/// What issuing an invoice would post and take, journaling nothing
|
||||
/// ([`Store::preview_invoice`]; plan P4b S4.3 item 12). The posting is the
|
||||
/// entry's lines in order: account, side, amount, project.
|
||||
@@ -329,6 +347,10 @@ pub type PayrollLineInput = PayrollLine;
|
||||
pub struct InvoicePreview {
|
||||
/// The entry it would post, line by line.
|
||||
pub posting: Vec<(AccountId, Side, Decimal, Option<ClassId>)>,
|
||||
/// The same lines with their accounts' codes and names (plan P4b
|
||||
/// S4.4: the preview page shows them, and an account the issue would
|
||||
/// create has no id the live books know yet).
|
||||
pub lines: Vec<PreviewPosting>,
|
||||
/// The document's total.
|
||||
pub total: Decimal,
|
||||
/// The number it would take (A-2), when the books number their
|
||||
@@ -336,6 +358,10 @@ pub struct InvoicePreview {
|
||||
pub number: Option<String>,
|
||||
/// Its due date.
|
||||
pub due_date: NaiveDate,
|
||||
/// The invoice the probe wrote: its lines, terms, snapshots and
|
||||
/// dates, exactly as the issue would store them. What the DRAFT copy
|
||||
/// prints (plan P4b S4.4).
|
||||
pub invoice: arap::Invoice,
|
||||
}
|
||||
|
||||
/// What recording a bill would post, journaling nothing
|
||||
@@ -344,6 +370,9 @@ pub struct InvoicePreview {
|
||||
pub struct BillPreview {
|
||||
/// The entry it would post, line by line.
|
||||
pub posting: Vec<(AccountId, Side, Decimal, Option<ClassId>)>,
|
||||
/// The same lines with their accounts' codes and names
|
||||
/// ([`InvoicePreview::lines`]).
|
||||
pub lines: Vec<PreviewPosting>,
|
||||
/// The document's total.
|
||||
pub total: Decimal,
|
||||
/// Its due date.
|
||||
@@ -354,6 +383,32 @@ pub struct BillPreview {
|
||||
/// refused, so a far-off date can't make one request write without end.
|
||||
pub const MAX_RECURRING_RUN: usize = 1000;
|
||||
|
||||
/// A probe entry's lines as a preview shows them: each account's code and
|
||||
/// name, read from the probe (plan P4b S4.4). The probe always holds the
|
||||
/// accounts its posting names, so a missing one is a bug, reported as an
|
||||
/// error rather than a panic.
|
||||
fn preview_lines(
|
||||
probe: &Ledger,
|
||||
lines: &[ledger_core::journal::JournalLine],
|
||||
) -> Result<Vec<PreviewPosting>, StoreError> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
let account = probe
|
||||
.chart()
|
||||
.get(l.account)
|
||||
.ok_or(LedgerError::UnknownAccount(l.account))?;
|
||||
Ok(PreviewPosting {
|
||||
account: account.code.clone(),
|
||||
name: account.name.clone(),
|
||||
side: l.side,
|
||||
amount: l.amount.amount,
|
||||
class_id: l.class_id,
|
||||
})
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// The store: one writer, one data directory.
|
||||
///
|
||||
/// `Store` is `Send` but not `Sync` (it owns a `rusqlite::Connection`).
|
||||
@@ -3317,9 +3372,11 @@ impl Store {
|
||||
.iter()
|
||||
.map(|l| (l.account, l.side, l.amount.amount, l.class_id))
|
||||
.collect(),
|
||||
lines: preview_lines(&probe, entry.lines())?,
|
||||
total: inv.total()?,
|
||||
number: fields.number,
|
||||
due_date: inv.due_date,
|
||||
invoice: inv.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -3493,6 +3550,7 @@ impl Store {
|
||||
.iter()
|
||||
.map(|l| (l.account, l.side, l.amount.amount, l.class_id))
|
||||
.collect(),
|
||||
lines: preview_lines(&probe, entry.lines())?,
|
||||
total: bill.total()?,
|
||||
due_date: bill.due_date,
|
||||
})
|
||||
|
||||
@@ -1062,3 +1062,216 @@ mod shares {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Oracle: D-55 (the owner's full contact block): a party's phone, contact
|
||||
/// person, billing and shipping addresses and billing email are
|
||||
/// `party.updated` facts under `documents_v2`; they journal one event
|
||||
/// apiece, replay gives them back, and the live ledger and a fresh replay
|
||||
/// agree.
|
||||
#[test]
|
||||
fn the_contact_block_round_trips_and_replays() {
|
||||
let dir = test_dir("contact-block");
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
let id = s.add_party("Harbor Coffee", &[]).unwrap();
|
||||
let fields = [
|
||||
(
|
||||
PartyField::Phone(Some("555-0142".into())),
|
||||
json!({"field": "phone", "value": "555-0142"}),
|
||||
),
|
||||
(
|
||||
PartyField::Contact(Some("Dana Reed".into())),
|
||||
json!({"field": "contact", "value": "Dana Reed"}),
|
||||
),
|
||||
(
|
||||
PartyField::BillingAddress(Some("12 Billing Way\nPortland, OR 97209".into())),
|
||||
json!({"field": "billing_address",
|
||||
"value": "12 Billing Way\nPortland, OR 97209"}),
|
||||
),
|
||||
(
|
||||
PartyField::ShippingAddress(Some("Dock 4, 9 Wharf Street".into())),
|
||||
json!({"field": "shipping_address", "value": "Dock 4, 9 Wharf Street"}),
|
||||
),
|
||||
(
|
||||
PartyField::BillingEmail(Some("ap@harbor.example".into())),
|
||||
json!({"field": "billing_email", "value": "ap@harbor.example"}),
|
||||
),
|
||||
];
|
||||
for (field, want) in &fields {
|
||||
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
|
||||
s.update_party(id, field.clone()).unwrap()
|
||||
});
|
||||
assert_eq!(p["change"], *want, "{field:?}");
|
||||
}
|
||||
let got = s.ledger().get_party(id).unwrap();
|
||||
assert_eq!(got.phone.as_deref(), Some("555-0142"));
|
||||
assert_eq!(got.contact.as_deref(), Some("Dana Reed"));
|
||||
assert_eq!(
|
||||
got.billing_address.as_deref(),
|
||||
Some("12 Billing Way\nPortland, OR 97209")
|
||||
);
|
||||
assert_eq!(
|
||||
got.shipping_address.as_deref(),
|
||||
Some("Dock 4, 9 Wharf Street")
|
||||
);
|
||||
assert_eq!(got.billing_email.as_deref(), Some("ap@harbor.example"));
|
||||
// The general email is its own fact, and neither replaces the other.
|
||||
s.update_party(id, PartyField::Email(Some("hello@harbor.example".into())))
|
||||
.unwrap();
|
||||
|
||||
// A full TIN in the block, or an address that isn't one, is refused.
|
||||
for field in [
|
||||
PartyField::Phone(Some("123-45-6789".into())),
|
||||
PartyField::BillingEmail(Some("not an address".into())),
|
||||
] {
|
||||
refused_nothing_journaled(&mut s, &dir, &format!("{field:?}"), |s| {
|
||||
s.update_party(id, field.clone())
|
||||
});
|
||||
}
|
||||
|
||||
s.verify().unwrap();
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
for without_db in [false, true] {
|
||||
if without_db {
|
||||
delete_db(&dir);
|
||||
}
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert!(
|
||||
*s.ledger() == live,
|
||||
"replay (db {}): {:?}",
|
||||
!without_db,
|
||||
s.ledger().differs_from(&live)
|
||||
);
|
||||
let got = s.ledger().get_party(id).unwrap();
|
||||
assert_eq!(got.contact.as_deref(), Some("Dana Reed"));
|
||||
assert_eq!(got.billing_email.as_deref(), Some("ap@harbor.example"));
|
||||
s.verify().unwrap();
|
||||
}
|
||||
// `None` clears each, and the journal keeps what they were.
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
for field in [
|
||||
PartyField::Phone(None),
|
||||
PartyField::Contact(None),
|
||||
PartyField::BillingAddress(None),
|
||||
PartyField::ShippingAddress(None),
|
||||
PartyField::BillingEmail(None),
|
||||
] {
|
||||
s.update_party(id, field).unwrap();
|
||||
}
|
||||
let got = s.ledger().get_party(id).unwrap();
|
||||
assert!(got.phone.is_none() && got.contact.is_none());
|
||||
assert!(got.billing_address.is_none() && got.shipping_address.is_none());
|
||||
assert!(got.billing_email.is_none());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// Oracle: D-55 and A-13: an invoice's bill-to snapshot is the customer's
|
||||
/// contact block at issue, with the billing address and billing email in
|
||||
/// place of the general ones when it has them
|
||||
/// (`PartySnapshot::from_party`, the one place that rule lives); a later
|
||||
/// party edit does not change the issued snapshot.
|
||||
#[test]
|
||||
fn an_invoice_keeps_the_billing_details_it_was_issued_with() {
|
||||
use ledger_core::arap::PartySnapshot;
|
||||
let dir = test_dir("contact-snapshot");
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
let customer = s.add_customer("Harbor Coffee").unwrap();
|
||||
let party = s.ledger().party_of_customer(customer).unwrap();
|
||||
for field in [
|
||||
PartyField::Address(Some("PO Box 9, Portland, OR 97209".into())),
|
||||
PartyField::Email(Some("hello@harbor.example".into())),
|
||||
PartyField::Phone(Some("555-0142".into())),
|
||||
PartyField::Contact(Some("Dana Reed".into())),
|
||||
PartyField::BillingAddress(Some("12 Billing Way, Portland, OR 97209".into())),
|
||||
PartyField::BillingEmail(Some("ap@harbor.example".into())),
|
||||
PartyField::ShippingAddress(Some("Dock 4, 9 Wharf Street".into())),
|
||||
] {
|
||||
s.update_party(party, field).unwrap();
|
||||
}
|
||||
let snapshot = PartySnapshot::from_party(s.ledger().get_party(party).unwrap());
|
||||
assert_eq!(
|
||||
snapshot.address.as_deref(),
|
||||
Some("12 Billing Way, Portland, OR 97209"),
|
||||
"the billing address, not the general one"
|
||||
);
|
||||
assert_eq!(snapshot.email.as_deref(), Some("ap@harbor.example"));
|
||||
assert_eq!(snapshot.contact.as_deref(), Some("Dana Reed"));
|
||||
assert_eq!(snapshot.phone.as_deref(), Some("555-0142"));
|
||||
assert_eq!(
|
||||
snapshot.shipping_address.as_deref(),
|
||||
Some("Dock 4, 9 Wharf Street")
|
||||
);
|
||||
assert_eq!(
|
||||
snapshot.billing_address.as_deref(),
|
||||
Some("12 Billing Way, Portland, OR 97209")
|
||||
);
|
||||
|
||||
let line = ledger_core::DocumentLine {
|
||||
description: "Coffee".into(),
|
||||
amount: dec("100.00"),
|
||||
class_id: None,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
};
|
||||
let id = s
|
||||
.create_invoice_with(
|
||||
customer,
|
||||
date(2026, 2, 1),
|
||||
date(2026, 3, 3),
|
||||
vec![line],
|
||||
ledger_core::arap::InvoiceFields {
|
||||
bill_to: Some(snapshot.clone()),
|
||||
..ledger_core::arap::InvoiceFields::default()
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
// The event carries it, so replay can never lose it.
|
||||
let event = journal(&dir)
|
||||
.into_iter()
|
||||
.rev()
|
||||
.find(|(t, _)| t == "invoice.created")
|
||||
.map(|(_, p)| p)
|
||||
.expect("invoice.created");
|
||||
assert_eq!(
|
||||
event["bill_to"],
|
||||
json!({
|
||||
"name": "Harbor Coffee",
|
||||
"address": "12 Billing Way, Portland, OR 97209",
|
||||
"email": "ap@harbor.example",
|
||||
"phone": "555-0142",
|
||||
"contact": "Dana Reed",
|
||||
"billing_address": "12 Billing Way, Portland, OR 97209",
|
||||
"shipping_address": "Dock 4, 9 Wharf Street",
|
||||
"billing_email": "ap@harbor.example",
|
||||
}),
|
||||
"{event}"
|
||||
);
|
||||
|
||||
// A later edit changes the record, never the issued copy.
|
||||
s.update_party(party, PartyField::BillingAddress(Some("99 New Way".into())))
|
||||
.unwrap();
|
||||
s.update_party(party, PartyField::Phone(Some("555-9999".into())))
|
||||
.unwrap();
|
||||
let kept = s.ledger().get_invoice(id).unwrap().bill_to.clone().unwrap();
|
||||
assert_eq!(kept, snapshot, "an issued snapshot never changes");
|
||||
// And the books come back to it exactly.
|
||||
s.verify().unwrap();
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
assert_eq!(
|
||||
s.ledger().get_invoice(id).unwrap().bill_to.clone().unwrap(),
|
||||
snapshot
|
||||
);
|
||||
assert!(*s.ledger() == live);
|
||||
// The party's own record took the edit.
|
||||
assert_eq!(
|
||||
s.ledger()
|
||||
.get_party(party)
|
||||
.unwrap()
|
||||
.billing_address
|
||||
.as_deref(),
|
||||
Some("99 New Way")
|
||||
);
|
||||
}
|
||||
@@ -1849,6 +1849,17 @@ fn books_written_under_older_rules_still_open() {
|
||||
"value": {"kind": {"net": 30}, "text": "Net 30"}}}),
|
||||
)
|
||||
.unwrap();
|
||||
// D-55's contact block, hand-written the same way (P4b S4.4).
|
||||
for change in [
|
||||
json!({"field": "phone", "value": "555-0142"}),
|
||||
json!({"field": "contact", "value": "Dana Reed"}),
|
||||
json!({"field": "billing_address", "value": "12 Billing Way"}),
|
||||
json!({"field": "shipping_address", "value": "Dock 4"}),
|
||||
json!({"field": "billing_email", "value": "ap@acme.example"}),
|
||||
] {
|
||||
w.append("party.updated", json!({"id": 1, "change": change}))
|
||||
.unwrap();
|
||||
}
|
||||
w.append(
|
||||
"settings.set",
|
||||
json!({"changes": [{"key": "invoicing.default_terms", "value": "2/10 net 30"}],
|
||||
@@ -1964,6 +1975,11 @@ fn books_written_under_older_rules_still_open() {
|
||||
// The new shapes replay as written: the party's facts and the setting.
|
||||
let acme = store.ledger().get_party(1).expect("the party");
|
||||
assert_eq!(acme.email.as_deref(), Some("billing@acme.example"));
|
||||
assert_eq!(acme.phone.as_deref(), Some("555-0142"));
|
||||
assert_eq!(acme.contact.as_deref(), Some("Dana Reed"));
|
||||
assert_eq!(acme.billing_address.as_deref(), Some("12 Billing Way"));
|
||||
assert_eq!(acme.shipping_address.as_deref(), Some("Dock 4"));
|
||||
assert_eq!(acme.billing_email.as_deref(), Some("ap@acme.example"));
|
||||
assert_eq!(
|
||||
acme.default_terms_as_customer
|
||||
.as_ref()
|
||||
|
||||
Reference in new issue
Block a user