merge: P4b S4.4, the create tools preview and confirm, the party contact block (branch S4.4, 002fb9f8)
The first of the five paused branches (plan 8.0.2, "Resuming the paused wave"). books.create_invoice and books.create_bill preview without confirmed and post with it (A-1), the issued PDF filed in the same store call, the party's full contact block (D-55) on books.update_party and the invoice's bill-to. The only conflict was docs/parity.md, where the 2026-10-28 review had written the S1-S4.3 notes on the A/R and A/P rows and S4.4 its own: both are kept. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
d329bd6f56
29 files changed
+2614
-190
No files matched your search
@@ -0,0 +1,711 @@
|
|||||||
|
//! Invoices and bills through the books server, as an agent reaches them
|
||||||
|
//! (common.rs): the preview that writes nothing, the confirm that posts,
|
||||||
|
//! numbers, terms, line accounts, party snapshots and the issued copy filed
|
||||||
|
//! with the invoice (plan/served.md 8.2 P4b S4 item 17; docs/mcp-interface.md
|
||||||
|
//! "Tools — A/R and A/P"; rules.md B-39, B-40; D-55).
|
||||||
|
//!
|
||||||
|
//! Every refusal is checked to leave the journal byte for byte as it was.
|
||||||
|
|
||||||
|
use common::{Books, Wire};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
|
||||||
|
mod common;
|
||||||
|
|
||||||
|
/// One agent's session with the test's books, over the books server's
|
||||||
|
/// socket (common.rs).
|
||||||
|
struct Mcp {
|
||||||
|
wire: Wire,
|
||||||
|
next_id: u64,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mcp {
|
||||||
|
/// A new, initialized session with `books`.
|
||||||
|
fn spawn(books: &Books) -> Self {
|
||||||
|
Self {
|
||||||
|
wire: books.session("documents"),
|
||||||
|
next_id: 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn request(&mut self, method: &str, params: Value) -> Value {
|
||||||
|
let id = self.next_id;
|
||||||
|
self.next_id += 1;
|
||||||
|
let msg = json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
|
||||||
|
self.wire.send(&msg);
|
||||||
|
let resp = self
|
||||||
|
.wire
|
||||||
|
.recv(common::TIMEOUT)
|
||||||
|
.unwrap_or_else(|| panic!("no answer to {method}: {:?}", self.wire.refused));
|
||||||
|
assert_eq!(resp["id"], id, "id mismatch in {resp}");
|
||||||
|
resp
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call a tool; panic on a tool error. Returns structuredContent.
|
||||||
|
fn call(&mut self, tool: &str, args: Value) -> Value {
|
||||||
|
match self.try_call(tool, args) {
|
||||||
|
Ok(v) => v,
|
||||||
|
Err(e) => panic!("{tool}: {e}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Call a tool: its result, or the refusal's text.
|
||||||
|
fn try_call(&mut self, tool: &str, args: Value) -> Result<Value, String> {
|
||||||
|
let resp = self.request("tools/call", json!({"name": tool, "arguments": args}));
|
||||||
|
let result = &resp["result"];
|
||||||
|
if result.get("isError") == Some(&json!(true)) {
|
||||||
|
let text = result["content"]
|
||||||
|
.as_array()
|
||||||
|
.and_then(|c| c.iter().find_map(|b| b["text"].as_str()))
|
||||||
|
.unwrap_or_default()
|
||||||
|
.to_string();
|
||||||
|
return Err(text);
|
||||||
|
}
|
||||||
|
Ok(result.get("structuredContent").cloned().unwrap_or_default())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The bytes of a file a tool handed over (a `HandedFile`).
|
||||||
|
fn handed(&mut self, file: &Value) -> Vec<u8> {
|
||||||
|
assert!(file["expires_at"].is_string(), "{file}");
|
||||||
|
self.wire.fetch(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn upload(&mut self, bytes: &[u8], file_name: &str, purpose: &str) -> String {
|
||||||
|
self.wire
|
||||||
|
.upload(bytes, file_name, "application/pdf", purpose)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A customer with `name`: its id.
|
||||||
|
fn customer(&mut self, name: &str) -> u64 {
|
||||||
|
self.call("books.add_customer", json!({"name": name}))["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A vendor with `name`: its id.
|
||||||
|
fn vendor(&mut self, name: &str) -> u64 {
|
||||||
|
self.call("books.add_vendor", json!({"name": name}))["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An expense account, so a bill's line can name one (A-10).
|
||||||
|
fn expense(&mut self, code: &str, name: &str) {
|
||||||
|
self.call(
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": code, "name": name, "account_type": "expense"}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The journal's `kind` events, oldest first, as `{type, payload}`.
|
||||||
|
fn events(books: &Books, kind: &str) -> Vec<Value> {
|
||||||
|
String::from_utf8(books.journal())
|
||||||
|
.unwrap()
|
||||||
|
.lines()
|
||||||
|
.filter_map(|l| serde_json::from_str::<Value>(l).ok())
|
||||||
|
.filter(|v| v["type"] == json!(kind))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every invoice and bill figure and the copies they keep, from the
|
||||||
|
/// preview that writes nothing to the confirm that files the issued copy.
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_from_issue_to_paid() {
|
||||||
|
let books = Books::new("documents-issue");
|
||||||
|
let mut m = Mcp::spawn(&books);
|
||||||
|
let harbor = m.customer("Harbor Coffee");
|
||||||
|
let issue = json!({
|
||||||
|
"customer": harbor.to_string(),
|
||||||
|
"date": "2026-02-01",
|
||||||
|
"terms": "Net 30",
|
||||||
|
"lines": [{"description": "Design", "amount": "500.00"}],
|
||||||
|
});
|
||||||
|
|
||||||
|
// The preview: nothing written, a DRAFT copy handed over, the number
|
||||||
|
// and due date the confirm will take.
|
||||||
|
let before = books.journal();
|
||||||
|
let preview = m.call("books.create_invoice", issue.clone());
|
||||||
|
assert_eq!(preview["preview"], json!(true), "{preview}");
|
||||||
|
assert_eq!(preview["number"], "INV-0001", "{preview}");
|
||||||
|
assert_eq!(preview["due_date"], "2026-03-03", "{preview}");
|
||||||
|
assert_eq!(preview["terms"], "Net 30", "{preview}");
|
||||||
|
assert_eq!(preview["total"], "500.00", "{preview}");
|
||||||
|
let posting: Vec<(String, String, String)> = preview["lines"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|l| {
|
||||||
|
(
|
||||||
|
l["account"].as_str().unwrap().to_string(),
|
||||||
|
l["side"].as_str().unwrap().to_string(),
|
||||||
|
l["amount"].as_str().unwrap().to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
posting,
|
||||||
|
[
|
||||||
|
("1200".into(), "debit".into(), "500.00".into()),
|
||||||
|
("4000".into(), "credit".into(), "500.00".into()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
let draft = m.handed(&preview["pdf"]);
|
||||||
|
assert!(draft.starts_with(b"%PDF"), "a PDF, not bytes inline");
|
||||||
|
assert!(draft.len() > 1_000, "the DRAFT copy is a page: {preview}");
|
||||||
|
assert_eq!(books.journal(), before, "a preview journals nothing");
|
||||||
|
assert!(books.journal().len() == before.len());
|
||||||
|
|
||||||
|
// The confirm: the same number, the issued copy filed with the invoice.
|
||||||
|
let mut confirmed = issue.clone();
|
||||||
|
confirmed["confirmed"] = json!(true);
|
||||||
|
let issued = m.call("books.create_invoice", confirmed);
|
||||||
|
assert_eq!(issued["number"], "INV-0001", "{issued}");
|
||||||
|
assert_eq!(issued["due_date"], "2026-03-03", "{issued}");
|
||||||
|
assert_eq!(issued["total"], "500.00", "{issued}");
|
||||||
|
let entry = issued["entry_id"].as_u64().unwrap();
|
||||||
|
let attachment = issued["pdf"]["attachment_id"].as_u64().unwrap();
|
||||||
|
let sha = issued["pdf"]["sha256"].as_str().unwrap().to_string();
|
||||||
|
let receipt = m.call("books.get_receipt", json!({"attachment_id": attachment}));
|
||||||
|
assert_eq!(receipt["sha256"], json!(sha), "the same SHA-256");
|
||||||
|
assert_eq!(receipt["entry_id"], json!(entry), "filed on its entry");
|
||||||
|
assert_eq!(receipt["file_name"], "INV-0001.pdf", "{receipt}");
|
||||||
|
assert_eq!(receipt["mime_type"], "application/pdf", "{receipt}");
|
||||||
|
assert!(
|
||||||
|
receipt["links"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|l| l["kind"] == json!("invoice") && l["id"] == issued["id"]),
|
||||||
|
"linked to the invoice: {receipt}"
|
||||||
|
);
|
||||||
|
// The entry carries the issued copy as its evidence.
|
||||||
|
let got = m.call("books.get_entry", json!({"entry_id": entry}));
|
||||||
|
assert!(
|
||||||
|
got["receipts"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|r| r["attachment_id"] == json!(attachment)),
|
||||||
|
"{got}"
|
||||||
|
);
|
||||||
|
// A convenience for a human sender; the books never send (A-4).
|
||||||
|
let mailto = issued["mailto"].as_str().unwrap();
|
||||||
|
assert!(mailto.starts_with("mailto:"), "{mailto}");
|
||||||
|
assert!(
|
||||||
|
mailto.contains("Invoice%20INV-0001%20from%20"),
|
||||||
|
"the subject names the number and the company: {mailto}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The next one takes the next number; a preview between them takes
|
||||||
|
// none.
|
||||||
|
let second = m.call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"customer": "Harbor Coffee", "date": "2026-02-02", "terms": "Net 30",
|
||||||
|
"lines": [{"description": "Build", "amount": "250.00"}], "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(second["number"], "INV-0002", "{second}");
|
||||||
|
let third = m.call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"customer": harbor.to_string(), "date": "2026-02-03", "terms": "Net 30",
|
||||||
|
"lines": [{"description": "Abandoned", "amount": "10.00"}],
|
||||||
|
"confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(third["number"], "INV-0003", "{third}");
|
||||||
|
m.call("books.verify", json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A bill keeps the vendor's invoice, names its own accounts, and carries
|
||||||
|
/// its terms' discount dates (A-10, A-13; B-39).
|
||||||
|
#[test]
|
||||||
|
fn a_bill_with_its_vendor_invoice_and_terms() {
|
||||||
|
let books = Books::new("documents-bill");
|
||||||
|
let mut m = Mcp::spawn(&books);
|
||||||
|
let coast = m.vendor("Coastline Hosting");
|
||||||
|
m.expense("6500", "Hosting");
|
||||||
|
let file = m.upload(
|
||||||
|
b"%PDF-1.4 Coastline's own invoice",
|
||||||
|
"coastline.pdf",
|
||||||
|
"receipt",
|
||||||
|
);
|
||||||
|
let bill = json!({
|
||||||
|
"vendor": coast.to_string(),
|
||||||
|
"date": "2026-02-01",
|
||||||
|
"terms": "2/10 net 30",
|
||||||
|
"vendor_ref": "CH-4471",
|
||||||
|
"lines": [{"description": "Hosting, February", "amount": "1000.00",
|
||||||
|
"account": "6500"}],
|
||||||
|
});
|
||||||
|
|
||||||
|
let before = books.journal();
|
||||||
|
let mut preview = bill.clone();
|
||||||
|
preview["upload_id"] = json!(file);
|
||||||
|
let p = m.call("books.create_bill", preview.clone());
|
||||||
|
assert_eq!(p["preview"], json!(true), "{p}");
|
||||||
|
assert_eq!(p["due_date"], "2026-03-03", "{p}");
|
||||||
|
assert_eq!(p["terms"], "2/10 net 30", "{p}");
|
||||||
|
assert_eq!(p["discount_by"], "2026-02-11", "{p}");
|
||||||
|
assert_eq!(p["max_discount"], "20.00", "2% of 1,000.00");
|
||||||
|
assert_eq!(books.journal(), before, "a preview journals nothing");
|
||||||
|
|
||||||
|
let mut confirm = preview;
|
||||||
|
confirm["confirmed"] = json!(true);
|
||||||
|
let made = m.call("books.create_bill", confirm);
|
||||||
|
assert_eq!(made["terms"], "2/10 net 30", "{made}");
|
||||||
|
assert_eq!(made["discount_by"], "2026-02-11", "{made}");
|
||||||
|
let attachment = made["file"]["attachment_id"].as_u64().unwrap();
|
||||||
|
let sha = made["file"]["sha256"].as_str().unwrap().to_string();
|
||||||
|
assert_eq!(
|
||||||
|
sha,
|
||||||
|
ledger_store::journal::sha256_hex(b"%PDF-1.4 Coastline's own invoice"),
|
||||||
|
"the vendor's own bytes"
|
||||||
|
);
|
||||||
|
let receipt = m.call("books.get_receipt", json!({"attachment_id": attachment}));
|
||||||
|
assert_eq!(receipt["entry_id"], made["entry_id"], "{receipt}");
|
||||||
|
assert_eq!(receipt["description"], "Vendor invoice", "{receipt}");
|
||||||
|
assert!(
|
||||||
|
receipt["links"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|l| l["kind"] == json!("bill") && l["id"] == made["id"]),
|
||||||
|
"linked to the bill: {receipt}"
|
||||||
|
);
|
||||||
|
// The posting: the expense the line names, the payable, tagged.
|
||||||
|
let entry = m.call("books.get_entry", json!({"entry_id": made["entry_id"]}));
|
||||||
|
let lines: Vec<(String, String, String)> = entry["lines"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.map(|l| {
|
||||||
|
(
|
||||||
|
l["code"].as_str().unwrap().to_string(),
|
||||||
|
l["side"].as_str().unwrap().to_string(),
|
||||||
|
l["amount"].as_str().unwrap().to_string(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
assert_eq!(
|
||||||
|
lines,
|
||||||
|
[
|
||||||
|
("6500".into(), "debit".into(), "1000.00".into()),
|
||||||
|
("2000".into(), "credit".into(), "1000.00".into()),
|
||||||
|
]
|
||||||
|
);
|
||||||
|
// The vendor's number is the bill's, and the document names it.
|
||||||
|
let doc = m.call(
|
||||||
|
"books.document",
|
||||||
|
json!({"ref": format!("bill:{}", made["id"])}),
|
||||||
|
);
|
||||||
|
assert_eq!(doc["total"], "1000.00", "{doc}");
|
||||||
|
m.call("books.verify", json!({}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One vendor's invoice number, entered twice: refused unless a reason is
|
||||||
|
/// given, which is journaled with the second bill (B-40).
|
||||||
|
#[test]
|
||||||
|
fn duplicate_vendor_invoice_refused() {
|
||||||
|
let books = Books::new("documents-duplicate");
|
||||||
|
let mut m = Mcp::spawn(&books);
|
||||||
|
let coast = m.vendor("Coastline Hosting");
|
||||||
|
m.expense("6500", "Hosting");
|
||||||
|
let first_file = m.upload(b"first copy", "ch-1.pdf", "receipt");
|
||||||
|
let bill = |file: &str, date: &str| {
|
||||||
|
json!({"vendor": coast.to_string(), "date": date, "due_date": "2026-03-03",
|
||||||
|
"vendor_ref": "CH-4471",
|
||||||
|
"lines": [{"description": "Hosting", "amount": "100.00", "account": "6500"}],
|
||||||
|
"upload_id": file, "confirmed": true})
|
||||||
|
};
|
||||||
|
m.call("books.create_bill", bill(&first_file, "2026-02-01"));
|
||||||
|
// The same number (however written) is the same vendor invoice.
|
||||||
|
let second_file = m.upload(b"second copy", "ch-1-again.pdf", "receipt");
|
||||||
|
let before = books.journal();
|
||||||
|
let refused = m
|
||||||
|
.try_call("books.create_bill", bill(&second_file, "2026-02-02"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(refused.contains("CH-4471"), "{refused}");
|
||||||
|
assert!(refused.contains("duplicate_reason"), "{refused}");
|
||||||
|
assert_eq!(books.journal(), before, "a refusal journals nothing");
|
||||||
|
// The upload was given back, not used up (an upload is spent only by
|
||||||
|
// the call that files it).
|
||||||
|
let mut with_reason = bill(&second_file, "2026-02-02");
|
||||||
|
with_reason["duplicate_reason"] =
|
||||||
|
json!("the vendor sent it twice; this one has the credit line");
|
||||||
|
let made = m.call("books.create_bill", with_reason);
|
||||||
|
let created = Mcp::events(&books, "bill.created");
|
||||||
|
let second = created.last().unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
second["payload"]["duplicate_reason"],
|
||||||
|
"the vendor sent it twice; this one has the credit line",
|
||||||
|
"{second}"
|
||||||
|
);
|
||||||
|
assert_eq!(made["total"], "100.00");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A line's account obeys the document's kind: revenue or a role-free
|
||||||
|
/// liability on an invoice, an expense or a non-cash role-free asset on a
|
||||||
|
/// bill; a role account, cash, a placeholder or none at all is refused
|
||||||
|
/// (A-10, A-11; rules.md B-39).
|
||||||
|
#[test]
|
||||||
|
fn a_line_account_must_be_allowed() {
|
||||||
|
let books = Books::new("documents-accounts");
|
||||||
|
let mut m = Mcp::spawn(&books);
|
||||||
|
let harbor = m.customer("Harbor Coffee");
|
||||||
|
let coast = m.vendor("Coastline Hosting");
|
||||||
|
for (code, name, kind) in [
|
||||||
|
("1000", "Checking", "asset"),
|
||||||
|
("2000", "Accounts Payable", "liability"),
|
||||||
|
("6999", "General Expenses", "expense"),
|
||||||
|
] {
|
||||||
|
m.call(
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": code, "name": name, "account_type": kind}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
m.call(
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": "2400", "name": "Deferred Revenue", "account_type": "liability"}),
|
||||||
|
);
|
||||||
|
m.call(
|
||||||
|
"books.add_account",
|
||||||
|
json!({"code": "1500", "name": "Equipment", "account_type": "asset"}),
|
||||||
|
);
|
||||||
|
let invoice = |account: &str| {
|
||||||
|
json!({"customer": harbor.to_string(), "date": "2026-02-01", "terms": "Net 30",
|
||||||
|
"lines": [{"description": "Work", "amount": "100.00", "account": account}],
|
||||||
|
"confirmed": true})
|
||||||
|
};
|
||||||
|
let before = books.journal();
|
||||||
|
// An invoice line may not post to the receivable control or a role
|
||||||
|
// account.
|
||||||
|
let why = m
|
||||||
|
.try_call("books.create_invoice", invoice("1200"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(why.contains("1200"), "{why}");
|
||||||
|
assert_eq!(books.journal(), before, "a refusal journals nothing");
|
||||||
|
// Nor to an expense account.
|
||||||
|
let why = m
|
||||||
|
.try_call("books.create_invoice", invoice("6999"))
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(why.contains("6999"), "{why}");
|
||||||
|
// A liability holding no role (deferred revenue) is allowed.
|
||||||
|
let deferred = m.call("books.create_invoice", invoice("2400"));
|
||||||
|
assert_eq!(deferred["total"], "100.00", "{deferred}");
|
||||||
|
|
||||||
|
// A bill's line names its account in these books, and it must be an
|
||||||
|
// expense or a non-cash asset holding no role.
|
||||||
|
let file = m.upload(b"coastline", "coastline.pdf", "receipt");
|
||||||
|
let bill = |account: &str| {
|
||||||
|
json!({"vendor": coast.to_string(), "date": "2026-02-01", "due_date": "2026-03-03",
|
||||||
|
"vendor_ref": "CH-1",
|
||||||
|
"lines": [{"description": "Hosting", "amount": "100.00", "account": account}],
|
||||||
|
"upload_id": file, "confirmed": true})
|
||||||
|
};
|
||||||
|
let why = m.try_call("books.create_bill", bill("1000")).unwrap_err();
|
||||||
|
assert!(why.contains("1000") && why.contains("cash"), "{why}");
|
||||||
|
let why = m.try_call("books.create_bill", bill("2000")).unwrap_err();
|
||||||
|
assert!(why.contains("2000"), "{why}");
|
||||||
|
// A line naming no account is refused in books with documents_v2.
|
||||||
|
let mut unnamed = bill("1500");
|
||||||
|
unnamed["lines"] = json!([{"description": "Hosting", "amount": "100.00"}]);
|
||||||
|
let why = m.try_call("books.create_bill", unnamed).unwrap_err();
|
||||||
|
assert!(why.contains("A-10"), "{why}");
|
||||||
|
// Equipment is an asset that is not cash and holds no role.
|
||||||
|
let equipment = m.call("books.create_bill", bill("1500"));
|
||||||
|
assert_eq!(equipment["total"], "100.00", "{equipment}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The due date falls back in one order: the date given, the terms given,
|
||||||
|
/// the party's default terms, the books' invoicing default (plan 8.2 S4.4
|
||||||
|
/// item 14; A-13).
|
||||||
|
#[test]
|
||||||
|
fn terms_fall_back_in_order() {
|
||||||
|
let books = Books::new("documents-terms");
|
||||||
|
let mut m = Mcp::spawn(&books);
|
||||||
|
let harbor = m.customer("Harbor Coffee");
|
||||||
|
let line = json!([{"description": "Work", "amount": "100.00"}]);
|
||||||
|
let invoice = |extra: Value| {
|
||||||
|
let mut args = json!({"customer": harbor.to_string(), "date": "2026-10-23",
|
||||||
|
"lines": line, "confirmed": true});
|
||||||
|
for (k, v) in extra.as_object().unwrap() {
|
||||||
|
args[k] = v.clone();
|
||||||
|
}
|
||||||
|
args
|
||||||
|
};
|
||||||
|
|
||||||
|
// The books' default, unset: Net 30.
|
||||||
|
let plain = m.call("books.create_invoice", invoice(json!({})));
|
||||||
|
assert_eq!(plain["due_date"], "2026-11-22", "{plain}");
|
||||||
|
assert_eq!(plain["terms"], "Net 30", "{plain}");
|
||||||
|
|
||||||
|
// The customer's own terms come before the books'.
|
||||||
|
let party = m.party_of("Harbor Coffee");
|
||||||
|
m.call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(), "default_terms_as_customer": "Net 15 EOM"}),
|
||||||
|
);
|
||||||
|
let by_party = m.call("books.create_invoice", invoice(json!({})));
|
||||||
|
assert_eq!(by_party["due_date"], "2026-11-15", "{by_party}");
|
||||||
|
assert_eq!(by_party["terms"], "Net 15 EOM", "{by_party}");
|
||||||
|
|
||||||
|
// Terms given come before the customer's.
|
||||||
|
let given = m.call("books.create_invoice", invoice(json!({"terms": "Net 45"})));
|
||||||
|
assert_eq!(given["due_date"], "2026-12-07", "{given}");
|
||||||
|
assert_eq!(given["terms"], "Net 45", "{given}");
|
||||||
|
|
||||||
|
// A due date given wins over all of them, and prints no terms.
|
||||||
|
let dated = m.call(
|
||||||
|
"books.create_invoice",
|
||||||
|
invoice(json!({"due_date": "2026-12-31"})),
|
||||||
|
);
|
||||||
|
assert_eq!(dated["due_date"], "2026-12-31", "{dated}");
|
||||||
|
assert!(dated.get("terms").is_none(), "{dated}");
|
||||||
|
|
||||||
|
// The books' setting, when the customer has no terms.
|
||||||
|
m.call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(), "default_terms_as_customer": ""}),
|
||||||
|
);
|
||||||
|
m.call(
|
||||||
|
"books.set_setting",
|
||||||
|
json!({"decided_by": "Avery Quill",
|
||||||
|
"changes": [{"key": "invoicing.default_terms", "value": "Net 60"}]}),
|
||||||
|
);
|
||||||
|
let setting = m.call("books.create_invoice", invoice(json!({})));
|
||||||
|
assert_eq!(setting["due_date"], "2026-12-22", "{setting}");
|
||||||
|
assert_eq!(setting["terms"], "Net 60", "{setting}");
|
||||||
|
|
||||||
|
// A bill falls back to its vendor's terms, and no further.
|
||||||
|
let coast = m.vendor("Coastline Hosting");
|
||||||
|
m.expense("6500", "Hosting");
|
||||||
|
let coast_party = m.party_of("Coastline Hosting");
|
||||||
|
m.call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": coast_party.to_string(), "default_terms_as_vendor": "Net 10"}),
|
||||||
|
);
|
||||||
|
let file = m.upload(b"coastline", "coastline.pdf", "receipt");
|
||||||
|
let bill = m.call(
|
||||||
|
"books.create_bill",
|
||||||
|
json!({"vendor": coast.to_string(), "date": "2026-10-23", "vendor_ref": "CH-9",
|
||||||
|
"lines": [{"description": "Hosting", "amount": "100.00", "account": "6500"}],
|
||||||
|
"upload_id": file, "confirmed": true}),
|
||||||
|
);
|
||||||
|
assert_eq!(bill["due_date"], "2026-11-02", "{bill}");
|
||||||
|
assert_eq!(bill["terms"], "Net 10", "{bill}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `party_id` is the old spelling of `customer`/`vendor` and still names
|
||||||
|
/// the same record (until P5); a number that is one record's id and
|
||||||
|
/// another's name is refused, never guessed.
|
||||||
|
#[test]
|
||||||
|
fn party_id_alias_still_works() {
|
||||||
|
let books = Books::new("documents-alias");
|
||||||
|
let mut m = Mcp::spawn(&books);
|
||||||
|
let harbor = m.customer("Harbor Coffee");
|
||||||
|
let line = json!([{"description": "Work", "amount": "10.00"}]);
|
||||||
|
let by_alias = m.call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"party_id": harbor, "date": "2026-02-01", "terms": "Net 30",
|
||||||
|
"lines": line, "confirmed": true}),
|
||||||
|
);
|
||||||
|
let by_name = m.call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"customer": "harbor coffee", "date": "2026-02-01", "terms": "Net 30",
|
||||||
|
"lines": line, "confirmed": true}),
|
||||||
|
);
|
||||||
|
let invoices = Mcp::events(&books, "invoice.created");
|
||||||
|
let parties: Vec<u64> = invoices
|
||||||
|
.iter()
|
||||||
|
.map(|e| e["payload"]["customer_id"].as_u64().unwrap())
|
||||||
|
.collect();
|
||||||
|
assert_eq!(parties, [harbor, harbor], "one customer, two spellings");
|
||||||
|
assert_eq!(by_alias["total"], by_name["total"]);
|
||||||
|
|
||||||
|
// A customer named "2" that is not customer 2: the number is
|
||||||
|
// ambiguous, and is refused rather than guessed.
|
||||||
|
m.customer("Zeta");
|
||||||
|
m.customer("2");
|
||||||
|
let before = books.journal();
|
||||||
|
let why = m
|
||||||
|
.try_call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"customer": "2", "date": "2026-02-01", "terms": "Net 30",
|
||||||
|
"lines": line, "confirmed": true}),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(why.contains("ambiguous"), "{why}");
|
||||||
|
assert_eq!(books.journal(), before, "a refusal journals nothing");
|
||||||
|
// Nothing at all names no customer.
|
||||||
|
let why = m
|
||||||
|
.try_call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"date": "2026-02-01", "terms": "Net 30", "lines": line, "confirmed": true}),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(why.contains("customer"), "{why}");
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Mcp {
|
||||||
|
/// The party a name belongs to (its id).
|
||||||
|
fn party_of(&mut self, name: &str) -> u64 {
|
||||||
|
self.call("books.party", json!({"party": name}))["party"]["party_id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// D-55: `books.update_party` sets each contact field and `""` clears it.
|
||||||
|
#[test]
|
||||||
|
fn update_party_sets_and_clears_the_contact_block() {
|
||||||
|
let books = Books::new("documents-contact");
|
||||||
|
let mut m = Mcp::spawn(&books);
|
||||||
|
m.customer("Harbor Coffee");
|
||||||
|
let party = m.party_of("Harbor Coffee");
|
||||||
|
for (key, value) in [
|
||||||
|
("phone", "555-0142"),
|
||||||
|
("contact", "Dana Reed"),
|
||||||
|
("billing_address", "12 Billing Way\nPortland, OR 97209"),
|
||||||
|
("shipping_address", "Dock 4, 9 Wharf Street"),
|
||||||
|
("billing_email", "ap@harbor.example"),
|
||||||
|
] {
|
||||||
|
let out = m.call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(), key: value}),
|
||||||
|
);
|
||||||
|
assert_eq!(out[key], json!(value), "{key}: {out}");
|
||||||
|
}
|
||||||
|
// Its general email is still its own field, not the billing one.
|
||||||
|
m.call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(), "email": "hello@harbor.example"}),
|
||||||
|
);
|
||||||
|
let shown = m.call("books.party", json!({"party": party.to_string()}));
|
||||||
|
assert_eq!(shown["party"]["email"], "hello@harbor.example", "{shown}");
|
||||||
|
assert_eq!(
|
||||||
|
shown["party"]["billing_email"], "ap@harbor.example",
|
||||||
|
"{shown}"
|
||||||
|
);
|
||||||
|
// "" clears each, one party.updated apiece.
|
||||||
|
let before = Mcp::events(&books, "party.updated").len();
|
||||||
|
let cleared = m.call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(), "phone": "", "contact": "", "billing_address": "",
|
||||||
|
"shipping_address": "", "billing_email": ""}),
|
||||||
|
);
|
||||||
|
for key in [
|
||||||
|
"phone",
|
||||||
|
"contact",
|
||||||
|
"billing_address",
|
||||||
|
"shipping_address",
|
||||||
|
"billing_email",
|
||||||
|
] {
|
||||||
|
assert_eq!(cleared[key], Value::Null, "{key}: {cleared}");
|
||||||
|
}
|
||||||
|
assert_eq!(
|
||||||
|
Mcp::events(&books, "party.updated").len(),
|
||||||
|
before + 5,
|
||||||
|
"one event per field"
|
||||||
|
);
|
||||||
|
// A full TIN never goes into the block, and a bad email is refused.
|
||||||
|
let why = m
|
||||||
|
.try_call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(), "phone": "123-45-6789"}),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(why.contains("taxpayer"), "{why}");
|
||||||
|
let why = m
|
||||||
|
.try_call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(), "billing_email": "not an address"}),
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
assert!(why.contains("address"), "{why}");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// D-55: an invoice's bill-to is the party's billing details when it has
|
||||||
|
/// them, else the general ones, and it is fixed at issue: a later edit
|
||||||
|
/// does not change the copy sent, though the next invoice takes it.
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_bill_to_uses_the_billing_details() {
|
||||||
|
let books = Books::new("documents-bill-to");
|
||||||
|
let mut m = Mcp::spawn(&books);
|
||||||
|
let harbor = m.customer("Harbor Coffee");
|
||||||
|
let party = m.party_of("Harbor Coffee");
|
||||||
|
m.call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(),
|
||||||
|
"address": "PO Box 9, Portland, OR 97209",
|
||||||
|
"email": "hello@harbor.example",
|
||||||
|
"phone": "555-0142",
|
||||||
|
"contact": "Dana Reed",
|
||||||
|
"billing_address": "12 Billing Way, Portland, OR 97209",
|
||||||
|
"billing_email": "ap@harbor.example",
|
||||||
|
"shipping_address": "Dock 4, 9 Wharf Street"}),
|
||||||
|
);
|
||||||
|
let issue = |date: &str| {
|
||||||
|
json!({"customer": harbor.to_string(), "date": date, "terms": "Net 30",
|
||||||
|
"lines": [{"description": "Coffee", "amount": "100.00"}],
|
||||||
|
"confirmed": true})
|
||||||
|
};
|
||||||
|
m.call("books.create_invoice", issue("2026-02-01"));
|
||||||
|
let created = Mcp::events(&books, "invoice.created");
|
||||||
|
let bill_to = &created.last().unwrap()["payload"]["bill_to"];
|
||||||
|
assert_eq!(
|
||||||
|
bill_to["address"], "12 Billing Way, Portland, OR 97209",
|
||||||
|
"the billing address, not the general one: {bill_to}"
|
||||||
|
);
|
||||||
|
assert_eq!(bill_to["email"], "ap@harbor.example", "{bill_to}");
|
||||||
|
assert_eq!(bill_to["billing_email"], "ap@harbor.example", "{bill_to}");
|
||||||
|
assert_eq!(
|
||||||
|
bill_to["billing_address"],
|
||||||
|
"12 Billing Way, Portland, OR 97209"
|
||||||
|
);
|
||||||
|
assert_eq!(bill_to["phone"], "555-0142", "{bill_to}");
|
||||||
|
assert_eq!(bill_to["contact"], "Dana Reed", "{bill_to}");
|
||||||
|
assert_eq!(bill_to["shipping_address"], "Dock 4, 9 Wharf Street");
|
||||||
|
|
||||||
|
// A later edit: the issued snapshot is what it was.
|
||||||
|
m.call(
|
||||||
|
"books.update_party",
|
||||||
|
json!({"party": party.to_string(), "billing_address": "99 New Way", "phone": "555-9999"}),
|
||||||
|
);
|
||||||
|
let again = Mcp::events(&books, "invoice.created");
|
||||||
|
assert_eq!(
|
||||||
|
again.last().unwrap()["payload"]["bill_to"]["address"],
|
||||||
|
"12 Billing Way, Portland, OR 97209",
|
||||||
|
"an issued copy never changes"
|
||||||
|
);
|
||||||
|
// The next invoice takes today's details.
|
||||||
|
m.call("books.create_invoice", issue("2026-03-01"));
|
||||||
|
let third = Mcp::events(&books, "invoice.created");
|
||||||
|
assert_eq!(
|
||||||
|
third.last().unwrap()["payload"]["bill_to"]["address"],
|
||||||
|
"99 New Way"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
third.last().unwrap()["payload"]["bill_to"]["phone"],
|
||||||
|
"555-9999"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A one-off bill-to override carries the whole block, for this
|
||||||
|
// document alone.
|
||||||
|
m.call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"customer": harbor.to_string(), "date": "2026-04-01", "terms": "Net 30",
|
||||||
|
"lines": [{"description": "Coffee", "amount": "100.00"}],
|
||||||
|
"bill_to": {"name": "Harbor Coffee — Accounts Payable",
|
||||||
|
"billing_address": "1 One-Off Plaza",
|
||||||
|
"billing_email": "oneoff@harbor.example",
|
||||||
|
"contact": "Sam Ortiz", "phone": "555-0100",
|
||||||
|
"shipping_address": "Gate 2"},
|
||||||
|
"confirmed": true}),
|
||||||
|
);
|
||||||
|
let overridden = Mcp::events(&books, "invoice.created");
|
||||||
|
let bill_to = &overridden.last().unwrap()["payload"]["bill_to"];
|
||||||
|
assert_eq!(bill_to["name"], "Harbor Coffee — Accounts Payable");
|
||||||
|
assert_eq!(bill_to["address"], "1 One-Off Plaza", "{bill_to}");
|
||||||
|
assert_eq!(bill_to["email"], "oneoff@harbor.example", "{bill_to}");
|
||||||
|
assert_eq!(bill_to["contact"], "Sam Ortiz", "{bill_to}");
|
||||||
|
assert_eq!(bill_to["phone"], "555-0100", "{bill_to}");
|
||||||
|
assert_eq!(bill_to["shipping_address"], "Gate 2", "{bill_to}");
|
||||||
|
}
|
||||||
@@ -611,6 +611,63 @@ fn format_upgrade_waits_for_consent() {
|
|||||||
assert_eq!(format_lines(&dir), 1);
|
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
|
/// Copy the tree `from` into `to`, private as the server keeps a book
|
||||||
/// (folders 0700, files 0600).
|
/// (folders 0700, files 0600).
|
||||||
fn copy_private(from: &Path, to: &Path) {
|
fn copy_private(from: &Path, to: &Path) {
|
||||||
@@ -705,17 +762,24 @@ fn books_p2_served_cut_over_whole() {
|
|||||||
.unwrap();
|
.unwrap();
|
||||||
let waiting = journal(&dir);
|
let waiting = journal(&dir);
|
||||||
let refused: Vec<(&str, Value)> = vec![
|
let refused: Vec<(&str, Value)> = vec![
|
||||||
// An invoice's or bill's line naming its project
|
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and
|
||||||
// (document_line_projects, rules.md B-37 item 8).
|
// its line naming its project (document_line_projects, rules.md
|
||||||
|
// B-37 item 8).
|
||||||
(
|
(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": 1, "date": "2026-02-10", "due_date": "2026-03-12",
|
json!({"customer": "1", "date": "2026-02-10", "terms": "Net 30",
|
||||||
"lines": [{"description": "Design", "amount": "500.00", "class": "Lighthouse"}]}),
|
"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",
|
"books.create_bill",
|
||||||
json!({"party_id": 1, "date": "2026-02-11", "due_date": "2026-03-13",
|
json!({"vendor": "1", "date": "2026-02-11", "due_date": "2026-03-13",
|
||||||
"lines": [{"description": "Drafting", "amount": "120.00", "class": "Lighthouse"}]}),
|
"vendor_ref": "L-77",
|
||||||
|
"lines": [{"description": "Drafting", "amount": "120.00",
|
||||||
|
"account": "6600", "class": "Lighthouse"}],
|
||||||
|
"existing_attachment_id": 1, "confirmed": true}),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"books.set_setting",
|
"books.set_setting",
|
||||||
|
|||||||
@@ -1019,18 +1019,20 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
|
|||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01",
|
json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01",
|
||||||
"lines": [{"description": "a", "amount": MAX},
|
"lines": [{"description": "a", "amount": MAX},
|
||||||
{"description": "b", "amount": "1"}]}),
|
{"description": "b", "amount": "1"}], "confirmed": true}),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01",
|
json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01",
|
||||||
"lines": [{"description": "a", "amount": MAX},
|
"vendor_ref": "H-1",
|
||||||
{"description": "b", "amount": "1"}]}),
|
"lines": [{"description": "a", "amount": MAX, "account": "6000"},
|
||||||
|
{"description": "b", "amount": "1", "account": "6000"}],
|
||||||
|
"existing_attachment_id": 1, "confirmed": true}),
|
||||||
),
|
),
|
||||||
(
|
(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": client, "date": "2026-05-01", "due_date": "+262142-12-31",
|
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",
|
"books.record_payout",
|
||||||
|
|||||||
@@ -1178,7 +1178,10 @@ async fn a_token_does_only_what_its_scopes_allow() {
|
|||||||
tools.contains(&"books.post_entry".to_string()),
|
tools.contains(&"books.post_entry".to_string()),
|
||||||
"it may preview"
|
"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;
|
let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await;
|
||||||
if let Err(e) = &preview {
|
if let Err(e) = &preview {
|
||||||
assert!(
|
assert!(
|
||||||
|
|||||||
@@ -230,7 +230,14 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
|
|||||||
server.tool("books.add_project", json!({"name": "Heron"}));
|
server.tool("books.add_project", json!({"name": "Heron"}));
|
||||||
let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
|
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 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(
|
let inv = server.tool(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
doc(
|
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"}]),
|
json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
let bill = server.tool(
|
let bill_file = server
|
||||||
"books.create_bill",
|
.wire
|
||||||
doc(
|
.upload(b"Dana's invoice", "dana.pdf", "application/pdf", "receipt");
|
||||||
&dana,
|
let mut bill_args = doc(
|
||||||
"2026-03-09",
|
&dana,
|
||||||
json!([
|
"2026-03-09",
|
||||||
{"description": "Illustration", "amount": "987.65", "class": "Kestrel"},
|
json!([
|
||||||
{"description": "Postage", "amount": "12.00"},
|
{"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(
|
server.tool(
|
||||||
"books.receive_payment",
|
"books.receive_payment",
|
||||||
json!({"document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}),
|
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()
|
.as_array()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
.iter()
|
.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))
|
.map(|l| l["project"].as_str().map(str::to_string))
|
||||||
.collect();
|
.collect();
|
||||||
assert_eq!(viewer, served, "entry {entry}");
|
assert_eq!(viewer, served, "entry {entry}");
|
||||||
|
|||||||
@@ -421,16 +421,20 @@ fn a_file_links_to_every_kind_of_subject() {
|
|||||||
m.add_party("Acme Design", &["contractor"]);
|
m.add_party("Acme Design", &["contractor"]);
|
||||||
let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"].clone();
|
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 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(
|
let bill = m.ok(
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
|
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"]
|
)["id"]
|
||||||
.clone();
|
.clone();
|
||||||
let invoice = m.ok(
|
let invoice = m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
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"]
|
)["id"]
|
||||||
.clone();
|
.clone();
|
||||||
let period = m.ok(
|
let period = m.ok(
|
||||||
|
|||||||
@@ -857,7 +857,8 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
let inv = m.ok(
|
let inv = m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
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"]
|
)["id"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -865,10 +866,18 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
"books.receive_payment",
|
"books.receive_payment",
|
||||||
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
|
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(
|
let bill = m.ok(
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
|
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"]
|
)["id"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -882,7 +891,8 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
let dup = m.ok(
|
let dup = m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
|
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"]
|
)["id"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -1297,6 +1307,9 @@ struct Known {
|
|||||||
periods: Vec<u64>,
|
periods: Vec<u64>,
|
||||||
vendors: Vec<u64>,
|
vendors: Vec<u64>,
|
||||||
bills: Vec<u64>,
|
bills: Vec<u64>,
|
||||||
|
/// The vendor's invoice every bill filed in this run names (P4b S4.4:
|
||||||
|
/// a bill keeps the file it was entered from).
|
||||||
|
vendor_invoice: u64,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// One step of the random walk.
|
/// One step of the random walk.
|
||||||
@@ -1363,6 +1376,9 @@ enum Op {
|
|||||||
CreateBill {
|
CreateBill {
|
||||||
party: Pick,
|
party: Pick,
|
||||||
amount: String,
|
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 {
|
PayBill {
|
||||||
doc: Pick,
|
doc: Pick,
|
||||||
@@ -1444,7 +1460,8 @@ fn op_strategy() -> impl Strategy<Value = Op> {
|
|||||||
prop::sample::select(vec!["asset", "expense", "revenue", "bogus"]))
|
prop::sample::select(vec!["asset", "expense", "revenue", "bogus"]))
|
||||||
.prop_map(|(c, t)| Op::AddAccount { code: c.to_string(), ty: t.to_string() }),
|
.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 => 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 => (pick(), amount_strategy()).prop_map(|(doc, amount)| Op::PayBill { doc, amount }),
|
||||||
1 => amount_strategy().prop_map(|deposit| Op::Payout { deposit }),
|
1 => amount_strategy().prop_map(|deposit| Op::Payout { deposit }),
|
||||||
1 => (account_strategy(), prop::sample::select(vec!["8000", "1000", ""]))
|
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}),
|
json!({"code": code, "name": format!("Acct {code}"), "account_type": ty}),
|
||||||
),
|
),
|
||||||
Op::AddVendor { name } => ("books.add_vendor", json!({"name": name})),
|
Op::AddVendor { name } => ("books.add_vendor", json!({"name": name})),
|
||||||
Op::CreateBill { party, amount } => (
|
Op::CreateBill {
|
||||||
|
party,
|
||||||
|
amount,
|
||||||
|
vendor_ref,
|
||||||
|
} => (
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01",
|
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 } => (
|
Op::PayBill { doc, amount } => (
|
||||||
"books.pay_bill",
|
"books.pay_bill",
|
||||||
@@ -1647,7 +1670,10 @@ proptest! {
|
|||||||
let toml = books_toml(&dir, "25.00", "");
|
let toml = books_toml(&dir, "25.00", "");
|
||||||
let mut m = Mcp::start(&dir, &toml);
|
let mut m = Mcp::start(&dir, &toml);
|
||||||
m.accounts(BASE_CHART);
|
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 {
|
for op in &ops {
|
||||||
run_op(&mut m, &mut known, op);
|
run_op(&mut m, &mut known, op);
|
||||||
}
|
}
|
||||||
@@ -2482,7 +2508,8 @@ fn assigned_roles_steer_invoices() {
|
|||||||
m.ok(
|
m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
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();
|
let tb = m.ok("books.trial_balance", json!({})).to_string();
|
||||||
assert!(tb.contains("1110") && tb.contains("4100"), "{tb}");
|
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(
|
let inv = m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
|
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"]
|
)["id"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2574,7 +2602,8 @@ fn documents_void_and_tie_out_over_the_wire() {
|
|||||||
let dup = m.ok(
|
let dup = m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
|
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"]
|
)["id"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
@@ -2641,7 +2670,8 @@ fn documents_list_and_timeline_over_the_wire() {
|
|||||||
m.ok(
|
m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": customer, "date": date, "due_date": due,
|
json!({"party_id": customer, "date": date, "due_date": due,
|
||||||
"lines": [{"description": "Consulting", "amount": amount}]}),
|
"lines": [{"description": "Consulting", "amount": amount}],
|
||||||
|
"confirmed": true}),
|
||||||
)["id"]
|
)["id"]
|
||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap()
|
.unwrap()
|
||||||
|
|||||||
@@ -827,10 +827,18 @@ fn party_totals_match_a_hand_computed_year() {
|
|||||||
.as_u64()
|
.as_u64()
|
||||||
.unwrap();
|
.unwrap();
|
||||||
// A bill of 800 (not paid until paid), 300 of it paid from the bank.
|
// 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(
|
let bill = m.ok(
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": v, "date": "2026-07-01", "due_date": "2026-07-31",
|
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(
|
let pay = m.ok(
|
||||||
"books.pay_bill",
|
"books.pay_bill",
|
||||||
@@ -910,7 +918,8 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
|
|||||||
let inv = m.ok(
|
let inv = m.ok(
|
||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": c, "date": "2026-03-01", "due_date": "2026-03-31",
|
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(
|
let pay = m.ok(
|
||||||
"books.receive_payment",
|
"books.receive_payment",
|
||||||
|
|||||||
@@ -1236,7 +1236,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
|
|||||||
{"description": "Hosting", "amount": "200.00", "class": harbor.to_string()},
|
{"description": "Hosting", "amount": "200.00", "class": harbor.to_string()},
|
||||||
{"description": "Rush fee", "amount": "100.00"},
|
{"description": "Rush fee", "amount": "100.00"},
|
||||||
{"description": "Revisions", "amount": "500.00", "class": " zeta SITE "},
|
{"description": "Revisions", "amount": "500.00", "class": " zeta SITE "},
|
||||||
]}),
|
], "confirmed": true}),
|
||||||
);
|
);
|
||||||
assert_eq!(inv["total"], "3800.00");
|
assert_eq!(inv["total"], "3800.00");
|
||||||
assert!(inv.get("notes").is_none(), "{inv}");
|
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),
|
m.entry_class_ids(inv_entry),
|
||||||
vec![None, Some(zeta), Some(harbor), None]
|
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(
|
let bill = m.ok(
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08", "lines": [
|
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08",
|
||||||
{"description": "Illustration", "amount": "1200.00", "class": "Zeta site"},
|
"vendor_ref": "DANA-77",
|
||||||
{"description": "Postage", "amount": "50.00"},
|
"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();
|
let bill_entry = bill["entry_id"].as_u64().unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
m.entry_lines(bill_entry),
|
m.entry_lines(bill_entry),
|
||||||
vec![
|
vec![
|
||||||
l4("6999", "debit", "1200.00", Some("Zeta site")),
|
l4("6000", "debit", "1200.00", Some("Zeta site")),
|
||||||
l4("6999", "debit", "50.00", None),
|
l4("6000", "debit", "50.00", None),
|
||||||
l4("2000", "credit", "1250.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": "Final", "amount": "10.00", "class": "Harbor"},
|
||||||
{"description": "Again", "amount": "10.00", "class": "harbor"},
|
{"description": "Again", "amount": "10.00", "class": "harbor"},
|
||||||
{"description": "Old", "amount": "10.00", "class": "Dock"},
|
{"description": "Old", "amount": "10.00", "class": "Dock"},
|
||||||
]}),
|
], "confirmed": true}),
|
||||||
);
|
);
|
||||||
let notes: Vec<&str> = out["notes"]
|
let notes: Vec<&str> = out["notes"]
|
||||||
.as_array()
|
.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": "Build", "amount": "10.00", "class": "Kestrel"},
|
||||||
{"description": "More", "amount": "5.00", "class": "kestrel"},
|
{"description": "More", "amount": "5.00", "class": "kestrel"},
|
||||||
{"description": "Site", "amount": "7.00", "class": "Zeta"},
|
{"description": "Site", "amount": "7.00", "class": "Zeta"},
|
||||||
]}),
|
], "confirmed": true}),
|
||||||
);
|
);
|
||||||
let notes = out["notes"].as_array().unwrap();
|
let notes = out["notes"].as_array().unwrap();
|
||||||
assert_eq!(notes.len(), 1, "{out}");
|
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],
|
m.entry_lines(e)[1],
|
||||||
l4("4000", "credit", "15.00", Some("Kestrel"))
|
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(
|
let bill = m.ok(
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
|
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04",
|
||||||
{"description": "Icons", "amount": "10.00", "class": "Kestrel"},
|
"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}");
|
assert!(bill.get("notes").is_none(), "{bill}");
|
||||||
}
|
}
|
||||||
@@ -1473,7 +1489,7 @@ fn books_without_the_feature_refuse_a_tagged_document() {
|
|||||||
"books.create_invoice",
|
"books.create_invoice",
|
||||||
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
|
||||||
{"description": "a", "amount": "10.00"},
|
{"description": "a", "amount": "10.00"},
|
||||||
]}),
|
], "confirmed": true}),
|
||||||
);
|
);
|
||||||
let text = m.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"]
|
let text = m.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"]
|
||||||
.as_str()
|
.as_str()
|
||||||
|
|||||||
@@ -270,10 +270,18 @@ fn require_tax_category_refuses_defect_8() {
|
|||||||
// A bill creates General Expenses 6999 without a category: never
|
// A bill creates General Expenses 6999 without a category: never
|
||||||
// refused, named in the result's warnings and the digest.
|
// refused, named in the result's warnings and the digest.
|
||||||
let vendor = m.call("books.add_vendor", json!({"name": "Coastline Hosting"}));
|
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(
|
let bill = m.call(
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01",
|
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!(
|
assert_eq!(
|
||||||
bill["warnings"],
|
bill["warnings"],
|
||||||
@@ -287,10 +295,18 @@ fn require_tax_category_refuses_defect_8() {
|
|||||||
json!(["6999 General Expenses"])
|
json!(["6999 General Expenses"])
|
||||||
);
|
);
|
||||||
// A second bill creates nothing, and warns of nothing.
|
// 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(
|
let again = m.call(
|
||||||
"books.create_bill",
|
"books.create_bill",
|
||||||
json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02",
|
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}");
|
assert!(again.get("warnings").is_none(), "{again}");
|
||||||
// Back to warn (cleared): created, with the warning.
|
// Back to warn (cleared): created, with the warning.
|
||||||
|
|||||||
@@ -711,18 +711,65 @@ fn last_day_of_month(date: NaiveDate) -> Result<NaiveDate, LedgerError> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// The parties on a document, as they were when it was issued (plan P4b
|
/// 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
|
/// S4.1, A-13; the contact block, D-55): a name, and the address and email
|
||||||
/// document. Never today's party record when a snapshot exists.
|
/// 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)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
pub struct PartySnapshot {
|
pub struct PartySnapshot {
|
||||||
/// The party's name at issue.
|
/// The party's name at issue.
|
||||||
pub name: String,
|
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")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
/// Its email at issue, when the document printed one.
|
/// Its email at issue, as the document printed it: the billing email
|
||||||
|
/// when the party has one, else its general one (D-55).
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
|
/// Its telephone number at issue (D-55).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
/// The person there to write to, at issue (D-55).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub contact: Option<String>,
|
||||||
|
/// Where its bills go, when it said so (D-55). [`Self::address`] is
|
||||||
|
/// this one whenever it is set.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub billing_address: Option<String>,
|
||||||
|
/// Where goods go, when it said so (D-55): the issued page prints a
|
||||||
|
/// Ship-to line for it.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub shipping_address: Option<String>,
|
||||||
|
/// Where its bills are emailed, when it said so (D-55). [`Self::email`]
|
||||||
|
/// is this one whenever it is set.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub billing_email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PartySnapshot {
|
||||||
|
/// The snapshot of `party` at issue (A-13, D-55): its name, and the
|
||||||
|
/// contact details as they stood, with the billing address and billing
|
||||||
|
/// email in place of the general ones when it has them. The one place
|
||||||
|
/// the bill-to rule lives, so every document agrees.
|
||||||
|
pub fn from_party(party: &crate::Party) -> Self {
|
||||||
|
PartySnapshot {
|
||||||
|
name: party
|
||||||
|
.display_name
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| party.name.clone()),
|
||||||
|
address: party
|
||||||
|
.billing_address
|
||||||
|
.clone()
|
||||||
|
.or_else(|| party.address.clone()),
|
||||||
|
email: party.billing_email.clone().or_else(|| party.email.clone()),
|
||||||
|
phone: party.phone.clone(),
|
||||||
|
contact: party.contact.clone(),
|
||||||
|
billing_address: party.billing_address.clone(),
|
||||||
|
shipping_address: party.shipping_address.clone(),
|
||||||
|
billing_email: party.billing_email.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// How an invoice was sent (plan P4b S5; the type S4.1 adds so
|
/// How an invoice was sent (plan P4b S5; the type S4.1 adds so
|
||||||
|
|||||||
@@ -4453,6 +4453,11 @@ impl Ledger {
|
|||||||
email: None,
|
email: None,
|
||||||
default_terms_as_customer: None,
|
default_terms_as_customer: None,
|
||||||
default_terms_as_vendor: None,
|
default_terms_as_vendor: None,
|
||||||
|
phone: None,
|
||||||
|
contact: None,
|
||||||
|
billing_address: None,
|
||||||
|
shipping_address: None,
|
||||||
|
billing_email: None,
|
||||||
address: None,
|
address: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
archived: false,
|
archived: false,
|
||||||
@@ -4582,6 +4587,11 @@ impl Ledger {
|
|||||||
email: None,
|
email: None,
|
||||||
default_terms_as_customer: None,
|
default_terms_as_customer: None,
|
||||||
default_terms_as_vendor: None,
|
default_terms_as_vendor: None,
|
||||||
|
phone: None,
|
||||||
|
contact: None,
|
||||||
|
billing_address: None,
|
||||||
|
shipping_address: None,
|
||||||
|
billing_email: None,
|
||||||
address: None,
|
address: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
archived: false,
|
archived: false,
|
||||||
@@ -4656,6 +4666,11 @@ impl Ledger {
|
|||||||
Email(v) => p.email = v,
|
Email(v) => p.email = v,
|
||||||
DefaultTermsAsCustomer(v) => p.default_terms_as_customer = v,
|
DefaultTermsAsCustomer(v) => p.default_terms_as_customer = v,
|
||||||
DefaultTermsAsVendor(v) => p.default_terms_as_vendor = 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(())
|
Ok(())
|
||||||
}
|
}
|
||||||
@@ -4707,6 +4722,18 @@ impl Ledger {
|
|||||||
crate::parties::check_email("email", e).map_err(LedgerError::InvalidParty)?;
|
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
|
// Terms are typed and already canonical (`crate::Terms`), so
|
||||||
// there is nothing here a string could get wrong.
|
// there is nothing here a string could get wrong.
|
||||||
DefaultTermsAsCustomer(_) | DefaultTermsAsVendor(_) => {}
|
DefaultTermsAsCustomer(_) | DefaultTermsAsVendor(_) => {}
|
||||||
|
|||||||
@@ -234,6 +234,24 @@ pub struct Party {
|
|||||||
/// an invoice's bill-to snapshot at issue.
|
/// an invoice's bill-to snapshot at issue.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub email: Option<String>,
|
pub email: Option<String>,
|
||||||
|
/// Telephone number (D-55): printed on the invoice's bill-to.
|
||||||
|
#[serde(default)]
|
||||||
|
pub phone: Option<String>,
|
||||||
|
/// The named person there to write to (D-55).
|
||||||
|
#[serde(default)]
|
||||||
|
pub contact: Option<String>,
|
||||||
|
/// Where its bills go (D-55): the invoice's bill-to address when set,
|
||||||
|
/// else [`Self::address`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub billing_address: Option<String>,
|
||||||
|
/// Where goods go (D-55): the issued invoice prints a Ship-to line
|
||||||
|
/// when it is set.
|
||||||
|
#[serde(default)]
|
||||||
|
pub shipping_address: Option<String>,
|
||||||
|
/// Where its bills are emailed (D-55): the bill-to email when set,
|
||||||
|
/// else [`Self::email`].
|
||||||
|
#[serde(default)]
|
||||||
|
pub billing_email: Option<String>,
|
||||||
/// The terms this party grants the company as a customer: an invoice's
|
/// The terms this party grants the company as a customer: an invoice's
|
||||||
/// due date falls back to these (A-13).
|
/// due date falls back to these (A-13).
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@@ -778,11 +796,21 @@ pub enum PartyField {
|
|||||||
DefaultTermsAsCustomer(Option<crate::Terms>),
|
DefaultTermsAsCustomer(Option<crate::Terms>),
|
||||||
/// The terms it gives the company as a vendor (A-13; `None` clears).
|
/// The terms it gives the company as a vendor (A-13; `None` clears).
|
||||||
DefaultTermsAsVendor(Option<crate::Terms>),
|
DefaultTermsAsVendor(Option<crate::Terms>),
|
||||||
|
/// The telephone number (D-55; `None` clears it).
|
||||||
|
Phone(Option<String>),
|
||||||
|
/// The named person there to write to (D-55; `None` clears it).
|
||||||
|
Contact(Option<String>),
|
||||||
|
/// Where its bills go (D-55; `None` clears it).
|
||||||
|
BillingAddress(Option<String>),
|
||||||
|
/// Where goods go (D-55; `None` clears it).
|
||||||
|
ShippingAddress(Option<String>),
|
||||||
|
/// Where its bills are emailed (D-55; `None` clears it).
|
||||||
|
BillingEmail(Option<String>),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl PartyField {
|
impl PartyField {
|
||||||
/// Every field's wire name, as `party.updated` journals it.
|
/// Every field's wire name, as `party.updated` journals it.
|
||||||
pub const FIELDS: [&'static str; 9] = [
|
pub const FIELDS: [&'static str; 14] = [
|
||||||
"name",
|
"name",
|
||||||
"display_name",
|
"display_name",
|
||||||
"website",
|
"website",
|
||||||
@@ -792,6 +820,11 @@ impl PartyField {
|
|||||||
"email",
|
"email",
|
||||||
"default_terms_as_customer",
|
"default_terms_as_customer",
|
||||||
"default_terms_as_vendor",
|
"default_terms_as_vendor",
|
||||||
|
"phone",
|
||||||
|
"contact",
|
||||||
|
"billing_address",
|
||||||
|
"shipping_address",
|
||||||
|
"billing_email",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Whether `field` is a field this version knows. A `party.updated`
|
/// Whether `field` is a field this version knows. A `party.updated`
|
||||||
@@ -800,14 +833,19 @@ impl PartyField {
|
|||||||
Self::FIELDS.contains(&field)
|
Self::FIELDS.contains(&field)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Whether the field needs the `documents_v2` feature: the three A-13
|
/// Whether the field needs the `documents_v2` feature: the A-13 facts
|
||||||
/// facts an older version would drop.
|
/// and the D-55 contact block, which an older version would drop.
|
||||||
pub fn needs_documents_v2(&self) -> bool {
|
pub fn needs_documents_v2(&self) -> bool {
|
||||||
matches!(
|
matches!(
|
||||||
self,
|
self,
|
||||||
PartyField::Email(_)
|
PartyField::Email(_)
|
||||||
| PartyField::DefaultTermsAsCustomer(_)
|
| PartyField::DefaultTermsAsCustomer(_)
|
||||||
| PartyField::DefaultTermsAsVendor(_)
|
| PartyField::DefaultTermsAsVendor(_)
|
||||||
|
| PartyField::Phone(_)
|
||||||
|
| PartyField::Contact(_)
|
||||||
|
| PartyField::BillingAddress(_)
|
||||||
|
| PartyField::ShippingAddress(_)
|
||||||
|
| PartyField::BillingEmail(_)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -127,6 +127,11 @@ fn bare_party(name: &str) -> Party {
|
|||||||
email: None,
|
email: None,
|
||||||
default_terms_as_customer: None,
|
default_terms_as_customer: None,
|
||||||
default_terms_as_vendor: None,
|
default_terms_as_vendor: None,
|
||||||
|
phone: None,
|
||||||
|
contact: None,
|
||||||
|
billing_address: None,
|
||||||
|
shipping_address: None,
|
||||||
|
billing_email: None,
|
||||||
address: None,
|
address: None,
|
||||||
notes: None,
|
notes: None,
|
||||||
archived: false,
|
archived: false,
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
- A new version of wanderbooks can keep the books in a newer journal format than the version that wrote them. Books an older version wrote wait for a format upgrade: books.start says so (format_upgrade) and puts it first among its next steps, in every session until it is done.
|
- 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.
|
- 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.
|
- 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.
|
- 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.
|
- After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours.
|
||||||
@@ -160,9 +160,9 @@ pub const TABLE: [(&str, Needs); 122] = [
|
|||||||
("books.parties", R),
|
("books.parties", R),
|
||||||
("books.party", R),
|
("books.party", R),
|
||||||
("books.set_entry_party", W),
|
("books.set_entry_party", W),
|
||||||
// These post at once, unchecked.
|
// These preview, then post when confirmed (P4b S4.4, A-1).
|
||||||
("books.create_invoice", P),
|
("books.create_invoice", C),
|
||||||
("books.create_bill", P),
|
("books.create_bill", C),
|
||||||
("books.receive_payment", P),
|
("books.receive_payment", P),
|
||||||
("books.pay_bill", P),
|
("books.pay_bill", P),
|
||||||
("books.void_document", P),
|
("books.void_document", P),
|
||||||
@@ -371,7 +371,7 @@ mod tests {
|
|||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
needs(
|
needs(
|
||||||
"books.create_invoice",
|
"books.receive_payment",
|
||||||
args(json!({"confirmed": false})).as_ref()
|
args(json!({"confirmed": false})).as_ref()
|
||||||
),
|
),
|
||||||
Some(Scope::Post),
|
Some(Scope::Post),
|
||||||
@@ -421,7 +421,11 @@ mod tests {
|
|||||||
assert!(!shown("books.access_log", &read));
|
assert!(!shown("books.access_log", &read));
|
||||||
let write = set(&[Scope::Read, Scope::Write]);
|
let write = set(&[Scope::Read, Scope::Write]);
|
||||||
assert!(shown("books.post_entry", &write), "it may preview");
|
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]);
|
let post = set(&[Scope::Post]);
|
||||||
assert!(shown("books.post_entry", &post));
|
assert!(shown("books.post_entry", &post));
|
||||||
assert!(!shown("books.chart", &post));
|
assert!(!shown("books.chart", &post));
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
//! (`docs/mcp-interface.md`; rules.md B-38).
|
//! (`docs/mcp-interface.md`; rules.md B-38).
|
||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
|
use ledger_core::Terms;
|
||||||
|
use ledger_core::arap::{self, PartySnapshot};
|
||||||
|
|
||||||
/// A payment's method and reference from its parameters (the method by
|
/// A payment's method and reference from its parameters (the method by
|
||||||
/// id or label, books.instruments).
|
/// 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
|
/// An invoice's or bill's lines as the tool takes them: each amount
|
||||||
/// parsed and each project resolved ([`resolve_class`]) before anything is
|
/// parsed, each project and account resolved ([`resolve_class`],
|
||||||
/// written.
|
/// [`resolve_account`]), and quantity and unit price together, before
|
||||||
|
/// anything is written (plan P4b S4.4).
|
||||||
fn document_lines(
|
fn document_lines(
|
||||||
store: &Store,
|
store: &Store,
|
||||||
lines: Vec<DocumentLineParam>,
|
lines: Vec<DocumentLineParam>,
|
||||||
@@ -87,6 +90,14 @@ fn document_lines(
|
|||||||
lines
|
lines
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|l| {
|
.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 {
|
Ok(DocumentLine {
|
||||||
description: l.description,
|
description: l.description,
|
||||||
amount: parse_amount(&l.amount)?,
|
amount: parse_amount(&l.amount)?,
|
||||||
@@ -95,9 +106,13 @@ fn document_lines(
|
|||||||
.as_deref()
|
.as_deref()
|
||||||
.map(|c| resolve_class(store, c))
|
.map(|c| resolve_class(store, c))
|
||||||
.transpose()?,
|
.transpose()?,
|
||||||
account: None,
|
account: l
|
||||||
quantity: None,
|
.account
|
||||||
unit_price: None,
|
.as_deref()
|
||||||
|
.map(|a| resolve_account(store, a))
|
||||||
|
.transpose()?,
|
||||||
|
quantity,
|
||||||
|
unit_price,
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
@@ -128,12 +143,349 @@ fn document_notes(
|
|||||||
notes
|
notes
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Resolve a customer the way [`resolve_vendor`] resolves a vendor: a
|
||||||
|
/// customer id, one of its party's ids, or its name (ignoring case and
|
||||||
|
/// surrounding space); a number that is one customer's id and another's
|
||||||
|
/// name is refused, never guessed (plan P4b S4.4).
|
||||||
|
fn resolve_customer(store: &Store, customer: &str) -> Result<u64, String> {
|
||||||
|
let ledger = store.ledger();
|
||||||
|
let by_id = customer.trim().parse::<u64>().ok().and_then(|id| {
|
||||||
|
ledger.get_customer(id).map(|_| id).or_else(|| {
|
||||||
|
ledger
|
||||||
|
.get_party(id)
|
||||||
|
.and_then(|p| p.customer)
|
||||||
|
.filter(|c| ledger.get_customer(*c).is_some())
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let matches: Vec<u64> = ledger
|
||||||
|
.list_customers()
|
||||||
|
.into_iter()
|
||||||
|
.filter(|c| c.name.eq_ignore_ascii_case(customer.trim()))
|
||||||
|
.map(|c| c.id)
|
||||||
|
.collect();
|
||||||
|
if let Some(id) = by_id {
|
||||||
|
return match matches.as_slice() {
|
||||||
|
[] => Ok(id),
|
||||||
|
[m] if *m == id => Ok(id),
|
||||||
|
_ => Err(format!(
|
||||||
|
"customer {customer:?} is ambiguous: it is customer {id}'s id and another customer's name; pass the id"
|
||||||
|
)),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
match matches.as_slice() {
|
||||||
|
[id] => Ok(*id),
|
||||||
|
[] => Err(format!(
|
||||||
|
"unknown customer {customer:?}: add it with books.add_customer"
|
||||||
|
)),
|
||||||
|
_ => Err(format!(
|
||||||
|
"several customers are named {customer:?}: pass the id"
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a vendor the way [`resolve_customer`] resolves a customer: a
|
||||||
|
/// vendor id, one of its party's ids, or its name (plan P4b S4.4).
|
||||||
|
fn resolve_vendor_arg(store: &Store, vendor: &str) -> Result<u64, String> {
|
||||||
|
if let Ok(id) = vendor.trim().parse::<u64>() {
|
||||||
|
let ledger = store.ledger();
|
||||||
|
if ledger.get_vendor(id).is_none()
|
||||||
|
&& let Some(v) = ledger.get_party(id).and_then(|p| p.vendor)
|
||||||
|
&& ledger.get_vendor(v).is_some()
|
||||||
|
{
|
||||||
|
return Ok(v);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resolve_vendor(store, vendor)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Whether these books declared the journal feature `documents_v2`: the
|
||||||
|
/// bills and invoices carrying a number, terms, snapshots, references, a
|
||||||
|
/// line's account or a quantity need it (rules.md B-27, D-52), and the
|
||||||
|
/// bill rules A-10, A-13 and the file apply only where it is declared.
|
||||||
|
fn documents_v2(store: &Store) -> bool {
|
||||||
|
store
|
||||||
|
.journal_format()
|
||||||
|
.has_feature(ledger_store::format::DOCUMENTS_V2)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The customer's or vendor's name to give a snapshot: its display name,
|
||||||
|
/// else its record's name.
|
||||||
|
fn party_label(store: &Store, customer: Option<u64>, vendor: Option<u64>) -> String {
|
||||||
|
let ledger = store.ledger();
|
||||||
|
if let Some(c) = customer {
|
||||||
|
if let Some(p) = ledger
|
||||||
|
.party_of_customer(c)
|
||||||
|
.and_then(|p| ledger.get_party(p))
|
||||||
|
{
|
||||||
|
return p.display_name.clone().unwrap_or_else(|| p.name.clone());
|
||||||
|
}
|
||||||
|
return ledger
|
||||||
|
.get_customer(c)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
if let Some(v) = vendor {
|
||||||
|
if let Some(p) = ledger.party_of_vendor(v).and_then(|p| ledger.get_party(p)) {
|
||||||
|
return p.display_name.clone().unwrap_or_else(|| p.name.clone());
|
||||||
|
}
|
||||||
|
return ledger
|
||||||
|
.get_vendor(v)
|
||||||
|
.map(|v| v.name.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
String::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Us as the document prints us (A-13, 8.3.4): the company's name, address
|
||||||
|
/// and email from its settings.
|
||||||
|
fn seller_snapshot(settings: &Settings) -> PartySnapshot {
|
||||||
|
let profile = |key: &str| {
|
||||||
|
settings
|
||||||
|
.company
|
||||||
|
.profile
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k == key)
|
||||||
|
.map(|(_, v)| v.clone())
|
||||||
|
.filter(|v| !v.trim().is_empty())
|
||||||
|
};
|
||||||
|
PartySnapshot {
|
||||||
|
name: settings.company.name.clone(),
|
||||||
|
address: profile("address"),
|
||||||
|
email: profile("email"),
|
||||||
|
// The contact block is the party's (D-55): the company has none
|
||||||
|
// yet (its own details are settings the page prints separately).
|
||||||
|
phone: None,
|
||||||
|
contact: None,
|
||||||
|
billing_address: None,
|
||||||
|
shipping_address: None,
|
||||||
|
billing_email: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The customer's snapshot at issue: what the caller gives, each part
|
||||||
|
/// omitted the party record's (A-13, D-55). The billing address and
|
||||||
|
/// billing email, when there are any, are what the page prints (D-55);
|
||||||
|
/// [`PartySnapshot::from_party`] is where that rule lives.
|
||||||
|
fn customer_snapshot(
|
||||||
|
store: &Store,
|
||||||
|
customer: u64,
|
||||||
|
given: Option<BillToParam>,
|
||||||
|
) -> Result<PartySnapshot, String> {
|
||||||
|
let ledger = store.ledger();
|
||||||
|
let party = ledger
|
||||||
|
.party_of_customer(customer)
|
||||||
|
.and_then(|p| ledger.get_party(p));
|
||||||
|
let mut snapshot = match party {
|
||||||
|
Some(p) => PartySnapshot::from_party(p),
|
||||||
|
None => PartySnapshot {
|
||||||
|
name: party_label(store, Some(customer), None),
|
||||||
|
address: None,
|
||||||
|
email: None,
|
||||||
|
phone: None,
|
||||||
|
contact: None,
|
||||||
|
billing_address: None,
|
||||||
|
shipping_address: None,
|
||||||
|
billing_email: None,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
if let Some(g) = given {
|
||||||
|
let text = |v: String| {
|
||||||
|
let t = v.trim().to_string();
|
||||||
|
Some(t).filter(|t| !t.is_empty())
|
||||||
|
};
|
||||||
|
if let Some(v) = g.name {
|
||||||
|
snapshot.name = v.trim().to_string();
|
||||||
|
}
|
||||||
|
if let Some(v) = g.address {
|
||||||
|
snapshot.address = text(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = g.email {
|
||||||
|
snapshot.email = text(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = g.phone {
|
||||||
|
snapshot.phone = text(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = g.contact {
|
||||||
|
snapshot.contact = text(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = g.shipping_address {
|
||||||
|
snapshot.shipping_address = text(v);
|
||||||
|
}
|
||||||
|
if let Some(v) = g.billing_address {
|
||||||
|
snapshot.billing_address = text(v);
|
||||||
|
snapshot.address = snapshot.billing_address.clone();
|
||||||
|
}
|
||||||
|
if let Some(v) = g.billing_email {
|
||||||
|
snapshot.billing_email = text(v);
|
||||||
|
snapshot.email = snapshot.billing_email.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ledger_core::parties::check_text("the bill-to name", &snapshot.name)?;
|
||||||
|
if let Some(a) = &snapshot.address {
|
||||||
|
ledger_core::parties::check_text("the bill-to address", a)?;
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("the bill-to contact", &snapshot.contact),
|
||||||
|
("the bill-to phone", &snapshot.phone),
|
||||||
|
("the billing address", &snapshot.billing_address),
|
||||||
|
("the shipping address", &snapshot.shipping_address),
|
||||||
|
] {
|
||||||
|
if let Some(v) = value {
|
||||||
|
ledger_core::parties::check_text(field, v)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for (field, value) in [
|
||||||
|
("the bill-to email", &snapshot.email),
|
||||||
|
("the billing email", &snapshot.billing_email),
|
||||||
|
] {
|
||||||
|
if let Some(v) = value {
|
||||||
|
ledger_core::parties::check_email(field, v)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(snapshot)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The terms an invoice takes when neither it nor the customer gives any:
|
||||||
|
/// `invoicing.default_terms`, else its default "Net 30" (plan P4b S4.2,
|
||||||
|
/// S4.4). None in books that haven't declared `documents_v2`: their
|
||||||
|
/// invoices keep the legacy shape, and a due date is given instead.
|
||||||
|
fn books_default_terms(store: &Store) -> Result<Option<Terms>, String> {
|
||||||
|
if !documents_v2(store) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let text = store
|
||||||
|
.ledger()
|
||||||
|
.settings()
|
||||||
|
.get("invoicing.default_terms")
|
||||||
|
.and_then(|r| r.value.as_str().map(str::to_string))
|
||||||
|
.unwrap_or_else(|| "Net 30".to_string());
|
||||||
|
Ok(Some(Terms::parse(&text).map_err(|e| e.to_string())?))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A party's default terms on one side (A-13).
|
||||||
|
fn party_default_terms(
|
||||||
|
store: &Store,
|
||||||
|
party: Option<ledger_core::PartyId>,
|
||||||
|
customer: bool,
|
||||||
|
) -> Option<Terms> {
|
||||||
|
let p = store.ledger().get_party(party?)?;
|
||||||
|
if customer {
|
||||||
|
p.default_terms_as_customer.clone()
|
||||||
|
} else {
|
||||||
|
p.default_terms_as_vendor.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The due date a document takes and the terms to store with it: the due
|
||||||
|
/// date given; else the terms given; else the party's default terms; else
|
||||||
|
/// the books' own default (invoice only); else refused, naming the ways
|
||||||
|
/// to give one (plan P4b S4.4 item 14).
|
||||||
|
///
|
||||||
|
/// Terms are stored only when they came from the caller or a default: a
|
||||||
|
/// document given only a due date keeps the legacy shape (`terms: None`)
|
||||||
|
/// and so is never refused for a new field on books that haven't declared
|
||||||
|
/// `documents_v2`.
|
||||||
|
fn due_and_terms(
|
||||||
|
date: NaiveDate,
|
||||||
|
due_date: Option<&str>,
|
||||||
|
terms: Option<&str>,
|
||||||
|
defaults: Vec<Option<Terms>>,
|
||||||
|
what: &str,
|
||||||
|
ways: &str,
|
||||||
|
) -> Result<(NaiveDate, Option<Terms>), String> {
|
||||||
|
let given = terms
|
||||||
|
.map(Terms::parse)
|
||||||
|
.transpose()
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
if let Some(d) = due_date {
|
||||||
|
return Ok((parse_date(d)?, given));
|
||||||
|
}
|
||||||
|
if let Some(t) = given {
|
||||||
|
let due = t.due_date(date).map_err(|e| e.to_string())?;
|
||||||
|
return Ok((due, Some(t)));
|
||||||
|
}
|
||||||
|
if let Some(t) = defaults.into_iter().flatten().next() {
|
||||||
|
let due = t.due_date(date).map_err(|e| e.to_string())?;
|
||||||
|
return Ok((due, Some(t)));
|
||||||
|
}
|
||||||
|
Err(format!("give {what} a due date: {ways}"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A posting line as the preview shows it, with its project's name.
|
||||||
|
fn posting_out(ledger: &Ledger, lines: &[ledger_store::PreviewPosting]) -> Vec<DocumentPostingOut> {
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| DocumentPostingOut {
|
||||||
|
account: l.account.clone(),
|
||||||
|
name: l.name.clone(),
|
||||||
|
side: match l.side {
|
||||||
|
Side::Debit => "debit".to_string(),
|
||||||
|
Side::Credit => "credit".to_string(),
|
||||||
|
},
|
||||||
|
amount: money(l.amount),
|
||||||
|
class: l
|
||||||
|
.class_id
|
||||||
|
.and_then(|c| ledger.get_class(c))
|
||||||
|
.map(|c| c.name.clone()),
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A word of `text` as a URL carries it: unreserved ASCII as it is, every
|
||||||
|
/// other byte `%XX` (UTF-8).
|
||||||
|
fn url_encode(text: &str) -> String {
|
||||||
|
let mut out = String::new();
|
||||||
|
for b in text.bytes() {
|
||||||
|
match b {
|
||||||
|
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'.' | b'_' | b'~' => {
|
||||||
|
out.push(b as char)
|
||||||
|
}
|
||||||
|
_ => out.push_str(&format!("%{b:02X}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The `mailto:` convenience link for a human to send an invoice from
|
||||||
|
/// their own mail program (A-4; the books never send): "Invoice INV-0007
|
||||||
|
/// from {company}" as the subject, a short body naming the file to
|
||||||
|
/// attach, the body URL-encoded and cut to 1,800 bytes at the last whole
|
||||||
|
/// word (plan P4b S4.4 item 14).
|
||||||
|
fn mailto_link(settings: &Settings, invoice: &arap::Invoice) -> String {
|
||||||
|
let number = invoice
|
||||||
|
.number
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("Invoice {}", invoice.id));
|
||||||
|
let to = invoice
|
||||||
|
.bill_to
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|b| b.email.clone())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let subject = url_encode(&format!("Invoice {number} from {}", settings.company.name));
|
||||||
|
let body = format!(
|
||||||
|
"Hello,\n\nInvoice {number} for {} is due {}. It is attached as {number}.pdf.\n\nThank you,\n{}",
|
||||||
|
money(invoice.total().unwrap_or_default()),
|
||||||
|
invoice.due_date,
|
||||||
|
settings.company.name
|
||||||
|
);
|
||||||
|
let mut encoded = url_encode(&body);
|
||||||
|
const LIMIT: usize = 1_800;
|
||||||
|
if encoded.len() > LIMIT {
|
||||||
|
// The last whole word that fits: everything before a "%20".
|
||||||
|
match encoded[..LIMIT].rfind("%20") {
|
||||||
|
Some(at) => encoded.truncate(at),
|
||||||
|
None => encoded.truncate(LIMIT),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("mailto:{to}?subject={subject}&body={encoded}")
|
||||||
|
}
|
||||||
|
|
||||||
#[tool_router(router = documents_router, vis = "pub(super)")]
|
#[tool_router(router = documents_router, vis = "pub(super)")]
|
||||||
impl BooksServer {
|
impl BooksServer {
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.create_invoice",
|
name = "books.create_invoice",
|
||||||
title = "Create and post an 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = true,
|
destructive_hint = true,
|
||||||
@@ -142,37 +494,143 @@ impl BooksServer {
|
|||||||
)]
|
)]
|
||||||
async fn create_invoice(
|
async fn create_invoice(
|
||||||
&self,
|
&self,
|
||||||
params: Parameters<CreateDocumentParams>,
|
params: Parameters<CreateInvoiceParams>,
|
||||||
) -> Result<Json<CreateDocumentOut>, String> {
|
) -> Result<Json<InvoiceOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let mut store = self.lock()?;
|
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 lines = document_lines(&store, p.lines)?;
|
||||||
let first_new = store.ledger().next_account_id();
|
let v2 = documents_v2(&store);
|
||||||
let id = store
|
let party = store.ledger().party_of_customer(customer_id);
|
||||||
.create_invoice(
|
let (due_date, terms) = due_and_terms(
|
||||||
p.party_id,
|
date,
|
||||||
parse_date(&p.date)?,
|
p.due_date.as_deref(),
|
||||||
parse_date(&p.due_date)?,
|
p.terms.as_deref(),
|
||||||
lines,
|
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())?;
|
.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()
|
.ledger()
|
||||||
.get_invoice(id)
|
.get_invoice(id)
|
||||||
.ok_or_else(|| format!("invoice {id} not found after creation"))?;
|
.ok_or_else(|| format!("invoice {id} not found after creation"))?
|
||||||
Ok(Json(CreateDocumentOut {
|
.clone();
|
||||||
id,
|
Ok(Json(InvoiceOut {
|
||||||
total: inv.total().map_err(|e| e.to_string())?.to_string(),
|
preview: None,
|
||||||
entry_id: inv.entry_id,
|
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),
|
warnings: created_uncategorized(store.ledger(), first_new),
|
||||||
notes: document_notes(store.ledger(), &inv.lines, true),
|
notes: document_notes(store.ledger(), &lines, true),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(
|
#[tool(
|
||||||
name = "books.create_bill",
|
name = "books.create_bill",
|
||||||
title = "Create and post a 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = true,
|
destructive_hint = true,
|
||||||
@@ -181,30 +639,211 @@ impl BooksServer {
|
|||||||
)]
|
)]
|
||||||
async fn create_bill(
|
async fn create_bill(
|
||||||
&self,
|
&self,
|
||||||
params: Parameters<CreateDocumentParams>,
|
params: Parameters<CreateBillParams>,
|
||||||
) -> Result<Json<CreateDocumentOut>, String> {
|
) -> Result<Json<BillOut>, String> {
|
||||||
let p = params.0;
|
let p = params.0;
|
||||||
let mut store = self.lock()?;
|
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 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 first_new = store.ledger().next_account_id();
|
||||||
let id = store
|
let id = store
|
||||||
.create_bill(
|
.create_bill_with(vendor_id, date, due_date, lines.clone(), fields)
|
||||||
p.party_id,
|
|
||||||
parse_date(&p.date)?,
|
|
||||||
parse_date(&p.due_date)?,
|
|
||||||
lines,
|
|
||||||
)
|
|
||||||
.map_err(|e| e.to_string())?;
|
.map_err(|e| e.to_string())?;
|
||||||
let bill = store
|
let bill = store
|
||||||
.ledger()
|
.ledger()
|
||||||
.get_bill(id)
|
.get_bill(id)
|
||||||
.ok_or_else(|| format!("bill {id} not found after creation"))?;
|
.ok_or_else(|| format!("bill {id} not found after creation"))?
|
||||||
Ok(Json(CreateDocumentOut {
|
.clone();
|
||||||
id,
|
let entry_id = bill.entry_id;
|
||||||
total: bill.total().map_err(|e| e.to_string())?.to_string(),
|
// The vendor's invoice, filed on the bill's entry and linked to the
|
||||||
entry_id: bill.entry_id,
|
// 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),
|
warnings: created_uncategorized(store.ledger(), first_new),
|
||||||
notes: document_notes(store.ledger(), &bill.lines, false),
|
notes: document_notes(store.ledger(), &lines, false),
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -63,6 +63,20 @@ fn party_out(ledger: &Ledger, p: &ledger_core::Party) -> PartyOut {
|
|||||||
website: p.website.clone(),
|
website: p.website.clone(),
|
||||||
address: p.address.clone(),
|
address: p.address.clone(),
|
||||||
notes: p.notes.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,
|
archived: p.archived,
|
||||||
vendor_id: p.vendor,
|
vendor_id: p.vendor,
|
||||||
customer_id: p.customer,
|
customer_id: p.customer,
|
||||||
@@ -540,7 +554,7 @@ impl BooksServer {
|
|||||||
#[tool(
|
#[tool(
|
||||||
name = "books.update_party",
|
name = "books.update_party",
|
||||||
title = "Update a 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(
|
annotations(
|
||||||
read_only_hint = false,
|
read_only_hint = false,
|
||||||
destructive_hint = false,
|
destructive_hint = false,
|
||||||
@@ -571,6 +585,40 @@ impl BooksServer {
|
|||||||
if let Some(v) = p.notes {
|
if let Some(v) = p.notes {
|
||||||
fields.push(F::Notes(optional_text(&v)));
|
fields.push(F::Notes(optional_text(&v)));
|
||||||
}
|
}
|
||||||
|
if let Some(v) = p.email {
|
||||||
|
fields.push(F::Email(optional_text(&v)));
|
||||||
|
}
|
||||||
|
// "" clears the terms; any other text is trade terms this party
|
||||||
|
// grants or is granted (B-39).
|
||||||
|
let terms = |v: String| -> Result<Option<ledger_core::Terms>, String> {
|
||||||
|
if v.trim().is_empty() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
ledger_core::Terms::parse(&v)
|
||||||
|
.map(Some)
|
||||||
|
.map_err(|e| e.to_string())
|
||||||
|
};
|
||||||
|
if let Some(v) = p.default_terms_as_customer {
|
||||||
|
fields.push(F::DefaultTermsAsCustomer(terms(v)?));
|
||||||
|
}
|
||||||
|
if let Some(v) = p.default_terms_as_vendor {
|
||||||
|
fields.push(F::DefaultTermsAsVendor(terms(v)?));
|
||||||
|
}
|
||||||
|
if let Some(v) = p.phone {
|
||||||
|
fields.push(F::Phone(optional_text(&v)));
|
||||||
|
}
|
||||||
|
if let Some(v) = p.contact {
|
||||||
|
fields.push(F::Contact(optional_text(&v)));
|
||||||
|
}
|
||||||
|
if let Some(v) = p.billing_address {
|
||||||
|
fields.push(F::BillingAddress(optional_text(&v)));
|
||||||
|
}
|
||||||
|
if let Some(v) = p.shipping_address {
|
||||||
|
fields.push(F::ShippingAddress(optional_text(&v)));
|
||||||
|
}
|
||||||
|
if let Some(v) = p.billing_email {
|
||||||
|
fields.push(F::BillingEmail(optional_text(&v)));
|
||||||
|
}
|
||||||
if let Some(a) = p.archived {
|
if let Some(a) = p.archived {
|
||||||
fields.push(F::Archived(a));
|
fields.push(F::Archived(a));
|
||||||
}
|
}
|
||||||
@@ -588,7 +636,7 @@ impl BooksServer {
|
|||||||
}
|
}
|
||||||
if fields.is_empty() && p.creditor_of.is_none() {
|
if fields.is_empty() && p.creditor_of.is_none() {
|
||||||
return Err(
|
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(),
|
.into(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -604,10 +652,15 @@ impl BooksServer {
|
|||||||
.set_party_creditor(id, *a, *on)
|
.set_party_creditor(id, *a, *on)
|
||||||
.map_err(|e| e.to_string())?;
|
.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:
|
// command's first line, never between its lines (rules.md B-27:
|
||||||
// books waiting for their format upgrade must not be left with the
|
// 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() {
|
if !creditor.is_empty() {
|
||||||
store
|
store
|
||||||
.ensure_feature(ledger_store::format::PARTY_CREDITORS)
|
.ensure_feature(ledger_store::format::PARTY_CREDITORS)
|
||||||
|
|||||||
+274
-26
@@ -3056,39 +3056,118 @@ pub struct DocumentLineParam {
|
|||||||
/// receivable or payable line never does.
|
/// receivable or payable line never does.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub class: Option<String>,
|
pub class: Option<String>,
|
||||||
|
/// The account the line's revenue, expense or asset goes to (a code or
|
||||||
|
/// an id): an invoice line a revenue account or a liability holding no
|
||||||
|
/// role (deferred revenue), a bill line an expense account or an asset
|
||||||
|
/// that is not cash and holds no role. Omitted: the kind's own role
|
||||||
|
/// account (revenue for an invoice, general expenses for a bill). A
|
||||||
|
/// bill's line must name one in books that have declared the journal
|
||||||
|
/// feature documents_v2 (A-10).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub account: Option<String>,
|
||||||
|
/// How many units the line bills, with unit_price, when the line is
|
||||||
|
/// priced rather than summed: quantity × unit_price must equal amount
|
||||||
|
/// exactly. Both or neither.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub quantity: Option<String>,
|
||||||
|
/// The price of one unit, with quantity.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub unit_price: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for `books.create_invoice` / `books.create_bill`.
|
/// A document's party as the page prints it: the customer's or vendor's
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
/// name, address and email at issue (A-13), and its contact block (D-55).
|
||||||
pub struct CreateDocumentParams {
|
/// Every part omitted is the party record's.
|
||||||
/// Customer id (invoice) or vendor id (bill).
|
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||||
pub party_id: u64,
|
pub struct BillToParam {
|
||||||
/// Document date, YYYY-MM-DD.
|
/// Its name at issue.
|
||||||
|
pub name: Option<String>,
|
||||||
|
/// Its address at issue (the general one), one line per line.
|
||||||
|
pub address: Option<String>,
|
||||||
|
/// Its email at issue (the general one).
|
||||||
|
pub email: Option<String>,
|
||||||
|
/// Its telephone number at issue.
|
||||||
|
pub phone: Option<String>,
|
||||||
|
/// The person there to write to at issue.
|
||||||
|
pub contact: Option<String>,
|
||||||
|
/// Where its bills go: the bill-to address when set, else `address`.
|
||||||
|
pub billing_address: Option<String>,
|
||||||
|
/// Where goods go: the page prints a Ship-to line for it.
|
||||||
|
pub shipping_address: Option<String>,
|
||||||
|
/// Where its bills are emailed: the bill-to email when set, else
|
||||||
|
/// `email`.
|
||||||
|
pub billing_email: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parameters for `books.create_invoice`: it previews without
|
||||||
|
/// `confirmed`, and posts with it (A-1).
|
||||||
|
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||||
|
pub struct CreateInvoiceParams {
|
||||||
|
/// The customer: a customer id, its party's id, or its name. A name
|
||||||
|
/// that two customers answer to is refused.
|
||||||
|
pub customer: Option<String>,
|
||||||
|
/// The old spelling of `customer` (accepted until P5): a customer id.
|
||||||
|
#[serde(default)]
|
||||||
|
pub party_id: Option<u64>,
|
||||||
|
/// The invoice date, YYYY-MM-DD.
|
||||||
pub date: String,
|
pub date: String,
|
||||||
/// Due date, YYYY-MM-DD.
|
/// Its payment terms ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on
|
||||||
pub due_date: String,
|
/// receipt", "Due 2026-11-15"). The due date is derived from them.
|
||||||
|
pub terms: Option<String>,
|
||||||
|
/// Its due date, YYYY-MM-DD: used as given, and it overrides `terms`.
|
||||||
|
pub due_date: Option<String>,
|
||||||
/// Line items.
|
/// Line items.
|
||||||
pub lines: Vec<DocumentLineParam>,
|
pub lines: Vec<DocumentLineParam>,
|
||||||
|
/// The customer's own reference (their purchase order).
|
||||||
|
pub customer_ref: Option<String>,
|
||||||
|
/// A memo printed on the invoice.
|
||||||
|
pub memo: Option<String>,
|
||||||
|
/// The customer as the page prints it (omitted: the party's name,
|
||||||
|
/// address and email at issue).
|
||||||
|
pub bill_to: Option<BillToParam>,
|
||||||
|
/// True: post it. Absent or false: preview it and post nothing.
|
||||||
|
pub confirmed: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of `books.create_invoice` / `books.create_bill`.
|
/// Parameters for `books.create_bill`: it previews without `confirmed`,
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
/// and posts with it.
|
||||||
pub struct CreateDocumentOut {
|
#[derive(Debug, Clone, Deserialize, JsonSchema)]
|
||||||
/// The new document's id.
|
pub struct CreateBillParams {
|
||||||
pub id: u64,
|
/// The vendor: a vendor id, its party's id, or its name. A name that
|
||||||
/// Total amount, decimal string.
|
/// two vendors answer to is refused.
|
||||||
pub total: String,
|
pub vendor: Option<String>,
|
||||||
/// The auto-posted journal entry's id.
|
/// The old spelling of `vendor` (accepted until P5): a vendor id.
|
||||||
pub entry_id: u64,
|
#[serde(default)]
|
||||||
/// What to tell the human: a revenue or expense account this created
|
pub party_id: Option<u64>,
|
||||||
/// (Revenue 4000, General Expenses 6999) has no tax category yet.
|
/// The bill's date, YYYY-MM-DD.
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
pub date: String,
|
||||||
pub warnings: Vec<String>,
|
/// The day the bill was received, when it differs from its date.
|
||||||
/// One per finished or abandoned project a line names, as
|
pub received_on: Option<String>,
|
||||||
/// `books.add_line`'s `note`, and on an invoice one per on-spec
|
/// The vendor's own invoice number: required in books that have
|
||||||
/// project a line names (is there now an agreement?): tagged anyway.
|
/// declared documents_v2 (a second bill with the same vendor and
|
||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
/// number is refused unless duplicate_reason says why).
|
||||||
pub notes: Vec<String>,
|
pub vendor_ref: Option<String>,
|
||||||
|
/// Its payment terms; the due date is derived from them.
|
||||||
|
pub terms: Option<String>,
|
||||||
|
/// Its due date, YYYY-MM-DD: used as given, and it overrides `terms`.
|
||||||
|
pub due_date: Option<String>,
|
||||||
|
/// Line items.
|
||||||
|
pub lines: Vec<DocumentLineParam>,
|
||||||
|
/// A memo about the bill.
|
||||||
|
pub memo: Option<String>,
|
||||||
|
/// The vendor's invoice itself: an upload made with books.begin_upload
|
||||||
|
/// and `wanderbooks upload` (D-46). Required in books that have
|
||||||
|
/// declared documents_v2: it is filed on the bill's entry as its
|
||||||
|
/// evidence.
|
||||||
|
pub upload_id: Option<String>,
|
||||||
|
/// The vendor's invoice, stored already (books.attachments): filed
|
||||||
|
/// like an upload.
|
||||||
|
pub existing_attachment_id: Option<u64>,
|
||||||
|
/// Why a vendor invoice number that already names a standing bill is
|
||||||
|
/// entered again anyway.
|
||||||
|
pub duplicate_reason: Option<String>,
|
||||||
|
/// True: post it. Absent or false: preview it and post nothing.
|
||||||
|
pub confirmed: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Parameters for `books.receive_payment` / `books.pay_bill`.
|
/// Parameters for `books.receive_payment` / `books.pay_bill`.
|
||||||
@@ -3945,6 +4024,28 @@ pub struct UpdatePartyParams {
|
|||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
/// Notes; "" clears them.
|
/// Notes; "" clears them.
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
|
/// Email address, where the company writes to them (A-13): copied onto
|
||||||
|
/// an invoice's bill-to or a bill's vendor at issue. "" clears it.
|
||||||
|
pub email: Option<String>,
|
||||||
|
/// The payment terms this party grants the company as a customer
|
||||||
|
/// ("Net 30", "2/10 net 30"): an invoice's due date falls back to them
|
||||||
|
/// when neither it nor the call gives terms. "" clears them.
|
||||||
|
pub default_terms_as_customer: Option<String>,
|
||||||
|
/// The payment terms this party, as a vendor, gives the company: a
|
||||||
|
/// bill's due date falls back to them. "" clears them.
|
||||||
|
pub default_terms_as_vendor: Option<String>,
|
||||||
|
/// Telephone number (D-55). "" clears it.
|
||||||
|
pub phone: Option<String>,
|
||||||
|
/// The named person there to write to (D-55). "" clears it.
|
||||||
|
pub contact: Option<String>,
|
||||||
|
/// Where its bills go (D-55): an invoice's bill-to address when set,
|
||||||
|
/// else `address`. "" clears it.
|
||||||
|
pub billing_address: Option<String>,
|
||||||
|
/// Where goods go (D-55). "" clears it.
|
||||||
|
pub shipping_address: Option<String>,
|
||||||
|
/// Where its bills are emailed (D-55): an invoice's bill-to email when
|
||||||
|
/// set, else `email`. "" clears it.
|
||||||
|
pub billing_email: Option<String>,
|
||||||
/// True: no longer dealt with (its history stays; it no longer
|
/// True: no longer dealt with (its history stays; it no longer
|
||||||
/// resolves by name, and its name is free for another party). False:
|
/// resolves by name, and its name is free for another party). False:
|
||||||
/// dealt with again (refused while another party has its name).
|
/// dealt with again (refused while another party has its name).
|
||||||
@@ -4289,6 +4390,25 @@ pub struct PartyOut {
|
|||||||
pub address: Option<String>,
|
pub address: Option<String>,
|
||||||
/// Notes.
|
/// Notes.
|
||||||
pub notes: Option<String>,
|
pub notes: Option<String>,
|
||||||
|
/// Email address (A-13): the general one, where the company writes to
|
||||||
|
/// it.
|
||||||
|
pub email: Option<String>,
|
||||||
|
/// Telephone number (D-55).
|
||||||
|
pub phone: Option<String>,
|
||||||
|
/// The named person there to write to (D-55).
|
||||||
|
pub contact: Option<String>,
|
||||||
|
/// Where its bills go (D-55): an invoice's bill-to address when set,
|
||||||
|
/// else `address`.
|
||||||
|
pub billing_address: Option<String>,
|
||||||
|
/// Where goods go (D-55).
|
||||||
|
pub shipping_address: Option<String>,
|
||||||
|
/// Where its bills are emailed (D-55): the bill-to email when set,
|
||||||
|
/// else `email`.
|
||||||
|
pub billing_email: Option<String>,
|
||||||
|
/// The terms it grants the company as a customer ("Net 30"), when set.
|
||||||
|
pub default_terms_as_customer: Option<String>,
|
||||||
|
/// The terms it gives the company as a vendor, when set.
|
||||||
|
pub default_terms_as_vendor: Option<String>,
|
||||||
/// No longer dealt with.
|
/// No longer dealt with.
|
||||||
pub archived: bool,
|
pub archived: bool,
|
||||||
/// Its vendor record's id, if it bills the company.
|
/// Its vendor record's id, if it bills the company.
|
||||||
@@ -4758,3 +4878,131 @@ pub struct HandbookOut {
|
|||||||
/// The section asked for (null: none was).
|
/// The section asked for (null: none was).
|
||||||
pub section: Option<HandbookTextOut>,
|
pub section: Option<HandbookTextOut>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// P4b S4.4: invoices and bills preview, then confirm
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// One line of a document's posting, as a preview shows it: the account's
|
||||||
|
/// code and name, its side, the amount, and the project it is tagged with.
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct DocumentPostingOut {
|
||||||
|
/// The account's code ("1200").
|
||||||
|
pub account: String,
|
||||||
|
/// Its name ("Accounts Receivable").
|
||||||
|
pub name: String,
|
||||||
|
/// "debit" or "credit".
|
||||||
|
pub side: String,
|
||||||
|
/// The amount, decimal string.
|
||||||
|
pub amount: String,
|
||||||
|
/// The project the line is tagged with, if any.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub class: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An invoice's PDF: the DRAFT copy a preview hands over, or the issued
|
||||||
|
/// copy a confirm files, named by its attachment (A-7).
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
#[serde(untagged)]
|
||||||
|
pub enum DocumentPdfOut {
|
||||||
|
/// The DRAFT copy, handed over: run its `download` to save it (D-46).
|
||||||
|
Draft(HandedFile),
|
||||||
|
/// The issued copy, filed with the invoice in the call that issued it.
|
||||||
|
Issued(IssuedFileOut),
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A file filed with a document: its attachment and SHA-256.
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct IssuedFileOut {
|
||||||
|
/// The attachment (books.get_receipt).
|
||||||
|
pub attachment_id: u64,
|
||||||
|
/// The file's SHA-256, hex: the same one books.get_receipt reports.
|
||||||
|
pub sha256: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of `books.create_invoice`: a preview (nothing posted) or the
|
||||||
|
/// posted invoice.
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct InvoiceOut {
|
||||||
|
/// True: a preview, and nothing was posted.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub preview: Option<bool>,
|
||||||
|
/// The invoice's id (confirm).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<u64>,
|
||||||
|
/// Its number (confirm: the one issued; preview: the one it would
|
||||||
|
/// take). Absent in books that don't number their invoices yet.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub number: Option<String>,
|
||||||
|
/// The total, decimal string.
|
||||||
|
pub total: String,
|
||||||
|
/// The due date, YYYY-MM-DD.
|
||||||
|
pub due_date: String,
|
||||||
|
/// The terms' text ("Net 30"), when it has them.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub terms: Option<String>,
|
||||||
|
/// The auto-posted entry's id (confirm).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub entry_id: Option<u64>,
|
||||||
|
/// The posting, line by line (preview).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub lines: Vec<DocumentPostingOut>,
|
||||||
|
/// The PDF: DRAFT and handed over on a preview, the issued copy filed
|
||||||
|
/// with the invoice on a confirm.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub pdf: Option<DocumentPdfOut>,
|
||||||
|
/// A `mailto:` link for a human to send it from their own mail
|
||||||
|
/// program (the books never send, A-4). The file is attached by hand.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub mailto: Option<String>,
|
||||||
|
/// What to tell the human: a revenue account this created (4000) has
|
||||||
|
/// no tax category yet.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
/// One per finished or abandoned project a line names, and one per
|
||||||
|
/// on-spec project a line names (is there now an agreement?).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Result of `books.create_bill`: a preview (nothing posted) or the
|
||||||
|
/// posted bill.
|
||||||
|
#[derive(Debug, Clone, Serialize, JsonSchema)]
|
||||||
|
pub struct BillOut {
|
||||||
|
/// True: a preview, and nothing was posted.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub preview: Option<bool>,
|
||||||
|
/// The bill's id (confirm).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub id: Option<u64>,
|
||||||
|
/// The total, decimal string.
|
||||||
|
pub total: String,
|
||||||
|
/// The due date, YYYY-MM-DD.
|
||||||
|
pub due_date: String,
|
||||||
|
/// The terms' text ("2/10 net 30"), when it has them.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub terms: Option<String>,
|
||||||
|
/// The auto-posted entry's id (confirm).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub entry_id: Option<u64>,
|
||||||
|
/// The posting, line by line (preview).
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub lines: Vec<DocumentPostingOut>,
|
||||||
|
/// The last day its early-payment discount is open, when it has one.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub discount_by: Option<String>,
|
||||||
|
/// The largest discount its terms allow, decimal string (the caller
|
||||||
|
/// gives the exact cents when it takes one).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_discount: Option<String>,
|
||||||
|
/// The vendor's invoice, filed with the bill (confirm).
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub file: Option<IssuedFileOut>,
|
||||||
|
/// What to tell the human: an expense account this created (6999) has
|
||||||
|
/// no tax category yet.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub warnings: Vec<String>,
|
||||||
|
/// One per finished or abandoned project a line names.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub notes: Vec<String>,
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@
|
|||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::settings::Company;
|
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};
|
use ledger_import::{InvoiceDoc, InvoiceItem, InvoiceParty, InvoiceStamp};
|
||||||
|
|
||||||
/// A date as an invoice prints it: "Feb 1, 2026".
|
/// A date as an invoice prints it: "Feb 1, 2026".
|
||||||
@@ -155,6 +155,115 @@ pub fn invoice_document(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The copy of an invoice that is being issued (plan P4b S4.4; A-7): the
|
||||||
|
/// page the confirm renders before the invoice exists in the ledger, and
|
||||||
|
/// files with it. No stamp and no note, because this is the copy the
|
||||||
|
/// customer gets. Its number is the one the issue will take (A-2), its
|
||||||
|
/// seller and bill-to the snapshots the issue will store (A-13) — read
|
||||||
|
/// from `ledger` when a document made before snapshots has none — and
|
||||||
|
/// nothing is settled on it yet.
|
||||||
|
pub fn issued_invoice_document(
|
||||||
|
ledger: &Ledger,
|
||||||
|
company: &Company,
|
||||||
|
invoice: &Invoice,
|
||||||
|
) -> Result<InvoiceDoc, String> {
|
||||||
|
unissued_document(ledger, company, invoice, None, None)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The DRAFT copy of an invoice not yet issued (plan P4b S4.4): the PDF a
|
||||||
|
/// preview hands over. The issued copy's page, stamped DRAFT, saying that
|
||||||
|
/// nothing is posted and that it is not the copy sent.
|
||||||
|
pub fn draft_invoice_document(
|
||||||
|
ledger: &Ledger,
|
||||||
|
company: &Company,
|
||||||
|
invoice: &Invoice,
|
||||||
|
) -> Result<InvoiceDoc, String> {
|
||||||
|
unissued_document(
|
||||||
|
ledger,
|
||||||
|
company,
|
||||||
|
invoice,
|
||||||
|
Some(InvoiceStamp::Draft),
|
||||||
|
Some("Draft for review: nothing is posted, and this is not the copy sent.".to_string()),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`issued_invoice_document`]'s and [`draft_invoice_document`]'s page:
|
||||||
|
/// the invoice as the books would hold it, with the stamp and note given.
|
||||||
|
fn unissued_document(
|
||||||
|
ledger: &Ledger,
|
||||||
|
company: &Company,
|
||||||
|
invoice: &Invoice,
|
||||||
|
stamp: Option<InvoiceStamp>,
|
||||||
|
note: Option<String>,
|
||||||
|
) -> Result<InvoiceDoc, String> {
|
||||||
|
let total = invoice.total().map_err(|e| e.to_string())?;
|
||||||
|
// The snapshots at issue are what prints (A-13); the party record and
|
||||||
|
// the company are the fallbacks for a document made before they were
|
||||||
|
// kept.
|
||||||
|
let seller = match &invoice.seller {
|
||||||
|
Some(s) => InvoiceParty {
|
||||||
|
name: s.name.clone(),
|
||||||
|
lines: address_lines(s.address.as_deref()),
|
||||||
|
},
|
||||||
|
None => InvoiceParty {
|
||||||
|
name: company.name.clone(),
|
||||||
|
lines: address_lines(
|
||||||
|
company
|
||||||
|
.profile
|
||||||
|
.iter()
|
||||||
|
.find(|(k, _)| k == "address")
|
||||||
|
.map(|(_, v)| v.as_str()),
|
||||||
|
),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let (name, address) = match &invoice.bill_to {
|
||||||
|
Some(b) => (Some(b.name.clone()), b.address.clone()),
|
||||||
|
None => {
|
||||||
|
let party = ledger
|
||||||
|
.party_of_customer(invoice.customer_id)
|
||||||
|
.and_then(|p| ledger.get_party(p));
|
||||||
|
let name = party
|
||||||
|
.and_then(|p| p.display_name.clone())
|
||||||
|
.or_else(|| {
|
||||||
|
ledger
|
||||||
|
.get_customer(invoice.customer_id)
|
||||||
|
.map(|c| c.name.clone())
|
||||||
|
})
|
||||||
|
.unwrap_or_default();
|
||||||
|
(Some(name), party.and_then(|p| p.address.clone()))
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let bill_to = InvoiceParty {
|
||||||
|
name: name.unwrap_or_default(),
|
||||||
|
lines: address_lines(address.as_deref()),
|
||||||
|
};
|
||||||
|
Ok(InvoiceDoc {
|
||||||
|
number: invoice
|
||||||
|
.number
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| format!("Invoice {}", invoice.id)),
|
||||||
|
issue_date: printed(invoice.date),
|
||||||
|
due_date: printed(invoice.due_date),
|
||||||
|
terms: invoice.terms.as_ref().map(ledger_core::Terms::describe),
|
||||||
|
seller,
|
||||||
|
bill_to,
|
||||||
|
items: invoice
|
||||||
|
.lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| InvoiceItem {
|
||||||
|
description: l.description.clone(),
|
||||||
|
amount: l.amount,
|
||||||
|
})
|
||||||
|
.collect(),
|
||||||
|
total,
|
||||||
|
settled: Vec::new(),
|
||||||
|
balance: total,
|
||||||
|
stamp,
|
||||||
|
stamp_line: None,
|
||||||
|
note,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ pub use attention::{
|
|||||||
};
|
};
|
||||||
pub use books::PackageBooks;
|
pub use books::PackageBooks;
|
||||||
pub use iif::export_iif;
|
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 k1_tax::{open_text, tax_basis_csv};
|
||||||
pub use package::{Package, PackageRequest, build_package, package_file_name};
|
pub use package::{Package, PackageRequest, build_package, package_file_name};
|
||||||
pub use reports::{
|
pub use reports::{
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
|||||||
pub use snapshot::{DiskCheck, Snapshot};
|
pub use snapshot::{DiskCheck, Snapshot};
|
||||||
pub use store::{
|
pub use store::{
|
||||||
Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview,
|
Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview,
|
||||||
JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation,
|
JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, PreviewPosting,
|
||||||
Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for,
|
Reconciliation, Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for,
|
||||||
is_database_file,
|
is_database_file,
|
||||||
};
|
};
|
||||||
@@ -322,6 +322,24 @@ pub struct Attachment {
|
|||||||
/// write-path signature reads as input rather than stored state.
|
/// write-path signature reads as input rather than stored state.
|
||||||
pub type PayrollLineInput = PayrollLine;
|
pub type PayrollLineInput = PayrollLine;
|
||||||
|
|
||||||
|
/// One line of a previewed posting, the way the page shows it: the
|
||||||
|
/// account's code and name (resolved in the probe, so an account the
|
||||||
|
/// preview would create is named even though the live books don't have it
|
||||||
|
/// yet), its side, amount and project (plan P4b S4.4).
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||||
|
pub struct PreviewPosting {
|
||||||
|
/// The account's code ("1200").
|
||||||
|
pub account: String,
|
||||||
|
/// Its name ("Accounts Receivable").
|
||||||
|
pub name: String,
|
||||||
|
/// Debit or credit.
|
||||||
|
pub side: Side,
|
||||||
|
/// The amount.
|
||||||
|
pub amount: Decimal,
|
||||||
|
/// The project it is tagged with, if any.
|
||||||
|
pub class_id: Option<ClassId>,
|
||||||
|
}
|
||||||
|
|
||||||
/// What issuing an invoice would post and take, journaling nothing
|
/// What issuing an invoice would post and take, journaling nothing
|
||||||
/// ([`Store::preview_invoice`]; plan P4b S4.3 item 12). The posting is the
|
/// ([`Store::preview_invoice`]; plan P4b S4.3 item 12). The posting is the
|
||||||
/// entry's lines in order: account, side, amount, project.
|
/// entry's lines in order: account, side, amount, project.
|
||||||
@@ -329,6 +347,10 @@ pub type PayrollLineInput = PayrollLine;
|
|||||||
pub struct InvoicePreview {
|
pub struct InvoicePreview {
|
||||||
/// The entry it would post, line by line.
|
/// The entry it would post, line by line.
|
||||||
pub posting: Vec<(AccountId, Side, Decimal, Option<ClassId>)>,
|
pub posting: Vec<(AccountId, Side, Decimal, Option<ClassId>)>,
|
||||||
|
/// The same lines with their accounts' codes and names (plan P4b
|
||||||
|
/// S4.4: the preview page shows them, and an account the issue would
|
||||||
|
/// create has no id the live books know yet).
|
||||||
|
pub lines: Vec<PreviewPosting>,
|
||||||
/// The document's total.
|
/// The document's total.
|
||||||
pub total: Decimal,
|
pub total: Decimal,
|
||||||
/// The number it would take (A-2), when the books number their
|
/// The number it would take (A-2), when the books number their
|
||||||
@@ -336,6 +358,10 @@ pub struct InvoicePreview {
|
|||||||
pub number: Option<String>,
|
pub number: Option<String>,
|
||||||
/// Its due date.
|
/// Its due date.
|
||||||
pub due_date: NaiveDate,
|
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
|
/// What recording a bill would post, journaling nothing
|
||||||
@@ -344,6 +370,9 @@ pub struct InvoicePreview {
|
|||||||
pub struct BillPreview {
|
pub struct BillPreview {
|
||||||
/// The entry it would post, line by line.
|
/// The entry it would post, line by line.
|
||||||
pub posting: Vec<(AccountId, Side, Decimal, Option<ClassId>)>,
|
pub posting: Vec<(AccountId, Side, Decimal, Option<ClassId>)>,
|
||||||
|
/// The same lines with their accounts' codes and names
|
||||||
|
/// ([`InvoicePreview::lines`]).
|
||||||
|
pub lines: Vec<PreviewPosting>,
|
||||||
/// The document's total.
|
/// The document's total.
|
||||||
pub total: Decimal,
|
pub total: Decimal,
|
||||||
/// Its due date.
|
/// Its due date.
|
||||||
@@ -354,6 +383,32 @@ pub struct BillPreview {
|
|||||||
/// refused, so a far-off date can't make one request write without end.
|
/// refused, so a far-off date can't make one request write without end.
|
||||||
pub const MAX_RECURRING_RUN: usize = 1000;
|
pub const MAX_RECURRING_RUN: usize = 1000;
|
||||||
|
|
||||||
|
/// A probe entry's lines as a preview shows them: each account's code and
|
||||||
|
/// name, read from the probe (plan P4b S4.4). The probe always holds the
|
||||||
|
/// accounts its posting names, so a missing one is a bug, reported as an
|
||||||
|
/// error rather than a panic.
|
||||||
|
fn preview_lines(
|
||||||
|
probe: &Ledger,
|
||||||
|
lines: &[ledger_core::journal::JournalLine],
|
||||||
|
) -> Result<Vec<PreviewPosting>, StoreError> {
|
||||||
|
lines
|
||||||
|
.iter()
|
||||||
|
.map(|l| {
|
||||||
|
let account = probe
|
||||||
|
.chart()
|
||||||
|
.get(l.account)
|
||||||
|
.ok_or(LedgerError::UnknownAccount(l.account))?;
|
||||||
|
Ok(PreviewPosting {
|
||||||
|
account: account.code.clone(),
|
||||||
|
name: account.name.clone(),
|
||||||
|
side: l.side,
|
||||||
|
amount: l.amount.amount,
|
||||||
|
class_id: l.class_id,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// The store: one writer, one data directory.
|
/// The store: one writer, one data directory.
|
||||||
///
|
///
|
||||||
/// `Store` is `Send` but not `Sync` (it owns a `rusqlite::Connection`).
|
/// `Store` is `Send` but not `Sync` (it owns a `rusqlite::Connection`).
|
||||||
@@ -3317,9 +3372,11 @@ impl Store {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|l| (l.account, l.side, l.amount.amount, l.class_id))
|
.map(|l| (l.account, l.side, l.amount.amount, l.class_id))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
lines: preview_lines(&probe, entry.lines())?,
|
||||||
total: inv.total()?,
|
total: inv.total()?,
|
||||||
number: fields.number,
|
number: fields.number,
|
||||||
due_date: inv.due_date,
|
due_date: inv.due_date,
|
||||||
|
invoice: inv.clone(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3493,6 +3550,7 @@ impl Store {
|
|||||||
.iter()
|
.iter()
|
||||||
.map(|l| (l.account, l.side, l.amount.amount, l.class_id))
|
.map(|l| (l.account, l.side, l.amount.amount, l.class_id))
|
||||||
.collect(),
|
.collect(),
|
||||||
|
lines: preview_lines(&probe, entry.lines())?,
|
||||||
total: bill.total()?,
|
total: bill.total()?,
|
||||||
due_date: bill.due_date,
|
due_date: bill.due_date,
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1062,3 +1062,216 @@ mod shares {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Oracle: D-55 (the owner's full contact block): a party's phone, contact
|
||||||
|
/// person, billing and shipping addresses and billing email are
|
||||||
|
/// `party.updated` facts under `documents_v2`; they journal one event
|
||||||
|
/// apiece, replay gives them back, and the live ledger and a fresh replay
|
||||||
|
/// agree.
|
||||||
|
#[test]
|
||||||
|
fn the_contact_block_round_trips_and_replays() {
|
||||||
|
let dir = test_dir("contact-block");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let id = s.add_party("Harbor Coffee", &[]).unwrap();
|
||||||
|
let fields = [
|
||||||
|
(
|
||||||
|
PartyField::Phone(Some("555-0142".into())),
|
||||||
|
json!({"field": "phone", "value": "555-0142"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
PartyField::Contact(Some("Dana Reed".into())),
|
||||||
|
json!({"field": "contact", "value": "Dana Reed"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
PartyField::BillingAddress(Some("12 Billing Way\nPortland, OR 97209".into())),
|
||||||
|
json!({"field": "billing_address",
|
||||||
|
"value": "12 Billing Way\nPortland, OR 97209"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
PartyField::ShippingAddress(Some("Dock 4, 9 Wharf Street".into())),
|
||||||
|
json!({"field": "shipping_address", "value": "Dock 4, 9 Wharf Street"}),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
PartyField::BillingEmail(Some("ap@harbor.example".into())),
|
||||||
|
json!({"field": "billing_email", "value": "ap@harbor.example"}),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
for (field, want) in &fields {
|
||||||
|
let ((), p) = one_event(&mut s, &dir, "party.updated", |s| {
|
||||||
|
s.update_party(id, field.clone()).unwrap()
|
||||||
|
});
|
||||||
|
assert_eq!(p["change"], *want, "{field:?}");
|
||||||
|
}
|
||||||
|
let got = s.ledger().get_party(id).unwrap();
|
||||||
|
assert_eq!(got.phone.as_deref(), Some("555-0142"));
|
||||||
|
assert_eq!(got.contact.as_deref(), Some("Dana Reed"));
|
||||||
|
assert_eq!(
|
||||||
|
got.billing_address.as_deref(),
|
||||||
|
Some("12 Billing Way\nPortland, OR 97209")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
got.shipping_address.as_deref(),
|
||||||
|
Some("Dock 4, 9 Wharf Street")
|
||||||
|
);
|
||||||
|
assert_eq!(got.billing_email.as_deref(), Some("ap@harbor.example"));
|
||||||
|
// The general email is its own fact, and neither replaces the other.
|
||||||
|
s.update_party(id, PartyField::Email(Some("hello@harbor.example".into())))
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// A full TIN in the block, or an address that isn't one, is refused.
|
||||||
|
for field in [
|
||||||
|
PartyField::Phone(Some("123-45-6789".into())),
|
||||||
|
PartyField::BillingEmail(Some("not an address".into())),
|
||||||
|
] {
|
||||||
|
refused_nothing_journaled(&mut s, &dir, &format!("{field:?}"), |s| {
|
||||||
|
s.update_party(id, field.clone())
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
s.verify().unwrap();
|
||||||
|
let live = s.ledger().clone();
|
||||||
|
drop(s);
|
||||||
|
for without_db in [false, true] {
|
||||||
|
if without_db {
|
||||||
|
delete_db(&dir);
|
||||||
|
}
|
||||||
|
let s = Store::open(&dir).unwrap();
|
||||||
|
assert!(
|
||||||
|
*s.ledger() == live,
|
||||||
|
"replay (db {}): {:?}",
|
||||||
|
!without_db,
|
||||||
|
s.ledger().differs_from(&live)
|
||||||
|
);
|
||||||
|
let got = s.ledger().get_party(id).unwrap();
|
||||||
|
assert_eq!(got.contact.as_deref(), Some("Dana Reed"));
|
||||||
|
assert_eq!(got.billing_email.as_deref(), Some("ap@harbor.example"));
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
|
// `None` clears each, and the journal keeps what they were.
|
||||||
|
let mut s = Store::open(&dir).unwrap();
|
||||||
|
for field in [
|
||||||
|
PartyField::Phone(None),
|
||||||
|
PartyField::Contact(None),
|
||||||
|
PartyField::BillingAddress(None),
|
||||||
|
PartyField::ShippingAddress(None),
|
||||||
|
PartyField::BillingEmail(None),
|
||||||
|
] {
|
||||||
|
s.update_party(id, field).unwrap();
|
||||||
|
}
|
||||||
|
let got = s.ledger().get_party(id).unwrap();
|
||||||
|
assert!(got.phone.is_none() && got.contact.is_none());
|
||||||
|
assert!(got.billing_address.is_none() && got.shipping_address.is_none());
|
||||||
|
assert!(got.billing_email.is_none());
|
||||||
|
s.verify().unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Oracle: D-55 and A-13: an invoice's bill-to snapshot is the customer's
|
||||||
|
/// contact block at issue, with the billing address and billing email in
|
||||||
|
/// place of the general ones when it has them
|
||||||
|
/// (`PartySnapshot::from_party`, the one place that rule lives); a later
|
||||||
|
/// party edit does not change the issued snapshot.
|
||||||
|
#[test]
|
||||||
|
fn an_invoice_keeps_the_billing_details_it_was_issued_with() {
|
||||||
|
use ledger_core::arap::PartySnapshot;
|
||||||
|
let dir = test_dir("contact-snapshot");
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let customer = s.add_customer("Harbor Coffee").unwrap();
|
||||||
|
let party = s.ledger().party_of_customer(customer).unwrap();
|
||||||
|
for field in [
|
||||||
|
PartyField::Address(Some("PO Box 9, Portland, OR 97209".into())),
|
||||||
|
PartyField::Email(Some("hello@harbor.example".into())),
|
||||||
|
PartyField::Phone(Some("555-0142".into())),
|
||||||
|
PartyField::Contact(Some("Dana Reed".into())),
|
||||||
|
PartyField::BillingAddress(Some("12 Billing Way, Portland, OR 97209".into())),
|
||||||
|
PartyField::BillingEmail(Some("ap@harbor.example".into())),
|
||||||
|
PartyField::ShippingAddress(Some("Dock 4, 9 Wharf Street".into())),
|
||||||
|
] {
|
||||||
|
s.update_party(party, field).unwrap();
|
||||||
|
}
|
||||||
|
let snapshot = PartySnapshot::from_party(s.ledger().get_party(party).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.address.as_deref(),
|
||||||
|
Some("12 Billing Way, Portland, OR 97209"),
|
||||||
|
"the billing address, not the general one"
|
||||||
|
);
|
||||||
|
assert_eq!(snapshot.email.as_deref(), Some("ap@harbor.example"));
|
||||||
|
assert_eq!(snapshot.contact.as_deref(), Some("Dana Reed"));
|
||||||
|
assert_eq!(snapshot.phone.as_deref(), Some("555-0142"));
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.shipping_address.as_deref(),
|
||||||
|
Some("Dock 4, 9 Wharf Street")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
snapshot.billing_address.as_deref(),
|
||||||
|
Some("12 Billing Way, Portland, OR 97209")
|
||||||
|
);
|
||||||
|
|
||||||
|
let line = ledger_core::DocumentLine {
|
||||||
|
description: "Coffee".into(),
|
||||||
|
amount: dec("100.00"),
|
||||||
|
class_id: None,
|
||||||
|
account: None,
|
||||||
|
quantity: None,
|
||||||
|
unit_price: None,
|
||||||
|
};
|
||||||
|
let id = s
|
||||||
|
.create_invoice_with(
|
||||||
|
customer,
|
||||||
|
date(2026, 2, 1),
|
||||||
|
date(2026, 3, 3),
|
||||||
|
vec![line],
|
||||||
|
ledger_core::arap::InvoiceFields {
|
||||||
|
bill_to: Some(snapshot.clone()),
|
||||||
|
..ledger_core::arap::InvoiceFields::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
// The event carries it, so replay can never lose it.
|
||||||
|
let event = journal(&dir)
|
||||||
|
.into_iter()
|
||||||
|
.rev()
|
||||||
|
.find(|(t, _)| t == "invoice.created")
|
||||||
|
.map(|(_, p)| p)
|
||||||
|
.expect("invoice.created");
|
||||||
|
assert_eq!(
|
||||||
|
event["bill_to"],
|
||||||
|
json!({
|
||||||
|
"name": "Harbor Coffee",
|
||||||
|
"address": "12 Billing Way, Portland, OR 97209",
|
||||||
|
"email": "ap@harbor.example",
|
||||||
|
"phone": "555-0142",
|
||||||
|
"contact": "Dana Reed",
|
||||||
|
"billing_address": "12 Billing Way, Portland, OR 97209",
|
||||||
|
"shipping_address": "Dock 4, 9 Wharf Street",
|
||||||
|
"billing_email": "ap@harbor.example",
|
||||||
|
}),
|
||||||
|
"{event}"
|
||||||
|
);
|
||||||
|
|
||||||
|
// A later edit changes the record, never the issued copy.
|
||||||
|
s.update_party(party, PartyField::BillingAddress(Some("99 New Way".into())))
|
||||||
|
.unwrap();
|
||||||
|
s.update_party(party, PartyField::Phone(Some("555-9999".into())))
|
||||||
|
.unwrap();
|
||||||
|
let kept = s.ledger().get_invoice(id).unwrap().bill_to.clone().unwrap();
|
||||||
|
assert_eq!(kept, snapshot, "an issued snapshot never changes");
|
||||||
|
// And the books come back to it exactly.
|
||||||
|
s.verify().unwrap();
|
||||||
|
let live = s.ledger().clone();
|
||||||
|
drop(s);
|
||||||
|
let s = Store::open(&dir).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
s.ledger().get_invoice(id).unwrap().bill_to.clone().unwrap(),
|
||||||
|
snapshot
|
||||||
|
);
|
||||||
|
assert!(*s.ledger() == live);
|
||||||
|
// The party's own record took the edit.
|
||||||
|
assert_eq!(
|
||||||
|
s.ledger()
|
||||||
|
.get_party(party)
|
||||||
|
.unwrap()
|
||||||
|
.billing_address
|
||||||
|
.as_deref(),
|
||||||
|
Some("99 New Way")
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1849,6 +1849,17 @@ fn books_written_under_older_rules_still_open() {
|
|||||||
"value": {"kind": {"net": 30}, "text": "Net 30"}}}),
|
"value": {"kind": {"net": 30}, "text": "Net 30"}}}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.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(
|
w.append(
|
||||||
"settings.set",
|
"settings.set",
|
||||||
json!({"changes": [{"key": "invoicing.default_terms", "value": "2/10 net 30"}],
|
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.
|
// The new shapes replay as written: the party's facts and the setting.
|
||||||
let acme = store.ledger().get_party(1).expect("the party");
|
let acme = store.ledger().get_party(1).expect("the party");
|
||||||
assert_eq!(acme.email.as_deref(), Some("billing@acme.example"));
|
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!(
|
assert_eq!(
|
||||||
acme.default_terms_as_customer
|
acme.default_terms_as_customer
|
||||||
.as_ref()
|
.as_ref()
|
||||||
|
|||||||
+122
-48
@@ -1190,15 +1190,29 @@ texts are at most 500 characters, no control characters.
|
|||||||
- `books.add_party { name, roles? }` → the party (below). Journaled as
|
- `books.add_party { name, roles? }` → the party (below). Journaled as
|
||||||
`party.added`.
|
`party.added`.
|
||||||
- `books.update_party { party, name?, display_name?, website?, address?,
|
- `books.update_party { party, name?, display_name?, website?, address?,
|
||||||
notes?, archived?, creditor_of? }` → the party. Give at least one field;
|
notes?, email?, default_terms_as_customer?, default_terms_as_vendor?,
|
||||||
`""` clears `display_name`, `website`, `address`, or `notes`;
|
phone?, contact?, billing_address?, shipping_address?, billing_email?,
|
||||||
`creditor_of` (account codes or ids) replaces the party's list of the
|
archived?, creditor_of? }` → the party. Give at least one field; `""`
|
||||||
liabilities the company owes it itself (below). Every field is checked
|
clears `display_name`, `website`, `address`, `notes`, `email`, the terms,
|
||||||
(in order, on a rehearsal) before any is written; each is journaled as
|
`phone`, `contact`, `billing_address`, `shipping_address` or
|
||||||
its own `party.updated`, in the order name, display_name, website,
|
`billing_email`; `creditor_of` (account codes or ids) replaces the
|
||||||
address, notes, archived, then one `party.creditor_set` per account
|
party's list of the liabilities the company owes it itself (below).
|
||||||
added or dropped. Un-archiving is refused while another party has the
|
`email` is its general address; `billing_email` is where its bills go
|
||||||
name.
|
(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
|
- `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
|
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
|
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.
|
violation, and the append-only audit log would show it anyway.
|
||||||
(`asset_id` instead of `entry_id` for a disposal; `split_group: [ids]`
|
(`asset_id` instead of `entry_id` for a disposal; `split_group: [ids]`
|
||||||
when the split check triggered.)
|
when the split check triggered.)
|
||||||
- **Not checked yet:** `books.create_invoice`, `books.create_bill`,
|
- **Not checked yet:** `books.receive_payment`, `books.pay_bill`,
|
||||||
`books.receive_payment`, `books.pay_bill`, `books.run_payroll` and
|
`books.run_payroll` and `books.remit_payroll_taxes` post at once with no
|
||||||
`books.remit_payroll_taxes` post at once with no preview and no receipt
|
receipt check: attach their evidence right after, to the entry they
|
||||||
check: attach their evidence right after, to the entry they return.
|
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
|
- **Anti-splitting.** Posting an entry triggers a split check: posted
|
||||||
entries on the same date with the same normalized description and debit
|
entries on the same date with the same normalized description and debit
|
||||||
accounts are grouped, and if the group total exceeds the threshold while
|
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
|
## 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_customer { name }` → `{ id, party_id }`.
|
||||||
- `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to
|
- `books.add_vendor { name }` → `{ id, party_id }`. Both link the record to
|
||||||
a party ("Tools — parties").
|
a party ("Tools — parties").
|
||||||
- `books.create_invoice { party_id, date, due_date, lines: [{ description,
|
- `books.create_invoice { customer | party_id, date, terms? | due_date?,
|
||||||
amount, class? }] }` → `{ id, total, entry_id, warnings?, notes? }`.
|
lines: [{ description, amount, quantity?, unit_price?, account?,
|
||||||
Auto-posts Dr `1200` Accounts Receivable / Cr `4000` Revenue. `warnings`
|
class? }], customer_ref?, memo?, bill_to?: { name?, address?, email?,
|
||||||
names a revenue account it created with no tax category (rules.md
|
phone?, contact?, billing_address?, shipping_address?, billing_email? },
|
||||||
B-32). `party_id` is the customer's `id` from `books.add_customer`, not
|
confirmed? }` → without `confirmed`: `{ preview: true, number?, total,
|
||||||
its `party_id`. A line's `class` (a project: name or id, resolved as
|
due_date, terms?, lines: [{ account, name, side, amount, class? }],
|
||||||
`books.add_line`'s) puts its revenue on that project (rules.md B-37 item
|
pdf }`, and nothing is written; with `confirmed: true`: `{ id, number?,
|
||||||
8, the owner's decision D-43): one project or none per line, several
|
total, due_date, terms?, entry_id, pdf: { attachment_id, sha256 },
|
||||||
projects as several lines each with its own amount, never a percentage
|
mailto?, warnings?, notes? }`.
|
||||||
(a cost truly shared names none: it reaches the projects only as
|
Confirmed it posts Dr `1200` Accounts Receivable / Cr each line's
|
||||||
entries of their own under the company's monthly allocation convention,
|
account (the revenue role, `4000`, when a line names none), the
|
||||||
when `books.conventions` has one, and otherwise stays untagged while the
|
receivable line tagged with the invoice and the receivable side one line
|
||||||
agent asks the human). The entry's revenue side is one line per project named, in the
|
per (account, project) part.
|
||||||
order each is first named, the untagged lines' as one (a document
|
- `customer` is the customer's id, its party's id or its name (a name
|
||||||
naming none posts the one revenue line it always did); the receivable
|
two customers answer to is refused); `party_id` is the old spelling of
|
||||||
line is never tagged. Every project is resolved before anything is
|
the same thing (a customer id), kept until P5.
|
||||||
written (an unknown one is refused, the document whole). `notes`: one
|
- The due date: `due_date` as given (and it overrides `terms`); else
|
||||||
per finished or abandoned project named, as `books.add_line`'s `note`,
|
`terms` ("Net 30", "Net 15 EOM", "2/10 net 30", "Due on receipt",
|
||||||
and one per on-spec project named, asking whether there is now an
|
"Due 2026-11-15", rules.md B-39); else the customer's
|
||||||
agreement (on spec, a hoped-for payment is no receivable until
|
`default_terms_as_customer`; else the books' `invoicing.default_terms`
|
||||||
received: B-37 item 4) (tagged anyway). Books that haven't declared the journal feature
|
("Net 30" unset); else refused, naming those ways. Terms are kept on
|
||||||
`document_line_projects` (B-27; the live books until their owner's next
|
the invoice only when given or taken from a default: with only a due
|
||||||
upgrade, D-39) refuse a line naming a project with
|
date the invoice prints none.
|
||||||
`format_upgrade_pending`, journaling nothing; a document naming none is
|
- Its number (`INV-0001`) is `invoicing.number_prefix` and
|
||||||
taken as before.
|
`invoicing.first_number` plus the count of numbered invoices before it
|
||||||
- `books.create_bill { party_id, date, due_date, lines }` → `{ id, total,
|
(A-2, B-40); it is stored at issue and never recomputed, and a preview
|
||||||
entry_id, warnings?, notes? }`. Auto-posts Dr `6999` General Expenses /
|
shows the number the confirm will take (an abandoned preview uses up
|
||||||
Cr `2000` Accounts Payable; `warnings` as for invoices. `party_id` is
|
none). Books that haven't declared `documents_v2` number nothing and
|
||||||
the vendor's `id` from `books.add_vendor`, not its `party_id`. A line's
|
show a document as "Invoice {N}".
|
||||||
`class` puts its cost on that project, as an invoice's its revenue: one
|
- The bill-to snapshot is the customer's name, address and email at
|
||||||
expense line per project, the untagged lines' as one, the payable line
|
issue, with its `billing_address` and `billing_email` in their place
|
||||||
never tagged; `notes` as for invoices, but none for an on-spec project
|
when it has them, and its `contact` person, `phone` and
|
||||||
(a cost of on-spec work is an expense like any other); the feature as
|
`shipping_address` (D-55); a `bill_to` object overrides any part for
|
||||||
for invoices.
|
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,
|
- `books.receive_payment { document_id, date, amount, cash_account,
|
||||||
instrument?, reference? }` → `{ entry_id, remaining, status }`. Posts
|
instrument?, reference? }` → `{ entry_id, remaining, status }`. Posts
|
||||||
Dr Cash / Cr A/R, the receivable line tagged with the invoice (B-38),
|
Dr Cash / Cr A/R, the receivable line tagged with the invoice (B-38),
|
||||||
|
|||||||
+18
-2
@@ -34,8 +34,8 @@ No other statuses.
|
|||||||
| Bank reconciliation (cleared flags + report) | Reconcile | done | — |
|
| Bank reconciliation (cleared flags + report) | Reconcile | done | — |
|
||||||
| QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 |
|
| QuickBooks CSV import (bank txns, chart of accounts) | Banking → Upload | done | R-FUTURE-4 |
|
||||||
| QuickBooks IIF + XLSX export | Export / reports | 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/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`) |
|
| 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 | — |
|
| 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) |
|
| 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 | — |
|
| 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
|
existing bookspec example still runs on every `cargo test`, through both
|
||||||
the loader's own checks and the `Engine` trait.
|
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 self-host deployment (2026-10-26)
|
||||||
|
|
||||||
P9a is a deployment, not a semantic feature: the same engine, store, MCP
|
P9a is a deployment, not a semantic feature: the same engine, store, MCP
|
||||||
|
|||||||
+4
-2
@@ -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
|
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
|
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,
|
the `invoicing.*`, `bills.*`, `company.email` or `company.website` keys,
|
||||||
which an earlier version refuses (S4.2); a `party.updated` with an email
|
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`
|
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
|
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
|
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
|
reason, or a line naming its account, quantity or unit price (S4.3); and
|
||||||
|
|||||||
@@ -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.
|
- 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.
|
- `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).
|
- `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`)
|
#### P4b S5: sent tracking (`invoice.sent`)
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user