diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs new file mode 100644 index 0000000..de7fcd2 --- /dev/null +++ b/crates/ledger-cli/tests/documents.rs @@ -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 { + 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 { + 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 { + String::from_utf8(books.journal()) + .unwrap() + .lines() + .filter_map(|l| serde_json::from_str::(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 = 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}"); +} diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index 375e3c7..a0df738 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -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", diff --git a/crates/ledger-cli/tests/hostile_args.rs b/crates/ledger-cli/tests/hostile_args.rs index e30201c..1e0bdb4 100644 --- a/crates/ledger-cli/tests/hostile_args.rs +++ b/crates/ledger-cli/tests/hostile_args.rs @@ -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", diff --git a/crates/ledger-cli/tests/local_server.rs b/crates/ledger-cli/tests/local_server.rs index cba3599..b107e56 100644 --- a/crates/ledger-cli/tests/local_server.rs +++ b/crates/ledger-cli/tests/local_server.rs @@ -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!( diff --git a/crates/ledger-cli/tests/projects_parity.rs b/crates/ledger-cli/tests/projects_parity.rs index 49c89ea..167b0e9 100644 --- a/crates/ledger-cli/tests/projects_parity.rs +++ b/crates/ledger-cli/tests/projects_parity.rs @@ -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}"); diff --git a/crates/ledger-cli/tests/spec_attachments.rs b/crates/ledger-cli/tests/spec_attachments.rs index daf55c1..fd51708 100644 --- a/crates/ledger-cli/tests/spec_attachments.rs +++ b/crates/ledger-cli/tests/spec_attachments.rs @@ -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( diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index ede855a..d87b91d 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -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, vendors: Vec, bills: Vec, + /// 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 { 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() diff --git a/crates/ledger-cli/tests/spec_parties.rs b/crates/ledger-cli/tests/spec_parties.rs index f2e3db0..5a9be27 100644 --- a/crates/ledger-cli/tests/spec_parties.rs +++ b/crates/ledger-cli/tests/spec_parties.rs @@ -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", diff --git a/crates/ledger-cli/tests/spec_projects.rs b/crates/ledger-cli/tests/spec_projects.rs index 4b36cb7..648838a 100644 --- a/crates/ledger-cli/tests/spec_projects.rs +++ b/crates/ledger-cli/tests/spec_projects.rs @@ -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() diff --git a/crates/ledger-cli/tests/spec_tax_policy.rs b/crates/ledger-cli/tests/spec_tax_policy.rs index 8409336..f5073a4 100644 --- a/crates/ledger-cli/tests/spec_tax_policy.rs +++ b/crates/ledger-cli/tests/spec_tax_policy.rs @@ -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. diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index cd8ebd5..e27d3a0 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -711,18 +711,65 @@ fn last_day_of_month(date: NaiveDate) -> Result { } /// 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, - /// 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, + /// Its telephone number at issue (D-55). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub phone: Option, + /// The person there to write to, at issue (D-55). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub contact: Option, + /// 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, + /// 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, + /// 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, +} + +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 diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index a24a24c..4158097 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -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(_) => {} diff --git a/crates/ledger-core/src/parties.rs b/crates/ledger-core/src/parties.rs index 9f26757..dbdd3ba 100644 --- a/crates/ledger-core/src/parties.rs +++ b/crates/ledger-core/src/parties.rs @@ -234,6 +234,24 @@ pub struct Party { /// an invoice's bill-to snapshot at issue. #[serde(default)] pub email: Option, + /// Telephone number (D-55): printed on the invoice's bill-to. + #[serde(default)] + pub phone: Option, + /// The named person there to write to (D-55). + #[serde(default)] + pub contact: Option, + /// Where its bills go (D-55): the invoice's bill-to address when set, + /// else [`Self::address`]. + #[serde(default)] + pub billing_address: Option, + /// Where goods go (D-55): the issued invoice prints a Ship-to line + /// when it is set. + #[serde(default)] + pub shipping_address: Option, + /// Where its bills are emailed (D-55): the bill-to email when set, + /// else [`Self::email`]. + #[serde(default)] + pub billing_email: Option, /// 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), /// The terms it gives the company as a vendor (A-13; `None` clears). DefaultTermsAsVendor(Option), + /// The telephone number (D-55; `None` clears it). + Phone(Option), + /// The named person there to write to (D-55; `None` clears it). + Contact(Option), + /// Where its bills go (D-55; `None` clears it). + BillingAddress(Option), + /// Where goods go (D-55; `None` clears it). + ShippingAddress(Option), + /// Where its bills are emailed (D-55; `None` clears it). + BillingEmail(Option), } 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(_) ) } } diff --git a/crates/ledger-core/tests/spec_parties.rs b/crates/ledger-core/tests/spec_parties.rs index 025d5af..622f585 100644 --- a/crates/ledger-core/tests/spec_parties.rs +++ b/crates/ledger-core/tests/spec_parties.rs @@ -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, diff --git a/crates/ledger-mcp/handbook/format-upgrade.md b/crates/ledger-mcp/handbook/format-upgrade.md index 36007b5..05271d4 100644 --- a/crates/ledger-mcp/handbook/format-upgrade.md +++ b/crates/ledger-mcp/handbook/format-upgrade.md @@ -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. diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 2814e5d..978337e 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -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)); diff --git a/crates/ledger-mcp/src/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs index 7b057c0..1b2b3ef 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -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, @@ -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 { + let ledger = store.ledger(); + let by_id = customer.trim().parse::().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 = 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 { + if let Ok(id) = vendor.trim().parse::() { + 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, vendor: Option) -> 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, +) -> Result { + 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, 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, + customer: bool, +) -> Option { + 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>, + what: &str, + ways: &str, +) -> Result<(NaiveDate, Option), 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 { + 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, - ) -> Result, String> { + params: Parameters, + ) -> Result, 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, - ) -> Result, String> { + params: Parameters, + ) -> Result, 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), })) } diff --git a/crates/ledger-mcp/src/tools/parties.rs b/crates/ledger-mcp/src/tools/parties.rs index cf0e961..a01ad6d 100644 --- a/crates/ledger-mcp/src/tools/parties.rs +++ b/crates/ledger-mcp/src/tools/parties.rs @@ -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, 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) diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 7a4ef1b..a55b1a9 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3056,39 +3056,118 @@ pub struct DocumentLineParam { /// receivable or payable line never does. #[serde(default, skip_serializing_if = "Option::is_none")] pub class: Option, + /// 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, + /// 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, + /// The price of one unit, with quantity. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unit_price: Option, } -/// 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, + /// Its address at issue (the general one), one line per line. + pub address: Option, + /// Its email at issue (the general one). + pub email: Option, + /// Its telephone number at issue. + pub phone: Option, + /// The person there to write to at issue. + pub contact: Option, + /// Where its bills go: the bill-to address when set, else `address`. + pub billing_address: Option, + /// Where goods go: the page prints a Ship-to line for it. + pub shipping_address: Option, + /// Where its bills are emailed: the bill-to email when set, else + /// `email`. + pub billing_email: Option, +} + +/// 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, + /// The old spelling of `customer` (accepted until P5): a customer id. + #[serde(default)] + pub party_id: Option, + /// 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, + /// Its due date, YYYY-MM-DD: used as given, and it overrides `terms`. + pub due_date: Option, /// Line items. pub lines: Vec, + /// The customer's own reference (their purchase order). + pub customer_ref: Option, + /// A memo printed on the invoice. + pub memo: Option, + /// The customer as the page prints it (omitted: the party's name, + /// address and email at issue). + pub bill_to: Option, + /// True: post it. Absent or false: preview it and post nothing. + pub confirmed: Option, } -/// 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, - /// 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, +/// 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, + /// The old spelling of `vendor` (accepted until P5): a vendor id. + #[serde(default)] + pub party_id: Option, + /// 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, + /// 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, + /// Its payment terms; the due date is derived from them. + pub terms: Option, + /// Its due date, YYYY-MM-DD: used as given, and it overrides `terms`. + pub due_date: Option, + /// Line items. + pub lines: Vec, + /// A memo about the bill. + pub memo: Option, + /// 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, + /// The vendor's invoice, stored already (books.attachments): filed + /// like an upload. + pub existing_attachment_id: Option, + /// Why a vendor invoice number that already names a standing bill is + /// entered again anyway. + pub duplicate_reason: Option, + /// True: post it. Absent or false: preview it and post nothing. + pub confirmed: Option, } /// Parameters for `books.receive_payment` / `books.pay_bill`. @@ -3945,6 +4024,28 @@ pub struct UpdatePartyParams { pub address: Option, /// Notes; "" clears them. pub notes: Option, + /// 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, + /// 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, + /// 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, + /// Telephone number (D-55). "" clears it. + pub phone: Option, + /// The named person there to write to (D-55). "" clears it. + pub contact: Option, + /// Where its bills go (D-55): an invoice's bill-to address when set, + /// else `address`. "" clears it. + pub billing_address: Option, + /// Where goods go (D-55). "" clears it. + pub shipping_address: Option, + /// Where its bills are emailed (D-55): an invoice's bill-to email when + /// set, else `email`. "" clears it. + pub billing_email: Option, /// 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, /// Notes. pub notes: Option, + /// Email address (A-13): the general one, where the company writes to + /// it. + pub email: Option, + /// Telephone number (D-55). + pub phone: Option, + /// The named person there to write to (D-55). + pub contact: Option, + /// Where its bills go (D-55): an invoice's bill-to address when set, + /// else `address`. + pub billing_address: Option, + /// Where goods go (D-55). + pub shipping_address: Option, + /// Where its bills are emailed (D-55): the bill-to email when set, + /// else `email`. + pub billing_email: Option, + /// The terms it grants the company as a customer ("Net 30"), when set. + pub default_terms_as_customer: Option, + /// The terms it gives the company as a vendor, when set. + pub default_terms_as_vendor: Option, /// 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, } + +// --------------------------------------------------------------------------- +// 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, +} + +/// 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, + /// The invoice's id (confirm). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// 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, + /// 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, + /// The auto-posted entry's id (confirm). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_id: Option, + /// The posting, line by line (preview). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lines: Vec, + /// 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, + /// 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, + /// 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, + /// 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, +} + +/// 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, + /// The bill's id (confirm). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub id: Option, + /// 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, + /// The auto-posted entry's id (confirm). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub entry_id: Option, + /// The posting, line by line (preview). + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub lines: Vec, + /// 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, + /// 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, + /// The vendor's invoice, filed with the bill (confirm). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub file: Option, + /// 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, + /// One per finished or abandoned project a line names. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub notes: Vec, +} diff --git a/crates/ledger-package/src/invoice.rs b/crates/ledger-package/src/invoice.rs index 6fa94b6..0a905e6 100644 --- a/crates/ledger-package/src/invoice.rs +++ b/crates/ledger-package/src/invoice.rs @@ -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 { + 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 { + 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, + note: Option, +) -> Result { + 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::*; diff --git a/crates/ledger-package/src/lib.rs b/crates/ledger-package/src/lib.rs index 578f6cd..93b4937 100644 --- a/crates/ledger-package/src/lib.rs +++ b/crates/ledger-package/src/lib.rs @@ -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::{ diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 593156c..a0087d1 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -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, }; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 055ff0b..624db3c 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -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, +} + /// 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)>, + /// 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, /// 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, /// 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)>, + /// The same lines with their accounts' codes and names + /// ([`InvoicePreview::lines`]). + pub lines: Vec, /// 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, 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, }) diff --git a/crates/ledger-store/tests/parties.rs b/crates/ledger-store/tests/parties.rs index b37ea72..60e1139 100644 --- a/crates/ledger-store/tests/parties.rs +++ b/crates/ledger-store/tests/parties.rs @@ -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") + ); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 412fecc..ded2c12 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -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() diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 92184cb..950549e 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -1190,15 +1190,29 @@ texts are at most 500 characters, no control characters. - `books.add_party { name, roles? }` → the party (below). Journaled as `party.added`. - `books.update_party { party, name?, display_name?, website?, address?, - notes?, archived?, creditor_of? }` → the party. Give at least one field; - `""` clears `display_name`, `website`, `address`, or `notes`; - `creditor_of` (account codes or ids) replaces the party's list of the - liabilities the company owes it itself (below). Every field is checked - (in order, on a rehearsal) before any is written; each is journaled as - its own `party.updated`, in the order name, display_name, website, - address, notes, archived, then one `party.creditor_set` per account - added or dropped. Un-archiving is refused while another party has the - name. + notes?, email?, default_terms_as_customer?, default_terms_as_vendor?, + phone?, contact?, billing_address?, shipping_address?, billing_email?, + archived?, creditor_of? }` → the party. Give at least one field; `""` + clears `display_name`, `website`, `address`, `notes`, `email`, the terms, + `phone`, `contact`, `billing_address`, `shipping_address` or + `billing_email`; `creditor_of` (account codes or ids) replaces the + party's list of the liabilities the company owes it itself (below). + `email` is its general address; `billing_email` is where its bills go + (an invoice's bill-to email, when set, else the general one). `address` + is its general/business address; `billing_address` where its bills go + (the bill-to address when set, else the general one) and + `shipping_address` where goods go; `contact` is the person there to + write to and `phone` its number (D-55). The terms are trade terms per + side (`"Net 30"`, `"2/10 net 30"`): a document's due date falls back to + them (below). Every field is checked (in order, on a rehearsal) before + any is written; each is journaled as its own `party.updated`, in the + order name, display_name, website, address, notes, email, the two terms, + phone, contact, billing_address, shipping_address, billing_email, + archived, then one `party.creditor_set` per account added or dropped. + The email, terms and contact block need the journal feature + `documents_v2` (rules.md B-27): books that haven't declared it refuse + them with `format_upgrade_pending`, journaling nothing. Un-archiving is + refused while another party has the name. - `books.set_party_roles { party, add?, remove? }` → the party. Adds apply before removes (so a contractor's W-9 survives a swap to landlord); roles it already has, or lacks, are skipped; each change is journaled as @@ -1632,10 +1646,12 @@ posting. violation, and the append-only audit log would show it anyway. (`asset_id` instead of `entry_id` for a disposal; `split_group: [ids]` when the split check triggered.) -- **Not checked yet:** `books.create_invoice`, `books.create_bill`, - `books.receive_payment`, `books.pay_bill`, `books.run_payroll` and - `books.remit_payroll_taxes` post at once with no preview and no receipt - check: attach their evidence right after, to the entry they return. +- **Not checked yet:** `books.receive_payment`, `books.pay_bill`, + `books.run_payroll` and `books.remit_payroll_taxes` post at once with no + receipt check: attach their evidence right after, to the entry they + return. (`books.create_invoice` and `books.create_bill` preview and + confirm, and a bill's vendor invoice is required, in books that have + declared `documents_v2`.) - **Anti-splitting.** Posting an entry triggers a split check: posted entries on the same date with the same normalized description and debit accounts are grouped, and if the group total exceeds the threshold while @@ -1973,45 +1989,103 @@ accounting only: the caller supplies every figure), tax filing. ## Tools — A/R and A/P -S4 changes `books.create_invoice` and `books.create_bill`; see plan 8.2 S4. +S4 gives `books.create_invoice` and `books.create_bill` a preview and a +confirm (A-1) and the documents numbers, terms, line accounts and party +snapshots; see plan 8.2 S4. - `books.add_customer { name }` → `{ id, party_id }`. - `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to a party ("Tools — parties"). -- `books.create_invoice { party_id, date, due_date, lines: [{ description, - amount, class? }] }` → `{ id, total, entry_id, warnings?, notes? }`. - Auto-posts Dr `1200` Accounts Receivable / Cr `4000` Revenue. `warnings` - names a revenue account it created with no tax category (rules.md - B-32). `party_id` is the customer's `id` from `books.add_customer`, not - its `party_id`. A line's `class` (a project: name or id, resolved as - `books.add_line`'s) puts its revenue on that project (rules.md B-37 item - 8, the owner's decision D-43): one project or none per line, several - projects as several lines each with its own amount, never a percentage - (a cost truly shared names none: it reaches the projects only as - entries of their own under the company's monthly allocation convention, - when `books.conventions` has one, and otherwise stays untagged while the - agent asks the human). The entry's revenue side is one line per project named, in the - order each is first named, the untagged lines' as one (a document - naming none posts the one revenue line it always did); the receivable - line is never tagged. Every project is resolved before anything is - written (an unknown one is refused, the document whole). `notes`: one - per finished or abandoned project named, as `books.add_line`'s `note`, - and one per on-spec project named, asking whether there is now an - agreement (on spec, a hoped-for payment is no receivable until - received: B-37 item 4) (tagged anyway). Books that haven't declared the journal feature - `document_line_projects` (B-27; the live books until their owner's next - upgrade, D-39) refuse a line naming a project with - `format_upgrade_pending`, journaling nothing; a document naming none is - taken as before. -- `books.create_bill { party_id, date, due_date, lines }` → `{ id, total, - entry_id, warnings?, notes? }`. Auto-posts Dr `6999` General Expenses / - Cr `2000` Accounts Payable; `warnings` as for invoices. `party_id` is - the vendor's `id` from `books.add_vendor`, not its `party_id`. A line's - `class` puts its cost on that project, as an invoice's its revenue: one - expense line per project, the untagged lines' as one, the payable line - never tagged; `notes` as for invoices, but none for an on-spec project - (a cost of on-spec work is an expense like any other); the feature as - for invoices. +- `books.create_invoice { customer | party_id, date, terms? | due_date?, + lines: [{ description, amount, quantity?, unit_price?, account?, + class? }], customer_ref?, memo?, bill_to?: { name?, address?, email?, + phone?, contact?, billing_address?, shipping_address?, billing_email? }, + confirmed? }` → without `confirmed`: `{ preview: true, number?, total, + due_date, terms?, lines: [{ account, name, side, amount, class? }], + pdf }`, and nothing is written; with `confirmed: true`: `{ id, number?, + total, due_date, terms?, entry_id, pdf: { attachment_id, sha256 }, + mailto?, warnings?, notes? }`. + Confirmed it posts Dr `1200` Accounts Receivable / Cr each line's + account (the revenue role, `4000`, when a line names none), the + receivable line tagged with the invoice and the receivable side one line + per (account, project) part. + - `customer` is the customer's id, its party's id or its name (a name + two customers answer to is refused); `party_id` is the old spelling of + the same thing (a customer id), kept until P5. + - The due date: `due_date` as given (and it overrides `terms`); else + `terms` ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on receipt", + "Due 2026-11-15", rules.md B-39); else the customer's + `default_terms_as_customer`; else the books' `invoicing.default_terms` + ("Net 30" unset); else refused, naming those ways. Terms are kept on + the invoice only when given or taken from a default: with only a due + date the invoice prints none. + - Its number (`INV-0001`) is `invoicing.number_prefix` and + `invoicing.first_number` plus the count of numbered invoices before it + (A-2, B-40); it is stored at issue and never recomputed, and a preview + shows the number the confirm will take (an abandoned preview uses up + none). Books that haven't declared `documents_v2` number nothing and + show a document as "Invoice {N}". + - The bill-to snapshot is the customer's name, address and email at + issue, with its `billing_address` and `billing_email` in their place + when it has them, and its `contact` person, `phone` and + `shipping_address` (D-55); a `bill_to` object overrides any part for + this one document. `customer_ref` is the customer's own reference (a + purchase order) and `memo` prints on the page. These are new fields: + books that haven't declared `documents_v2` refuse a document carrying + any of them with `format_upgrade_pending`, journaling nothing, and + take one carrying none as before. + - A line's `class` (a project: name or id) tags its revenue, as before; + a line's `account` sends it to a revenue account or a liability + holding no role (deferred revenue). `quantity` and `unit_price` go + together, and quantity × unit_price must equal `amount` exactly. + - Without `confirmed` the DRAFT copy of the PDF is handed over (`pdf`, + run its `download`, D-46); confirming renders the issued copy before + anything is journaled and files it with the invoice in the same call + (A-7, B-40), returning its `attachment_id` and `sha256` (the same + `sha256` `books.get_receipt` reports). + - `mailto` is a convenience link for a human to send it from their own + mail program: the subject "Invoice INV-0007 from {company}", the body + URL-encoded and cut to 1,800 bytes at the last whole word. The books + never send anything (A-4): a mailto link carries no attachment, so say + which file to attach. + - `warnings` names a revenue account it created with no tax category + (rules.md B-32); `notes` one per finished or abandoned project a line + names, and on confirm one per on-spec project (is there now an + agreement?). +- `books.create_bill { vendor | party_id, date, received_on?, vendor_ref?, + terms? | due_date?, lines: [{ description, amount, account, class? }], + memo?, upload_id | existing_attachment_id, duplicate_reason?, + confirmed? }` → without `confirmed`: `{ preview: true, total, due_date, + terms?, lines: [...], discount_by?, max_discount? }` and nothing is + written; with `confirmed: true`: `{ id, total, due_date, terms?, + entry_id, lines?, discount_by?, max_discount?, file: { attachment_id, + sha256 }, warnings?, notes? }`. + Confirmed it posts Dr each line's account / Cr `2000` Accounts Payable, + the payable line tagged with the bill and one debit line per (account, + project) part; `vendor` is a vendor id, its party's id or its name, and + `party_id` the old spelling (a vendor id). + - `vendor_ref` is the vendor's own invoice number: required in books + that have declared `documents_v2` (B-40). The same vendor and number + twice is refused unless `duplicate_reason` says why, which is + journaled with the bill; case, spaces and hyphens are ignored. + `received_on` is the day it was received, when it differs from its + date. + - In books that have declared `documents_v2` every line names its + `account` (A-10: an expense account, or an asset that is not cash and + holds no role: equipment, prepaid), and the vendor's invoice is + required: `upload_id` (books.begin_upload and `wanderbooks upload`, + D-46) or `existing_attachment_id` (bytes stored already, + books.attachments). It is filed on the bill's entry as its evidence, + linked to the bill, and returned as `file` with its `sha256`. In books + that haven't declared the feature, lines without `account` post to + `6999` as before and the file is optional. + - The due date and terms fall back as an invoice's, to the vendor's + `default_terms_as_vendor` and no further. `discount_by` is the last + day an early-payment discount is open and `max_discount` the largest + it allows (B-39): the caller gives the exact cents when it takes one. + - `warnings` names an expense account it created with no tax category; + `notes` one per finished or abandoned project a line names (none for + on-spec: a cost of on-spec work is an expense like any other). - `books.receive_payment { document_id, date, amount, cash_account, instrument?, reference? }` → `{ entry_id, remaining, status }`. Posts Dr Cash / Cr A/R, the receivable line tagged with the invoice (B-38), diff --git a/docs/parity.md b/docs/parity.md index b3f39da..a178871 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -34,8 +34,8 @@ No other statuses. | Bank reconciliation (cleared flags + report) | Reconcile | done | — | | QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 | | QuickBooks IIF + XLSX export | Export / reports | done | R-FUTURE-4 | -| A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5; since P4b S1 (D-45) a document's paid, outstanding and status are read from its tagged ledger lines, hand lines on the receivables account are refused, `books.void_document` and `books.control_check` tie the subledger to the control account (rules.md B-38); S2 `books.documents`/`books.document` and the viewer pages; S3 the invoice PDF; S4.1-S4.3 terms, numbers, line accounts, the issued PDF filed at issue (behind `documents_v2`, the owner's next upgrade) | -| A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5; the same S1 derivation and control guard on the payables account; S4.1-S4.3 the vendor's invoice number, terms, every line on its own account (behind `documents_v2`) | +| A/R invoices + payments + aging | Business → Invoices | done | R-FUTURE-5; since P4b S1 (D-45) a document's paid, outstanding and status are read from its tagged ledger lines, hand lines on the receivables account are refused, `books.void_document` and `books.control_check` tie the subledger to the control account (rules.md B-38); S2 `books.documents`/`books.document` and the viewer pages; S3 the invoice PDF; S4.1-S4.3 terms, numbers, line accounts, the issued PDF filed at issue (behind `documents_v2`, the owner's next upgrade); numbers, terms, line accounts, the party snapshots and the issued PDF filed with the invoice, through `books.create_invoice`'s preview and confirm (P4b S4.4) | +| A/P bills + payments + aging | Business → Bills | done | R-FUTURE-5; the same S1 derivation and control guard on the payables account; S4.1-S4.3 the vendor's invoice number, terms, every line on its own account (behind `documents_v2`); the vendor's own invoice number (a duplicate refused), terms, line accounts and the vendor's invoice filed as the bill's evidence, through `books.create_bill`'s preview and confirm (P4b S4.4) | | Payroll runs (accounting only, no tax tables) + tax remittance | Payroll | done | — | | Tracking classes (divisional P&L) | Class tracking | done | invoice and bill lines take a class too (projects, rules.md B-37 item 8; QBO/Xero class or tracking per line) | | CPA handoff package (TB + GL xlsx, P&L + BS pdf, zip) | Reports → export | done | — | @@ -107,6 +107,22 @@ and the rule index from B-1 to B-45 (`bookspec/spec/rules-index.md`). Every existing bookspec example still runs on every `cargo test`, through both the loader's own checks and the `Engine` trait. +### P4b S4.4: the create tools preview and confirm (2026-10-27) + +The A/R and A/P rows above gain their note rather than a new row: no +parity item changed status, so the floor stands. `books.create_invoice` +and `books.create_bill` now preview without `confirmed` (the posting, the +number and due date it will take, the DRAFT or the discount, and nothing +written) and post with it (A-1), which is what the workflow reference +does before a document leaves the company. A bill in books that have +declared `documents_v2` names each line's account and keeps the vendor's +invoice (A-10, A-13); an invoice keeps the copy it issued (A-7, B-40) and +a `mailto` link for a human to send it, since the books never send (A-4). +`books.update_party` takes the party's email and terms per side (A-13) and +the owner's full contact block (D-55: phone, contact person, billing and +shipping addresses, billing email), which an invoice's bill-to copies at +issue. + ### P9a self-host deployment (2026-10-26) P9a is a deployment, not a semantic feature: the same engine, store, MCP diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 161e093..385ec8d 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1740,8 +1740,10 @@ that declared `projects` before it existed wait for their owner's next upgrade before a document's line names a project. `documents_v2` (plan P4b S4, one feature for S4 to S7: D-52) is the next: a `settings.set` of the `invoicing.*`, `bills.*`, `company.email` or `company.website` keys, -which an earlier version refuses (S4.2); a `party.updated` with an email -or a default terms fact (S4.2); an `invoice.created` or `bill.created` +which an earlier version refuses (S4.2); a `party.updated` with an email, +a default terms fact (S4.2), or a contact-block fact — a phone, a contact +person, a billing or shipping address, a billing email (D-55); an +`invoice.created` or `bill.created` carrying a number, terms, a bill-to or seller snapshot, a customer reference or memo, a vendor reference, a receipt date or a duplicate's reason, or a line naming its account, quantity or unit price (S4.3); and diff --git a/plan/served.md b/plan/served.md index d553339..0f04e19 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1330,6 +1330,16 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 - Item 13's 8.0.6 item 3 (R-A step 4): the over-the-wire call for a *document* carrying the new fields cannot exist before S4.4's tool arguments do, so `books_p2_served_cut_over_whole` keeps S4.2's `invoicing.default_terms` entry, which refuses before consent and is taken after; the store-level before/after is `documents_v2_waits_for_the_upgrade`. S4.4 adds the document call to the same list. - `issued_pdf_is_filed_with_the_invoice_or_nothing_is` proves every *refusal* leaves the journal unchanged; `create_invoice_issued` has no transaction over journal appends, so a storage failure between the invoice's lines and the attachment's is not rolled back. The PDF's bytes are stored before the first line, so that window is an I/O failure alone. - `preview_invoice` takes a number only in books that have declared `documents_v2`, so books that haven't keep the legacy "Invoice {id}" shape and can still file an issued copy before the upgrade (8.3.14 item 5). + S4.4 (2026-10-27), departures only: + - 8.0.6 item 3: `books_p2_served_cut_over_whole`'s refused list now holds both document writes (`books.create_invoice` with `terms` and a project line, `books.create_bill` with `vendor_ref`, a line account, a project and an existing attachment), and the same calls are made after the consent; S4.3's store-level test stays. + - The preview needed the document the issue would store before the invoice exists, and the account codes of a posting whose accounts the live chart doesn't have yet, so `InvoicePreview`/`BillPreview` gained `lines` (`PreviewPosting`: code, name, side, amount, project, resolved by the probe) and `InvoicePreview` gained `invoice` (the probe's document). `posting` stays: S4.3's test reads it. + - The DRAFT and issued pages are built by `ledger_package::draft_invoice_document` and `issued_invoice_document` (new), both taking the probed invoice and rendering through the page builder `invoice_document` already uses, so S4.5 enriches the renderer once. The server renders the issued copy before the store call and `create_invoice_issued` files exactly those bytes; the confirm passes the preview's number as the issue's number, so the filed page prints the number the preview showed (a number another session took in between is refused as a duplicate, B-40). + - Item 15's `vendor_ref`, a line's `account` and the file are required only in books that have declared `documents_v2`, reading its last sentence ("on books without the feature, lines without `account` post to `6999` as before and the file is optional") as the general case: requiring a `documents_v2` field always would refuse every bill on the owner's books before the go-ahead, against 8.0.4's "everything else in the unit works at once". + - The confirm is the preview's own rehearsal plus the write: `preview_invoice`/`preview_bill` run first on every call, so a preview refuses exactly what the confirm would; the tool's own checks (the vendor's number, the line accounts, the file) come after the lines resolve, so an unknown project still names itself. + - `books.update_party`'s handler stays in `crates/ledger-mcp/src/tools/parties.rs`, its own group file (U0a's rule); only the document tools' handlers are in `tools/documents.rs`. It asks `documents_v2` before its first line when any of the email, terms or contact fields is given, never between them. + - D-55: `PartySnapshot::from_party` is the one place the billing-else-general rule lives (an invoice's bill-to `address`/`email` are the billing ones when set); the snapshot carries the whole block, and `PartyOut` gained the email, terms and contact fields so `books.party` shows what `books.update_party` sets. + - Item 17's `documents_v2_refused_until_consent` is in `crates/ledger-cli/tests/format_upgrade.rs`; the wire tests are in the new `crates/ledger-cli/tests/documents.rs`, and D-55's round-trip and at-issue tests in `crates/ledger-store/tests/parties.rs`. + - Existing callers of `books.create_invoice` and `books.create_bill` gained `confirmed: true` (and a bill its vendor's number, line accounts and file): the contract A-1 changes. Two tests that named `books.create_invoice` as a tool a write token cannot see now name `books.receive_payment`, since a write token may preview an invoice. #### P4b S5: sent tracking (`invoice.sent`)