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

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

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

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

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

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

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

No files matched your search

+711
View File
@@ -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}");
}
+70 -6
View File
@@ -611,6 +611,63 @@ fn format_upgrade_waits_for_consent() {
assert_eq!(format_lines(&dir), 1);
}
/// plan P4b S4.4 (R-A step 4, 8.0.6 item 3): on books that haven't
/// declared `documents_v2`, an invoice carrying a new field (terms) is
/// refused with the upgrade remedy and journals nothing, while one
/// carrying none posts as before; after the owner's go-ahead the same call
/// is made, and takes the first number.
#[test]
fn documents_v2_refused_until_consent() {
let home = Home::new("documents-v2");
let dir = old_books(&home, "acme");
let _held = serve(&home);
let out = home
.cmd("token")
.args(["create", "--book", "acme"])
.stdin(Stdio::null())
.output()
.unwrap();
assert!(out.status.success(), "{out:?}");
let mut a = Agent::start(&home, "acme");
let customer = a
.call("books.add_customer", json!({"name": "Harbor Coffee"}))
.unwrap()["id"]
.clone();
let line = json!([{"description": "Design", "amount": "500.00"}]);
let with_terms = json!({"customer": customer.to_string(), "date": "2026-02-10",
"terms": "Net 30", "lines": line, "confirmed": true});
let before = journal(&dir);
let refused = a
.call("books.create_invoice", with_terms.clone())
.unwrap_err();
assert!(refused.starts_with("format_upgrade_pending"), "{refused}");
assert!(refused.contains("documents_v2"), "{refused}");
assert_eq!(journal(&dir), before, "a refusal journals nothing");
// A document carrying none of the new fields posts as before, and
// files its copy; it takes no number (an older version reads it).
let plain = a
.call(
"books.create_invoice",
json!({"customer": customer.to_string(), "date": "2026-02-10", "due_date": "2026-03-12",
"lines": line, "confirmed": true}),
)
.unwrap();
assert!(plain.get("number").is_none(), "{plain}");
assert_eq!(plain["total"], "500.00");
// The go-ahead.
let mut words = home.words("books");
words.extend(["upgrade".into(), "acme".into()]);
let (code, shown) = home.at_terminal(&words, &[PASS]);
assert_eq!(code, 0, "{shown}");
assert_eq!(format_lines(&dir), 1, "the format moved once");
let made = a.call("books.create_invoice", with_terms).unwrap();
assert_eq!(made["number"], "INV-0001", "{made}");
assert_eq!(made["terms"], "Net 30", "{made}");
assert_eq!(made["due_date"], "2026-03-12", "{made}");
a.call("books.verify", json!({})).unwrap();
}
/// Copy the tree `from` into `to`, private as the server keeps a book
/// (folders 0700, files 0600).
fn copy_private(from: &Path, to: &Path) {
@@ -705,17 +762,24 @@ fn books_p2_served_cut_over_whole() {
.unwrap();
let waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![
// An invoice's or bill's line naming its project
// (document_line_projects, rules.md B-37 item 8).
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and
// its line naming its project (document_line_projects, rules.md
// B-37 item 8).
(
"books.create_invoice",
json!({"party_id": 1, "date": "2026-02-10", "due_date": "2026-03-12",
"lines": [{"description": "Design", "amount": "500.00", "class": "Lighthouse"}]}),
json!({"customer": "1", "date": "2026-02-10", "terms": "Net 30",
"lines": [{"description": "Design", "amount": "500.00", "class": "Lighthouse"}],
"confirmed": true}),
),
// A bill carrying documents_v2's fields (the vendor's number, the
// line's account, its invoice: A-10, A-13) and its line's project.
(
"books.create_bill",
json!({"party_id": 1, "date": "2026-02-11", "due_date": "2026-03-13",
"lines": [{"description": "Drafting", "amount": "120.00", "class": "Lighthouse"}]}),
json!({"vendor": "1", "date": "2026-02-11", "due_date": "2026-03-13",
"vendor_ref": "L-77",
"lines": [{"description": "Drafting", "amount": "120.00",
"account": "6600", "class": "Lighthouse"}],
"existing_attachment_id": 1, "confirmed": true}),
),
(
"books.set_setting",
+6 -4
View File
@@ -1019,18 +1019,20 @@ fn inputs_that_once_panicked_are_refused_or_answered() {
"books.create_invoice",
json!({"party_id": client, "date": "2026-05-01", "due_date": "2026-06-01",
"lines": [{"description": "a", "amount": MAX},
{"description": "b", "amount": "1"}]}),
{"description": "b", "amount": "1"}], "confirmed": true}),
),
(
"books.create_bill",
json!({"party_id": acme, "date": "2026-05-01", "due_date": "2026-06-01",
"lines": [{"description": "a", "amount": MAX},
{"description": "b", "amount": "1"}]}),
"vendor_ref": "H-1",
"lines": [{"description": "a", "amount": MAX, "account": "6000"},
{"description": "b", "amount": "1", "account": "6000"}],
"existing_attachment_id": 1, "confirmed": true}),
),
(
"books.create_invoice",
json!({"party_id": client, "date": "2026-05-01", "due_date": "+262142-12-31",
"lines": [{"description": "a", "amount": "1"}]}),
"lines": [{"description": "a", "amount": "1"}], "confirmed": true}),
),
(
"books.record_payout",
+4 -1
View File
@@ -1178,7 +1178,10 @@ async fn a_token_does_only_what_its_scopes_allow() {
tools.contains(&"books.post_entry".to_string()),
"it may preview"
);
assert!(!tools.contains(&"books.create_invoice".to_string()));
assert!(
!tools.contains(&"books.receive_payment".to_string()),
"it may preview an invoice, but not post a payment"
);
let preview = call(&writer, "books.post_entry", json!({"entry_id": entry})).await;
if let Err(e) = &preview {
assert!(
+22 -12
View File
@@ -230,7 +230,14 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
server.tool("books.add_project", json!({"name": "Heron"}));
let cedar = server.tool("books.add_customer", json!({"name": "Cedar Client"}))["id"].clone();
let dana = server.tool("books.add_vendor", json!({"name": "Dana Illustration"}))["id"].clone();
let doc = |party: &Value, date: &str, lines: Value| json!({"party_id": party, "date": date, "due_date": date, "lines": lines});
let doc = |party: &Value, date: &str, lines: Value| {
json!({"party_id": party, "date": date, "due_date": date, "lines": lines,
"confirmed": true})
};
server.tool(
"books.add_account",
json!({"code": "6500", "name": "Supplies", "account_type": "expense"}),
);
let inv = server.tool(
"books.create_invoice",
doc(
@@ -251,17 +258,20 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
json!([{"description": "Build", "amount": "4321.09", "class": "kestrel"}]),
),
);
let bill = server.tool(
"books.create_bill",
doc(
&dana,
"2026-03-09",
json!([
{"description": "Illustration", "amount": "987.65", "class": "Kestrel"},
{"description": "Postage", "amount": "12.00"},
]),
),
let bill_file = server
.wire
.upload(b"Dana's invoice", "dana.pdf", "application/pdf", "receipt");
let mut bill_args = doc(
&dana,
"2026-03-09",
json!([
{"description": "Illustration", "amount": "987.65", "account": "6500", "class": "Kestrel"},
{"description": "Postage", "amount": "12.00", "account": "6500"},
]),
);
bill_args["vendor_ref"] = json!("DANA-9");
bill_args["upload_id"] = json!(bill_file);
let bill = server.tool("books.create_bill", bill_args);
server.tool(
"books.receive_payment",
json!({"document_id": inv["id"], "date": "2026-02-20", "amount": "1830.00", "cash_account": "1000"}),
@@ -329,7 +339,7 @@ fn invoiced_and_billed_project_figures_are_the_same_in_the_viewer_and_from_the_s
.as_array()
.unwrap()
.iter()
.filter(|l| l["code"] == "4000" || l["code"] == "6999")
.filter(|l| matches!(l["code"].as_str(), Some("4000" | "6500" | "6999")))
.map(|l| l["project"].as_str().map(str::to_string))
.collect();
assert_eq!(viewer, served, "entry {entry}");
+6 -2
View File
@@ -421,16 +421,20 @@ fn a_file_links_to_every_kind_of_subject() {
m.add_party("Acme Design", &["contractor"]);
let vendor = m.ok("books.add_vendor", json!({"name": "Landlord"}))["id"].clone();
let customer = m.ok("books.add_customer", json!({"name": "Client"}))["id"].clone();
let rent_invoice = m.upload(b"rent invoice", "rent.pdf", "application/pdf", "receipt");
let bill = m.ok(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
"lines": [{"description": "Rent", "amount": "300.00"}]}),
"vendor_ref": "RENT-2",
"lines": [{"description": "Rent", "amount": "300.00", "account": "6000"}],
"upload_id": rent_invoice, "confirmed": true}),
)["id"]
.clone();
let invoice = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Work", "amount": "500.00"}]}),
"lines": [{"description": "Work", "amount": "500.00"}],
"confirmed": true}),
)["id"]
.clone();
let period = m.ok(
+41 -11
View File
@@ -857,7 +857,8 @@ fn happy_path(name: &str) -> Mcp {
let inv = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
"lines": [{"description": "Consulting", "amount": "500.00"}],
"confirmed": true}),
)["id"]
.as_u64()
.unwrap();
@@ -865,10 +866,18 @@ fn happy_path(name: &str) -> Mcp {
"books.receive_payment",
json!({"document_id": inv, "date": "2026-02-10", "amount": "200.00", "cash_account": "1000"}),
);
let vendor_invoice = m.upload(
b"vendor invoice",
"vendor-invoice.pdf",
"application/pdf",
"receipt",
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": vendor, "date": "2026-02-01", "due_date": "2026-02-28",
"lines": [{"description": "Office rent", "amount": "300.00"}]}),
"vendor_ref": "V-1001",
"lines": [{"description": "Office rent", "amount": "300.00", "account": "6000"}],
"upload_id": vendor_invoice, "confirmed": true}),
)["id"]
.as_u64()
.unwrap();
@@ -882,7 +891,8 @@ fn happy_path(name: &str) -> Mcp {
let dup = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}),
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}],
"confirmed": true}),
)["id"]
.as_u64()
.unwrap();
@@ -1297,6 +1307,9 @@ struct Known {
periods: Vec<u64>,
vendors: Vec<u64>,
bills: Vec<u64>,
/// The vendor's invoice every bill filed in this run names (P4b S4.4:
/// a bill keeps the file it was entered from).
vendor_invoice: u64,
}
/// One step of the random walk.
@@ -1363,6 +1376,9 @@ enum Op {
CreateBill {
party: Pick,
amount: String,
/// The vendor's own invoice number: two bills by one vendor with
/// the same number are refused (B-40).
vendor_ref: String,
},
PayBill {
doc: Pick,
@@ -1444,7 +1460,8 @@ fn op_strategy() -> impl Strategy<Value = Op> {
prop::sample::select(vec!["asset", "expense", "revenue", "bogus"]))
.prop_map(|(c, t)| Op::AddAccount { code: c.to_string(), ty: t.to_string() }),
1 => prop::sample::select(vec!["Acme", "Acme", "", "1"]).prop_map(|n| Op::AddVendor { name: n.to_string() }),
1 => (pick(), amount_strategy()).prop_map(|(party, amount)| Op::CreateBill { party, amount }),
1 => (pick(), amount_strategy(), 0u64..1000)
.prop_map(|(party, amount, n)| Op::CreateBill { party, amount, vendor_ref: format!("V-{n}") }),
1 => (pick(), amount_strategy()).prop_map(|(doc, amount)| Op::PayBill { doc, amount }),
1 => amount_strategy().prop_map(|deposit| Op::Payout { deposit }),
1 => (account_strategy(), prop::sample::select(vec!["8000", "1000", ""]))
@@ -1578,10 +1595,16 @@ fn run_op(m: &mut Mcp, k: &mut Known, op: &Op) {
json!({"code": code, "name": format!("Acct {code}"), "account_type": ty}),
),
Op::AddVendor { name } => ("books.add_vendor", json!({"name": name})),
Op::CreateBill { party, amount } => (
Op::CreateBill {
party,
amount,
vendor_ref,
} => (
"books.create_bill",
json!({"party_id": party.resolve(&k.vendors), "date": "2026-03-01",
"due_date": "2026-03-31", "lines": [{"description": "thing", "amount": amount}]}),
"due_date": "2026-03-31", "vendor_ref": vendor_ref,
"lines": [{"description": "thing", "amount": amount, "account": "6000"}],
"existing_attachment_id": k.vendor_invoice, "confirmed": true}),
),
Op::PayBill { doc, amount } => (
"books.pay_bill",
@@ -1647,7 +1670,10 @@ proptest! {
let toml = books_toml(&dir, "25.00", "");
let mut m = Mcp::start(&dir, &toml);
m.accounts(BASE_CHART);
let mut known = Known::default();
let mut known = Known {
vendor_invoice: m.attach(None, b"a vendor's invoice"),
..Known::default()
};
for op in &ops {
run_op(&mut m, &mut known, op);
}
@@ -2482,7 +2508,8 @@ fn assigned_roles_steer_invoices() {
m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
"lines": [{"description": "Consulting", "amount": "500.00"}],
"confirmed": true}),
);
let tb = m.ok("books.trial_balance", json!({})).to_string();
assert!(tb.contains("1110") && tb.contains("4100"), "{tb}");
@@ -2544,7 +2571,8 @@ fn documents_void_and_tie_out_over_the_wire() {
let inv = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "Consulting", "amount": "500.00"}]}),
"lines": [{"description": "Consulting", "amount": "500.00"}],
"confirmed": true}),
)["id"]
.as_u64()
.unwrap();
@@ -2574,7 +2602,8 @@ fn documents_void_and_tie_out_over_the_wire() {
let dup = m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}]}),
"lines": [{"description": "Consulting, entered twice", "amount": "500.00"}],
"confirmed": true}),
)["id"]
.as_u64()
.unwrap();
@@ -2641,7 +2670,8 @@ fn documents_list_and_timeline_over_the_wire() {
m.ok(
"books.create_invoice",
json!({"party_id": customer, "date": date, "due_date": due,
"lines": [{"description": "Consulting", "amount": amount}]}),
"lines": [{"description": "Consulting", "amount": amount}],
"confirmed": true}),
)["id"]
.as_u64()
.unwrap()
+11 -2
View File
@@ -827,10 +827,18 @@ fn party_totals_match_a_hand_computed_year() {
.as_u64()
.unwrap();
// A bill of 800 (not paid until paid), 300 of it paid from the bank.
let logo_invoice = m.wire.upload(
b"logo invoice",
"logo-invoice.pdf",
"application/pdf",
"receipt",
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": v, "date": "2026-07-01", "due_date": "2026-07-31",
"lines": [{"description": "Logo", "amount": "800.00"}]}),
"vendor_ref": "D-1041",
"lines": [{"description": "Logo", "amount": "800.00", "account": "6000"}],
"upload_id": logo_invoice, "confirmed": true}),
);
let pay = m.ok(
"books.pay_bill",
@@ -910,7 +918,8 @@ fn party_totals_for_a_customer_a_member_and_a_prepaid() {
let inv = m.ok(
"books.create_invoice",
json!({"party_id": c, "date": "2026-03-01", "due_date": "2026-03-31",
"lines": [{"description": "Work", "amount": "1000.00"}]}),
"lines": [{"description": "Work", "amount": "1000.00"}],
"confirmed": true}),
);
let pay = m.ok(
"books.receive_payment",
+29 -13
View File
@@ -1236,7 +1236,7 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
{"description": "Hosting", "amount": "200.00", "class": harbor.to_string()},
{"description": "Rush fee", "amount": "100.00"},
{"description": "Revisions", "amount": "500.00", "class": " zeta SITE "},
]}),
], "confirmed": true}),
);
assert_eq!(inv["total"], "3800.00");
assert!(inv.get("notes").is_none(), "{inv}");
@@ -1256,19 +1256,27 @@ fn invoice_and_bill_lines_count_toward_their_projects() {
m.entry_class_ids(inv_entry),
vec![None, Some(zeta), Some(harbor), None]
);
let dana_invoice = m.wire.upload(
b"Dana's invoice",
"dana-invoice.pdf",
"application/pdf",
"receipt",
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08", "lines": [
{"description": "Illustration", "amount": "1200.00", "class": "Zeta site"},
{"description": "Postage", "amount": "50.00"},
]}),
json!({"party_id": dana_vendor, "date": "2026-03-09", "due_date": "2026-04-08",
"vendor_ref": "DANA-77",
"lines": [
{"description": "Illustration", "amount": "1200.00", "account": "6000", "class": "Zeta site"},
{"description": "Postage", "amount": "50.00", "account": "6000"},
], "upload_id": dana_invoice, "confirmed": true}),
);
let bill_entry = bill["entry_id"].as_u64().unwrap();
assert_eq!(
m.entry_lines(bill_entry),
vec![
l4("6999", "debit", "1200.00", Some("Zeta site")),
l4("6999", "debit", "50.00", None),
l4("6000", "debit", "1200.00", Some("Zeta site")),
l4("6000", "debit", "50.00", None),
l4("2000", "credit", "1250.00", None),
]
);
@@ -1375,7 +1383,7 @@ fn a_document_for_a_finished_project_says_so() {
{"description": "Final", "amount": "10.00", "class": "Harbor"},
{"description": "Again", "amount": "10.00", "class": "harbor"},
{"description": "Old", "amount": "10.00", "class": "Dock"},
]}),
], "confirmed": true}),
);
let notes: Vec<&str> = out["notes"]
.as_array()
@@ -1420,7 +1428,7 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
{"description": "Build", "amount": "10.00", "class": "Kestrel"},
{"description": "More", "amount": "5.00", "class": "kestrel"},
{"description": "Site", "amount": "7.00", "class": "Zeta"},
]}),
], "confirmed": true}),
);
let notes = out["notes"].as_array().unwrap();
assert_eq!(notes.len(), 1, "{out}");
@@ -1434,11 +1442,19 @@ fn an_invoice_for_an_on_spec_project_asks_about_an_agreement() {
m.entry_lines(e)[1],
l4("4000", "credit", "15.00", Some("Kestrel"))
);
let icons_invoice = m.wire.upload(
b"icons invoice",
"icons-invoice.pdf",
"application/pdf",
"receipt",
);
let bill = m.ok(
"books.create_bill",
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04", "lines": [
{"description": "Icons", "amount": "10.00", "class": "Kestrel"},
]}),
json!({"party_id": v, "date": "2026-10-05", "due_date": "2026-11-04",
"vendor_ref": "V-512",
"lines": [
{"description": "Icons", "amount": "10.00", "account": "6000", "class": "Kestrel"},
], "upload_id": icons_invoice, "confirmed": true}),
);
assert!(bill.get("notes").is_none(), "{bill}");
}
@@ -1473,7 +1489,7 @@ fn books_without_the_feature_refuse_a_tagged_document() {
"books.create_invoice",
json!({"party_id": cedar, "date": "2026-03-02", "due_date": "2026-04-01", "lines": [
{"description": "a", "amount": "10.00"},
]}),
], "confirmed": true}),
);
let text = m.read_only("books.handbook", json!({"section": "projects"}))["section"]["text"]
.as_str()
+18 -2
View File
@@ -270,10 +270,18 @@ fn require_tax_category_refuses_defect_8() {
// A bill creates General Expenses 6999 without a category: never
// refused, named in the result's warnings and the digest.
let vendor = m.call("books.add_vendor", json!({"name": "Coastline Hosting"}));
let first_invoice = m.wire.upload(
b"Coastline's invoice",
"coastline-1.pdf",
"application/pdf",
"receipt",
);
let bill = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-01", "due_date": "2026-03-01",
"lines": [{"description": "hosting", "amount": "42.00"}]}),
"vendor_ref": "CH-1",
"lines": [{"description": "hosting", "amount": "42.00", "account": "6150"}],
"upload_id": first_invoice, "confirmed": true}),
);
assert_eq!(
bill["warnings"],
@@ -287,10 +295,18 @@ fn require_tax_category_refuses_defect_8() {
json!(["6999 General Expenses"])
);
// A second bill creates nothing, and warns of nothing.
let second_invoice = m.wire.upload(
b"Coastline's second invoice",
"coastline-2.pdf",
"application/pdf",
"receipt",
);
let again = m.call(
"books.create_bill",
json!({"party_id": vendor["id"], "date": "2026-02-02", "due_date": "2026-03-02",
"lines": [{"description": "hosting", "amount": "1.00"}]}),
"vendor_ref": "CH-2",
"lines": [{"description": "hosting", "amount": "1.00", "account": "6150"}],
"upload_id": second_invoice, "confirmed": true}),
);
assert!(again.get("warnings").is_none(), "{again}");
// Back to warn (cleared): created, with the warning.