merge: P4b S5, sent tracking (branch S5, ad649be3)
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s

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
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:21:21 -06:00
commit 3a3f03d884
44 files changed
+1847 -30

No files matched your search

@@ -250,3 +250,50 @@ fn the_viewer_and_books_start_see_the_same_attention() {
compare_and_expect(&place, &mut wire, today, "doesn't match the journal");
}
}
/// Plan P4b S5, step 4 (R-F): an issued invoice nobody marked sent is on
/// the viewer's list and in `books.start`'s next steps, from the one
/// engine, with the step naming `books.mark_sent`.
#[test]
fn an_unsent_invoice_is_on_both_lists() {
let today = ledger_core::settings::today_in(None);
let place = common::Place::new("attention-unsent-parity");
let dir = place.books_data();
let _ = std::fs::remove_dir_all(&dir);
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
st.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let client = st.add_customer("Harbor Client").unwrap();
st.create_invoice_with(
client,
today - chrono::Days::new(40),
today + chrono::Days::new(20),
vec![ledger_core::arap::DocumentLine {
description: "Consulting".into(),
amount: Decimal::new(50_000, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
ledger_core::arap::InvoiceFields {
number: Some("INV-0001".into()),
..Default::default()
},
)
.unwrap();
drop(st);
let mut wire = place.start(Some(SETTINGS));
wire.initialize("attention-unsent-parity");
compare_and_expect(&place, &mut wire, today, "Invoice INV-0001 was never sent");
// The step offers the fix: books.mark_sent on that invoice.
let answer = start(&mut wire);
let step = answer["next_steps"]
.as_array()
.unwrap()
.iter()
.find(|s| s["why"] == json!("Invoice INV-0001 was never sent"))
.unwrap_or_else(|| panic!("no unsent step: {answer}"));
assert_eq!(step["tool"], json!("books.mark_sent"), "{answer}");
assert_eq!(step["args"]["invoice"], json!("invoice:1"), "{answer}");
}
+86
View File
@@ -709,3 +709,89 @@ fn an_invoice_bill_to_uses_the_billing_details() {
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"
);
}
+17
View File
@@ -760,6 +760,16 @@ fn books_p2_served_cut_over_whole() {
// A project by name alone is a plain class, which P2 reads.
a.call("books.add_project", json!({"name": "Lighthouse"}))
.unwrap();
// An invoice for the send below: a document P2 reads (no new fields),
// so it is written before the go-ahead; its send waits for it.
let invoice = a
.call(
"books.create_invoice",
json!({"party_id": 1, "date": "2026-01-05", "due_date": "2026-02-04",
"lines": [{"description": "Design", "amount": "400.00"}], "confirmed": true}),
)
.unwrap()["id"]
.clone();
let waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and
@@ -793,6 +803,13 @@ fn books_p2_served_cut_over_whole() {
json!({"decided_by": "Avery Quill",
"changes": [{"key": "invoicing.default_terms", "value": "Net 30"}]}),
),
// A recorded send (invoice.sent, documents_v2, P4b S5): an older
// version refuses the type.
(
"books.mark_sent",
json!({"invoice": invoice.to_string(), "on": "2026-01-06",
"by": "Avery Quill", "recipients": ["billing@fernhill.example"]}),
),
(
"books.add_account",
json!({"code": "4900", "name": "Gain on sale", "account_type": "revenue",
+1 -1
View File
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
// Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap();
assert_eq!(tools.len(), 122, "expected 122 tools, got {}", tools.len());
assert_eq!(tools.len(), 123, "expected 123 tools, got {}", tools.len());
// Files come in as tool input: books.begin_upload, never a local path.
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
+7
View File
@@ -906,6 +906,13 @@ fn happy_path(name: &str) -> Mcp {
json!({"kind": "invoices", "status": "all"}),
);
m.ok("books.document", json!({"ref": format!("invoice:{inv}")}));
// P4b S5: the invoice went out (the books record the send, never make it).
m.ok(
"books.mark_sent",
json!({"invoice": format!("invoice:{inv}"), "on": "2026-02-03",
"by": "Avery Quill", "recipients": ["billing@client.example"],
"reference": "msg-1"}),
);
// Fixed assets.
let registered = m.ok(