Files
wanderbooks/crates/ledger-cli/tests/documents.rs
T
swaitsandClaude Fable 5.1 3a3f03d884
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s
merge: P4b S5, sent tracking (branch S5, ad649be3)
The second of the paused branches. invoice.sent, Ledger::mark_sent (with
its Lean step), books.mark_sent in its own group, the two attention
items, schema 17. Conflicts, resolved as plan 8.0.2 says: the shared
over-the-wire test file crates/ledger-cli/tests/documents.rs (S4.4's
file kept whole, S5's test appended, its create call given the
confirmed: true the S4.4 contract now needs), types.rs (both appends
kept), docs/parity.md (the review's oracle row and S5's new row).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
2026-10-28 21:21:21 -06:00

798 lines
31 KiB
Rust

//! 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}");
}
// P4b S5: sent tracking (merged from branch S5).
/// Two sends on one invoice: the first the original (defaults: the
/// token's name, email, today's company day), the second a reminder with
/// the channel's message id; then `books.documents` shows the last send
/// and `books.document` the whole record.
#[test]
fn mark_sent_records_who_and_how() {
let books = common::Books::new("p4b-s5-mark-sent");
let mut wire = books.session("p4b-s5");
let customer =
common::answer_of(&wire.call("books.add_customer", json!({"name": "Fernhill Labs"})))
.unwrap()["id"]
.as_u64()
.unwrap();
let invoice = common::answer_of(&wire.call(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-03-01", "due_date": "2026-03-31",
"lines": [{"description": "Design", "amount": "1200.00"}], "confirmed": true}),
))
.unwrap()["id"]
.as_u64()
.unwrap();
let first = common::answer_of(&wire.call(
"books.mark_sent",
json!({"invoice": format!("invoice:{invoice}"), "on": "2026-03-05",
"by": "Avery Quill", "recipients": ["billing@fernhill.example"]}),
))
.unwrap();
assert_eq!(first["invoice"], json!(format!("invoice:{invoice}")));
assert_eq!(first["sends"][0]["on"], json!("2026-03-05"));
assert_eq!(first["sends"][0]["by"], json!("Avery Quill"));
assert_eq!(
first["sends"][0]["via"],
json!("email"),
"the default channel"
);
assert_eq!(first["sends"][0]["to"], json!(["billing@fernhill.example"]));
assert_eq!(
first["sends"][0]["kind"],
json!("original"),
"the first send is the original"
);
// The second names no `by`, `via` or `kind`: this session's token
// (the agent), email, and a reminder now that one send is recorded.
let second = common::answer_of(&wire.call(
"books.mark_sent",
json!({"invoice": invoice.to_string(), "on": "2026-03-20",
"recipients": ["billing@fernhill.example"], "reference": "msg-2",
"note": "Second copy"}),
))
.unwrap();
let sends = second["sends"].as_array().unwrap();
assert_eq!(sends.len(), 2, "{second}");
assert_eq!(sends[1]["by"], json!("test"), "the token's name");
assert_eq!(sends[1]["via"], json!("email"));
assert_eq!(sends[1]["kind"], json!("reminder"));
assert_eq!(sends[1]["reference"], json!("msg-2"));
assert_eq!(sends[1]["note"], json!("Second copy"));
let listed =
common::answer_of(&wire.call("books.documents", json!({"kind": "invoices"}))).unwrap();
assert_eq!(
listed["documents"][0]["ref"],
json!(format!("invoice:{invoice}"))
);
assert_eq!(
listed["documents"][0]["last_sent"],
json!("2026-03-20"),
"the list shows the latest send"
);
let one = common::answer_of(&wire.call(
"books.document",
json!({"ref": format!("invoice:{invoice}"), "as_of": "2026-03-31"}),
))
.unwrap();
assert_eq!(one["sends"].as_array().unwrap().len(), 2);
assert_eq!(
one["outstanding"],
json!("1200.00"),
"a send changes no figure"
);
}