diff --git a/crates/ledger-cli/tests/attention_parity.rs b/crates/ledger-cli/tests/attention_parity.rs index 0a14ec4..1b9fd2c 100644 --- a/crates/ledger-cli/tests/attention_parity.rs +++ b/crates/ledger-cli/tests/attention_parity.rs @@ -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}"); +} diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs index de7fcd2..9f5e817 100644 --- a/crates/ledger-cli/tests/documents.rs +++ b/crates/ledger-cli/tests/documents.rs @@ -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" + ); +} diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index a0df738..e79edfd 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -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", diff --git a/crates/ledger-cli/tests/mcp_journeys.rs b/crates/ledger-cli/tests/mcp_journeys.rs index c8f33cc..5e7cf89 100644 --- a/crates/ledger-cli/tests/mcp_journeys.rs +++ b/crates/ledger-cli/tests/mcp_journeys.rs @@ -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")); diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index d87b91d..39670f2 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -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( diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index e27d3a0..f3290e0 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -789,6 +789,36 @@ pub enum SentVia { Other(String), } +impl SentVia { + /// Its key as the journal, the tools and the viewer name it: `email`, + /// `mail`, `portal`, `hand`, or `other:NAME` for another channel, + /// named (plan P4b S5). + pub fn key(&self) -> String { + match self { + SentVia::Email => "email".to_string(), + SentVia::Mail => "mail".to_string(), + SentVia::Portal => "portal".to_string(), + SentVia::Hand => "hand".to_string(), + SentVia::Other(name) => format!("other:{name}"), + } + } + + /// [`Self::key`] the other way; `None` for a channel this version + /// doesn't know (a newer version's `sent_via`). + pub fn parse(key: &str) -> Option { + match key { + "email" => Some(SentVia::Email), + "mail" => Some(SentVia::Mail), + "portal" => Some(SentVia::Portal), + "hand" => Some(SentVia::Hand), + other => other + .strip_prefix("other:") + .filter(|name| !name.trim().is_empty()) + .map(|name| SentVia::Other(name.to_string())), + } + } +} + /// Whether a send was the original or a reminder (plan P4b S5). #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] @@ -799,6 +829,26 @@ pub enum SendKind { Reminder, } +impl SendKind { + /// Its key as the journal, the tools and the viewer name it. + pub fn key(self) -> &'static str { + match self { + SendKind::Original => "original", + SendKind::Reminder => "reminder", + } + } + + /// [`Self::key`] the other way; `None` for a kind this version + /// doesn't know. + pub fn parse(key: &str) -> Option { + match key { + "original" => Some(SendKind::Original), + "reminder" => Some(SendKind::Reminder), + _ => None, + } + } +} + /// One recorded send of an invoice (plan P4b S5): who sent it, how, to /// whom, and the channel's own reference. A send changes no figure. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/ledger-core/src/error.rs b/crates/ledger-core/src/error.rs index 28212b3..55272b5 100644 --- a/crates/ledger-core/src/error.rs +++ b/crates/ledger-core/src/error.rs @@ -778,6 +778,17 @@ pub enum LedgerError { #[error("{0}")] InvalidVoid(String), + /// A void invoice can't be recorded as sent: it was never a document + /// the customer should have received (plan P4b S5, B-38). + #[error("invoice {0} is void: a void invoice was never sent")] + DocumentVoid(u64), + + /// A send was refused: the message says why (no recipients, a date + /// before its invoice, a recipient or reference that breaks the + /// books' text rule). Plan P4b S5. + #[error("{0}")] + InvalidSend(String), + /// Invoice/bill due date is before its date. #[error("due date {0} is before document date")] DueBeforeDate(String), diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index 4158097..35ffd08 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -5751,6 +5751,108 @@ impl Ledger { Ok(()) } + /// Record that an invoice went out (plan P4b S5): who sent it, how, to + /// whom, when, whether it was the original or a reminder, and the + /// channel's own reference (a message id). The books never send + /// anything themselves (A-4: "Wanderbooks just does books"). + /// + /// ```text + /// GOAL: record a send as a fact of the document's life, without + /// touching a figure: a send moves no money. + /// SOURCE: docs/spec/rules.md B-38 (a document keeps no figure of its + /// own), B-27 (the invoice.sent line, feature documents_v2); + /// plan P4b S5, A-4 (the books record sends; they never send). + /// INVARIANTS: + /// 1. The invoice MUST exist, and a send MUST NOT be dated before it. + /// 2. A void invoice MUST NOT be sent (DocumentVoid): it should never + /// have reached the customer. Open, partial, paid and written-off + /// invoices all may be (a receipt copy, a reminder). + /// 3. `to` MUST hold at least one recipient; every recipient, the + /// reference and the note MUST pass parties::check_text (no full + /// TIN, no control characters, at most 500 characters). + /// 4. A send MUST change no figure: the invoice's DocState is the + /// same before and after, on every date. + /// 5. A refusal MUST leave the books unchanged. + /// SCENARIOS: + /// 1. IF the invoice is unknown, void, dated after the send, has no + /// recipients, or a text breaks the books' rule: SHOULD refuse. + /// ``` + pub fn mark_sent(&mut self, invoice: InvoiceId, sent: arap::Sent) -> Result<(), LedgerError> { + self.atomically(|l| l.mark_sent_steps(invoice, sent)) + } + + /// The steps of [`Self::mark_sent`], run inside [`Self::atomically`]. + fn mark_sent_steps(&mut self, invoice: InvoiceId, sent: arap::Sent) -> Result<(), LedgerError> { + let issued = self + .invoices + .get(&invoice) + .ok_or(LedgerError::UnknownInvoice(invoice))? + .date; + if sent.on < issued { + return Err(LedgerError::InvalidSend(format!( + "invoice {invoice} is dated {issued}: a send can't be dated before it, on {}", + sent.on + ))); + } + // The invoice's state on the day it went out: a void later than the + // send doesn't un-send it, and a void before it does. + let state = self + .doc_state(arap::DocRef::Invoice(invoice), sent.on)? + .ok_or(LedgerError::UnknownInvoice(invoice))?; + match state.status { + arap::DocStatus::Open + | arap::DocStatus::Partial + | arap::DocStatus::Paid + | arap::DocStatus::WrittenOff => {} + arap::DocStatus::Void => return Err(LedgerError::DocumentVoid(invoice)), + arap::DocStatus::Overpaid | arap::DocStatus::Unattached => { + return Err(LedgerError::InvalidSend(format!( + "invoice {invoice} is {status} on {on}: only an open, partial, paid or written-off invoice can be sent", + status = state.status.key(), + on = sent.on + ))); + } + } + if sent.to.is_empty() { + return Err(LedgerError::InvalidSend(format!( + "a send of invoice {invoice} needs at least one recipient" + ))); + } + for to in &sent.to { + crate::parties::check_text("a recipient", to).map_err(LedgerError::InvalidSend)?; + } + if let Some(reference) = &sent.reference { + crate::parties::check_text("the reference", reference) + .map_err(LedgerError::InvalidSend)?; + } + if let Some(note) = &sent.note { + crate::parties::check_text("the note", note).map_err(LedgerError::InvalidSend)?; + } + self.invoices + .get_mut(&invoice) + .expect("checked above") + .sends + .push(sent); + Ok(()) + } + + /// Record a send without checking it: replay support for journaling + /// stores. Nothing is validated on replay (rules.md B-28); the invoice + /// must already have been replayed from its own events. + #[doc(hidden)] + pub fn replay_invoice_sent( + &mut self, + invoice_id: InvoiceId, + sent: arap::Sent, + ) -> Result<(), LedgerError> { + let invoice = self + .invoices + .get_mut(&invoice_id) + .ok_or(LedgerError::UnknownInvoice(invoice_id))?; + invoice.sends.push(sent); + Ok(()) + } + /// Look up an invoice. pub fn get_invoice(&self, id: InvoiceId) -> Option<&Invoice> { self.invoices.get(&id) diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index e7a7980..8331750 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -67,6 +67,7 @@ fn route(t: ledger_package::Target) -> Option { ledger_package::Target::Assets => Route::Assets, ledger_package::Target::Asset(a) => Route::Asset(a), ledger_package::Target::Documents => Route::Documents, + ledger_package::Target::Invoice(i) => Route::Invoice(i), ledger_package::Target::Journal => Route::Journal, ledger_package::Target::Report(r) => Route::Reports(match r { ledger_package::Report::Vendor1099 => ReportKind::Vendor1099, diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 67654c5..7c7f123 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -807,6 +807,24 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { format!("Payment received, {}", amount(p)), entry_id.map(Route::Entry), ), + "invoice.sent" => { + let invoice = p.get("invoice_id").and_then(|v| v.as_u64()).unwrap_or(0); + let via = p + .get("via") + .and_then(|v| v.as_str()) + .map(|v| v.strip_prefix("other:").unwrap_or(v)) + .unwrap_or("a channel"); + let what = if p.get("kind").and_then(|v| v.as_str()) == Some("reminder") { + "reminder " + } else { + "" + }; + ev( + icon::INVOICE, + format!("Invoice {invoice} {what}sent by {via}"), + None, + ) + } "bill.paid" => ev( icon::MONEY, format!("Bill paid, {}", amount(p)), diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index f51ad3c..0709afc 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -8,6 +8,7 @@ use chrono::NaiveDate; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; +use ledger_core::arap::{InvoiceFields, SendKind, Sent, SentVia}; use ledger_core::{AccountType, Currency, DocumentLine}; use ledger_gui::nav::Route; use ledger_gui::{App, Source, data}; @@ -208,3 +209,128 @@ fn books_without_documents_show_no_overview_tiles_for_them() { shot(&mut h, "receivables-empty"); assert!(h.query_by_label("No invoices").is_some()); } + +/// Books with one issued invoice (a number, so the sent-tracking items +/// apply) and a checking account, for the attention items (plan P4b S5). +fn issued_invoice(dir: &TempDir, due: NaiveDate) -> u64 { + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + s.add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let client = s.add_customer("Harbor Client").unwrap(); + s.create_invoice_with( + client, + d(2026, 2, 1), + due, + vec![line("Consulting", 50_000)], + InvoiceFields { + number: Some("INV-0001".into()), + ..Default::default() + }, + ) + .unwrap() +} + +/// A send as these tests record one. +fn sent(on: NaiveDate) -> Sent { + Sent { + on, + by: "agent".into(), + via: SentVia::Email, + to: vec!["billing@harbor.example".into()], + kind: SendKind::Original, + reference: None, + note: None, + } +} + +fn attention_titles(dir: &TempDir) -> Vec { + data::load_once(&Source { + data_dir: dir.0.join("books-data"), + today: Some(d(2026, 3, 15)), + }) + .unwrap() + .attention + .into_iter() + .map(|a| a.title) + .collect() +} + +/// Plan P4b S5, step 4 (R-F): an issued invoice nobody marked sent, past +/// `invoicing.unsent_days` (2 by default), is a to-do that opens the +/// invoice; recording a send clears it. The overview draws it (the +/// screenshot is the R-F pattern's). +#[test] +fn an_unsent_invoice_is_listed_until_a_send_clears_it() { + let dir = TempDir::new("attention-unsent"); + let id = issued_invoice(&dir, d(2026, 4, 30)); + let titles = attention_titles(&dir); + assert!( + titles + .iter() + .any(|t| t == "Invoice INV-0001 was never sent"), + "{titles:?}" + ); + let mut h = open(&dir, Route::Overview); + assert!( + h.query_by_label_contains("Invoice INV-0001 was never sent") + .is_some() + ); + shot(&mut h, "attention-unsent-invoice"); + let mut s = Store::open(&dir.0.join("books-data")).unwrap(); + s.mark_sent(id, sent(d(2026, 2, 10))).unwrap(); + drop(s); + let titles = attention_titles(&dir); + assert!( + !titles.iter().any(|t| t.contains("never sent")), + "a send clears the item: {titles:?}" + ); +} + +/// The legacy case: an invoice with no number (issued before +/// `documents_v2`) is exempt from "never sent", however old. +#[test] +fn a_legacy_invoice_with_no_number_is_never_listed_as_unsent() { + let dir = TempDir::new("attention-legacy"); + let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap(); + s.add_account("1000", "Checking", AccountType::Asset, None, false) + .unwrap(); + let client = s.add_customer("Harbor Client").unwrap(); + s.create_invoice( + client, + d(2026, 1, 5), + d(2026, 4, 30), + vec![line("Consulting", 50_000)], + ) + .unwrap(); + drop(s); + let titles = attention_titles(&dir); + assert!( + !titles.iter().any(|t| t.contains("never sent")), + "{titles:?}" + ); +} + +/// An overdue invoice whose last send is older than +/// `invoicing.reminder_days` (14 by default) asks for a reminder, on the +/// invoice's page. +#[test] +fn an_overdue_invoice_with_an_old_send_is_asked_to_remind() { + let dir = TempDir::new("attention-remind"); + let id = issued_invoice(&dir, d(2026, 3, 1)); + let mut s = Store::open(&dir.0.join("books-data")).unwrap(); + s.mark_sent(id, sent(d(2026, 2, 20))).unwrap(); + drop(s); + let books = data::load_once(&Source { + data_dir: dir.0.join("books-data"), + today: Some(d(2026, 3, 15)), + }) + .unwrap(); + let item = books + .attention + .iter() + .find(|a| a.title.contains("remind the customer")) + .unwrap_or_else(|| panic!("no reminder item: {:?}", attention_titles(&dir))); + assert_eq!(item.target, Some(Route::Invoice(id))); + assert!(item.detail.contains("14 days overdue"), "{}", item.detail); + assert!(item.prompt.contains("books.mark_sent"), "{}", item.prompt); +} diff --git a/crates/ledger-mcp/handbook/invoices-and-bills.md b/crates/ledger-mcp/handbook/invoices-and-bills.md new file mode 100644 index 0000000..464e9f4 --- /dev/null +++ b/crates/ledger-mcp/handbook/invoices-and-bills.md @@ -0,0 +1,28 @@ +# Invoices and bills + +An invoice bills a customer: it posts Dr Accounts Receivable and Cr the +revenue its lines name. A bill is a vendor's invoice to us: it posts Dr +the expense or asset its lines name, and Cr Accounts Payable. A document's +own figures are read from the ledger lines tagged with it, never kept on +the document: a reversed payment, a void or a write-off comes out right +without another copy to correct (B-38). + +## Sending an invoice + +- The books never send mail, and never say they did. To send an invoice, + create and post it (`books.create_invoice`), hand its PDF to the human + (`books.document` with `pdf: true`), and let the owner or a delegate + send it. +- Then record the send with `books.mark_sent`: the invoice (`invoice:7`), + the day it went out, who sent it (`by`: the owner, or the agent acting + for them), how (`via`: `email`, or `mail`, `portal`, `hand`, or another + channel's own name), to whom (`to`: the bill-to email, else the + customer's email), whether it was the original or a reminder (`kind`: + `original` for the first send, `reminder` after), and the channel's own + reference when there is one (a message id). +- A send changes no figure and never moves the due date. Several sends + are allowed: each is recorded in order, and `books.documents` shows the + latest as `last_sent`. +- A void invoice can't be marked sent: it should never have reached the + customer. An invoice already open, partly paid, paid or written off can + (a copy for the customer's records, a reminder). diff --git a/crates/ledger-mcp/src/handbook.rs b/crates/ledger-mcp/src/handbook.rs index beb69b2..92dac73 100644 --- a/crates/ledger-mcp/src/handbook.rs +++ b/crates/ledger-mcp/src/handbook.rs @@ -103,6 +103,10 @@ pub const SECTIONS: &[(&str, &str)] = &[ "tracing-figures", include_str!("../handbook/tracing-figures.md"), ), + ( + "invoices-and-bills", + include_str!("../handbook/invoices-and-bills.md"), + ), ]; /// The resource URI of the handbook's index, and the prefix of a section's. diff --git a/crates/ledger-mcp/src/scopes.rs b/crates/ledger-mcp/src/scopes.rs index 978337e..dd9acdf 100644 --- a/crates/ledger-mcp/src/scopes.rs +++ b/crates/ledger-mcp/src/scopes.rs @@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed; const K: Needs = PostForApprovalRules; /// Every tool and what it needs (INVARIANT 1). -pub const TABLE: [(&str, Needs); 122] = [ +pub const TABLE: [(&str, Needs); 123] = [ ("books.handbook", R), ("books.start", R), // Settings are committed at once: the settings commit of the plan. @@ -166,6 +166,8 @@ pub const TABLE: [(&str, Needs); 122] = [ ("books.receive_payment", P), ("books.pay_bill", P), ("books.void_document", P), + // Records a send: changes the books' record, never a figure. + ("books.mark_sent", W), ("books.control_check", R), ("books.documents", R), ("books.document", R), diff --git a/crates/ledger-mcp/src/server.rs b/crates/ledger-mcp/src/server.rs index dce5b8b..b7aca05 100644 --- a/crates/ledger-mcp/src/server.rs +++ b/crates/ledger-mcp/src/server.rs @@ -566,6 +566,7 @@ impl BooksServer { + Self::instruments_router() + Self::projects_router() + Self::documents_router() + + Self::sent_router() + Self::entries_router() + Self::reports_router() + Self::assets_router() @@ -1420,6 +1421,8 @@ mod reconciliation; mod reports_tools; #[path = "tools/schedules.rs"] mod schedules; +#[path = "tools/sent.rs"] +mod sent; #[path = "tools/settings.rs"] mod settings_tools; #[path = "tools/tax.rs"] diff --git a/crates/ledger-mcp/src/server_tests.rs b/crates/ledger-mcp/src/server_tests.rs index 2f12cfb..7e92bd6 100644 --- a/crates/ledger-mcp/src/server_tests.rs +++ b/crates/ledger-mcp/src/server_tests.rs @@ -42,7 +42,7 @@ fn annotations_complete() { const D: Hints = (Some(false), Some(true), None); const WI: Hints = (Some(false), Some(false), Some(true)); const DI: Hints = (Some(false), Some(true), Some(true)); - let table: [(&str, Hints); 122] = [ + let table: [(&str, Hints); 123] = [ ("books.handbook", R), ("books.start", R), ("books.set_setting", DI), @@ -145,6 +145,7 @@ fn annotations_complete() { ("books.receive_payment", D), ("books.pay_bill", D), ("books.void_document", D), + ("books.mark_sent", W), ("books.control_check", R), ("books.documents", R), ("books.document", R), diff --git a/crates/ledger-mcp/src/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs index 1b2b3ef..073b899 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -984,7 +984,13 @@ impl BooksServer { } let store = self.lock()?; let ledger = store.ledger(); - let docs: Vec<(ledger_core::DocRef, String, NaiveDate, NaiveDate)> = if invoices { + let docs: Vec<( + ledger_core::DocRef, + String, + NaiveDate, + NaiveDate, + Option, + )> = if invoices { ledger .invoices() .into_iter() @@ -993,7 +999,13 @@ impl BooksServer { .get_customer(i.customer_id) .map(|c| c.name.clone()) .unwrap_or_default(); - (ledger_core::DocRef::Invoice(i.id), who, i.date, i.due_date) + ( + ledger_core::DocRef::Invoice(i.id), + who, + i.date, + i.due_date, + last_sent(&i.sends), + ) }) .collect() } else { @@ -1005,14 +1017,20 @@ impl BooksServer { .get_vendor(b.vendor_id) .map(|v| v.name.clone()) .unwrap_or_default(); - (ledger_core::DocRef::Bill(b.id), who, b.date, b.due_date) + ( + ledger_core::DocRef::Bill(b.id), + who, + b.date, + b.due_date, + None, + ) }) .collect() }; let party = p.party.as_deref().map(|s| s.trim().to_lowercase()); let mut rows = Vec::new(); let mut owed = Decimal::ZERO; - for (doc, who, date, due) in docs { + for (doc, who, date, due, last_sent) in docs { if let Some(want_party) = &party && who.to_lowercase() != *want_party && doc_id(doc).to_string() != *want_party @@ -1050,6 +1068,7 @@ impl BooksServer { outstanding: money(st.outstanding), status: key.to_string(), overdue_days: st.overdue_days, + last_sent, }, )); } @@ -1080,7 +1099,7 @@ impl BooksServer { let doc = parse_doc_ref(&p.r#ref)?; let store = self.lock()?; let ledger = store.ledger(); - let (who, date, due, entry_id, lines) = match doc { + let (who, date, due, entry_id, lines, sends) = match doc { ledger_core::DocRef::Invoice(id) => { let i = ledger .get_invoice(id) @@ -1089,7 +1108,14 @@ impl BooksServer { .get_customer(i.customer_id) .map(|c| c.name.clone()) .unwrap_or_default(); - (who, i.date, i.due_date, i.entry_id, i.lines.clone()) + ( + who, + i.date, + i.due_date, + i.entry_id, + i.lines.clone(), + i.sends.iter().map(sent_out).collect(), + ) } ledger_core::DocRef::Bill(id) => { let b = ledger @@ -1099,7 +1125,14 @@ impl BooksServer { .get_vendor(b.vendor_id) .map(|v| v.name.clone()) .unwrap_or_default(); - (who, b.date, b.due_date, b.entry_id, b.lines.clone()) + ( + who, + b.date, + b.due_date, + b.entry_id, + b.lines.clone(), + Vec::new(), + ) } }; let st = ledger @@ -1171,6 +1204,7 @@ impl BooksServer { status: st.status.key().to_string(), overdue_days: st.overdue_days, timeline, + sends, pdf, })) } diff --git a/crates/ledger-mcp/src/tools/helpers.rs b/crates/ledger-mcp/src/tools/helpers.rs index 8a12991..a583bf7 100644 --- a/crates/ledger-mcp/src/tools/helpers.rs +++ b/crates/ledger-mcp/src/tools/helpers.rs @@ -708,3 +708,21 @@ pub(crate) fn resolve_instrument(store: &Store, r: &str) -> Result SentOut { + SentOut { + on: s.on.to_string(), + by: s.by.clone(), + via: s.via.key(), + to: s.to.clone(), + kind: s.kind.key().to_string(), + reference: s.reference.clone(), + note: s.note.clone(), + } +} + +/// The day an invoice last went out, when a send is recorded. +pub(crate) fn last_sent(sends: &[ledger_core::arap::Sent]) -> Option { + sends.iter().map(|s| s.on).max().map(|d| d.to_string()) +} diff --git a/crates/ledger-mcp/src/tools/sent.rs b/crates/ledger-mcp/src/tools/sent.rs new file mode 100644 index 0000000..d6f7e69 --- /dev/null +++ b/crates/ledger-mcp/src/tools/sent.rs @@ -0,0 +1,118 @@ +//! `books.mark_sent`'s group: the record that an invoice went out (plan +//! P4b S5, A-4). The books never send; this records who did, how, to whom, +//! when, whether it was the original or a reminder, and the channel's own +//! reference. A send moves no figure. + +use super::*; + +/// `books.mark_sent`'s `invoice`: `invoice:7` (as books.documents names +/// it), or a bare id. +fn invoice_id(r: &str) -> Result { + let t = r.trim(); + let t = t.strip_prefix("invoice:").map(str::trim).unwrap_or(t); + t.parse::() + .map_err(|_| format!("bad invoice {r:?}: expected invoice:7 or 7")) +} + +/// The channel a send went by: email, mail, portal, hand, or another +/// channel's own name (`Other`). +fn sent_via(s: &str) -> Result { + use ledger_core::arap::SentVia; + let t = s.trim(); + match t.to_ascii_lowercase().as_str() { + "email" => Ok(SentVia::Email), + "mail" | "post" | "paper mail" => Ok(SentVia::Mail), + "portal" => Ok(SentVia::Portal), + "hand" | "in person" => Ok(SentVia::Hand), + _ if t.is_empty() => { + Err("via is empty: give email, mail, portal, hand or another channel".into()) + } + _ => Ok(SentVia::Other(t.to_string())), + } +} + +/// original or reminder. +fn send_kind(s: &str) -> Result { + ledger_core::arap::SendKind::parse(s.trim()) + .ok_or_else(|| format!("bad kind {s:?}: expected original or reminder")) +} + +#[tool_router(router = sent_router, vis = "pub(super)")] +impl BooksServer { + #[tool( + name = "books.mark_sent", + title = "Record an invoice sent", + description = "Record that an invoice went out: who sent it (by), how (via: email, mail, portal, hand, or another channel's name), to whom, the day, original or reminder, and the channel's own reference (a message id). The books never send mail: the owner or a delegate sends, then this records it. Several sends are allowed (reminders). A send changes no figure and never moves the due date. Refused on a void invoice, dated before it, with no recipients, or while the books wait for their format upgrade (documents_v2).", + annotations( + read_only_hint = false, + destructive_hint = false, + open_world_hint = false + ) + )] + async fn mark_sent( + &self, + params: Parameters, + ) -> Result, String> { + use ledger_core::arap::{SendKind, Sent}; + let p = params.0; + let on = self.as_of(p.on.as_deref())?; + let id = invoice_id(&p.invoice)?; + let mut store = self.lock()?; + let (to, kind) = { + let invoice = store + .ledger() + .get_invoice(id) + .ok_or_else(|| format!("no invoice {id}: see books.documents"))?; + let to = match p.recipients { + Some(to) if !to.is_empty() => to, + _ => { + let bill_to = invoice.bill_to.as_ref().and_then(|b| b.email.clone()); + let party = store + .ledger() + .party_of_customer(invoice.customer_id) + .and_then(|pid| store.ledger().get_party(pid)) + .and_then(|p| p.email.clone()); + vec![bill_to.or(party).ok_or_else(|| { + format!( + "invoice {id} has no bill-to email and its customer no email: give to" + ) + })?] + } + }; + let kind = match p.kind.as_deref() { + Some(k) => send_kind(k)?, + None if invoice.sends.is_empty() => SendKind::Original, + None => SendKind::Reminder, + }; + (to, kind) + }; + let by = p.by.unwrap_or_else(|| { + CALLER + .with(|c| c.borrow().as_ref().map(|(_, name)| name.clone())) + .unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string()) + }); + let via = sent_via(p.via.as_deref().unwrap_or("email"))?; + store + .mark_sent( + id, + Sent { + on, + by, + via, + to, + kind, + reference: p.reference, + note: p.note, + }, + ) + .map_err(|e| e.to_string())?; + let invoice = store + .ledger() + .get_invoice(id) + .ok_or_else(|| format!("invoice {id} not found after its send"))?; + Ok(Json(MarkSentOut { + invoice: format!("invoice:{id}"), + sends: invoice.sends.iter().map(sent_out).collect(), + })) + } +} diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index a55b1a9..8057e36 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3249,6 +3249,10 @@ pub struct DocumentRowOut { pub status: String, /// Days past due while something is owed; 0 otherwise. pub overdue_days: i64, + /// The day it last went out, when a send is recorded (P4b S5); + /// absent for a document never sent and for every bill. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub last_sent: Option, } /// Output of `books.documents`. @@ -3342,6 +3346,11 @@ pub struct DocumentOut { pub overdue_days: i64, /// Every ledger line tagged with it, in posting order. pub timeline: Vec, + /// Every recorded send, in order (P4b S5): who, how, to whom, when, + /// original or reminder, and the channel's own reference. Empty for a + /// bill and for an invoice never sent. + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub sends: Vec, /// The invoice's PDF, handed over (with `pdf`): run its download. #[serde(default, skip_serializing_if = "Option::is_none")] pub pdf: Option, @@ -4839,7 +4848,7 @@ pub struct ReconciliationsOut { /// Parameters for `books.handbook`. #[derive(Debug, Default, Deserialize, JsonSchema)] pub struct HandbookParams { - /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index. + /// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, invoices-and-bills, or "all" for the whole handbook in order. Omit for the index. #[serde(default)] pub section: Option, } @@ -5006,3 +5015,71 @@ pub struct BillOut { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub notes: Vec, } + +// P4b S5: sent tracking. + +/// Parameters for `books.mark_sent`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct MarkSentParams { + /// The invoice: `invoice:7` (as books.documents names it) or its id, + /// `7`. + pub invoice: String, + /// The day it went out, YYYY-MM-DD (omitted: today in the company's + /// time zone). + #[serde(default)] + pub on: Option, + /// Who sent it: the owner, or a named delegate such as an agent + /// (omitted: this session's token name). + #[serde(default)] + pub by: Option, + /// The channel: email (the default), mail, portal, hand, or another + /// channel's own name. + #[serde(default)] + pub via: Option, + /// The recipients (omitted: the invoice's bill-to email, else the + /// customer's email; refused when neither is set). The journal keeps + /// them as the send's `to`. + #[serde(default)] + pub recipients: Option>, + /// original (the default for a first send) or reminder (the default + /// after one). + #[serde(default)] + pub kind: Option, + /// The channel's own id (a message id, a tracking number). + #[serde(default)] + pub reference: Option, + /// A note about the send. + #[serde(default)] + pub note: Option, +} + +/// One recorded send of an invoice (P4b S5). +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct SentOut { + /// The day it went out. + pub on: String, + /// Who sent it. + pub by: String, + /// How: email, mail, portal, hand, or another channel's name. + pub via: String, + /// The recipients. + pub to: Vec, + /// original or reminder. + pub kind: String, + /// The channel's own id, when given. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub reference: Option, + /// A note about the send, when given. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub note: Option, +} + +/// Output of `books.mark_sent`. +#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] +pub struct MarkSentOut { + /// The invoice, as `invoice:7`. + pub invoice: String, + /// Every send on it, in order, the newest last. + pub sends: Vec, +} diff --git a/crates/ledger-package/src/attention.rs b/crates/ledger-package/src/attention.rs index ab7f33f..a5968ba 100644 --- a/crates/ledger-package/src/attention.rs +++ b/crates/ledger-package/src/attention.rs @@ -37,7 +37,12 @@ //! books.set_entry_instrument; a year whose worksheet can't be made //! for want of named cash accounts while vendors were paid in it //! (Todo); overdue bills, and overdue -//! invoices (Problem, one each, with the total); money owed to +//! invoices (Problem, one each, with the total); an issued invoice +//! (`number` set) still Open or Partial with no send and past +//! `invoicing.unsent_days` (Todo, its prompt naming books.mark_sent; +//! a legacy invoice with no number is exempt); an overdue invoice +//! whose last send is older than `invoicing.reminder_days` (Todo, the +//! same tool); money owed to //! owners (Info, per account with a balance); the database differing //! from the journal or unreadable, and damaged or missing stored //! files (Problem); depreciation due through the last finished month @@ -83,8 +88,8 @@ use std::collections::{BTreeSet, HashMap, HashSet}; use chrono::{Datelike, NaiveDate}; use ledger_core::parties::MoneyAccounts; use ledger_core::{ - AccountId, AccountType, AssetId, EntryId, EntryStatus, Ledger, LedgerError, Party, PartyId, - PartyMember, PeriodStatus, + AccountId, AccountType, AssetId, DocRef, DocStatus, EntryId, EntryStatus, Ledger, LedgerError, + Party, PartyId, PartyMember, PeriodStatus, }; use ledger_store::{Attachment, DiskCheck, JournalLine, Reconciliation, Statement, Subject}; use rust_decimal::Decimal; @@ -131,6 +136,8 @@ pub enum Target { Asset(AssetId), /// The documents and attachments list. Documents, + /// One invoice's page. + Invoice(ledger_core::InvoiceId), /// The journal. Journal, /// One of the reports. @@ -717,6 +724,85 @@ pub fn find_with( }); } + // Invoices: did each go out, and were overdue ones reminded? (plan P4b + // S5, A-4: the books record sends, they never make them.) The day + // counts are the owner's settings, with their documented defaults. + let setting_days = |key: &str, fallback: u32| { + ledger + .settings() + .get(key) + .and_then(|r| r.value.as_u64()) + .and_then(|n| u32::try_from(n).ok()) + .unwrap_or(fallback) + }; + let unsent_days = setting_days("invoicing.unsent_days", 2); + let reminder_days = setting_days("invoicing.reminder_days", 14); + for invoice in ledger.invoices() { + let Ok(Some(state)) = ledger.doc_state(DocRef::Invoice(invoice.id), today) else { + continue; + }; + let label = invoice + .number + .clone() + .unwrap_or_else(|| invoice.id.to_string()); + let last_sent = invoice.sends.iter().map(|s| s.on).max(); + let open = matches!(state.status, DocStatus::Open | DocStatus::Partial); + let listed_after = invoice + .date + .checked_add_days(chrono::Days::new(u64::from(unsent_days))); + if invoice.number.is_some() + && open + && last_sent.is_none() + && listed_after.is_some_and(|limit| today > limit) + { + out.push(Attention { + severity: Severity::Todo, + kind: "invoice_never_sent", + title: format!("Invoice {label} was never sent"), + detail: format!( + "Issued {}, with no send recorded. It is listed once {unsent_days} days have passed.", + date(invoice.date) + ), + target: Target::Invoice(invoice.id), + prompt: format!( + "In wanderbooks, invoice {label} (invoice:{}) has no recorded send. Ask me when and how it went out, then record it with books.mark_sent (the recipient, how it went, and the channel's reference when there is one).", + invoice.id + ), + tool: Some(( + "books.mark_sent".into(), + serde_json::json!({ "invoice": format!("invoice:{}", invoice.id) }), + )), + }); + } + if state.overdue_days > 0 + && let Some(last) = last_sent + && today.signed_duration_since(last).num_days() > i64::from(reminder_days) + { + let days = usize::try_from(state.overdue_days).unwrap_or(usize::MAX); + out.push(Attention { + severity: Severity::Todo, + kind: "invoice_not_reminded", + title: format!("Invoice {label} is overdue: remind the customer"), + detail: format!( + "Due {}, {} overdue; last sent {}. A reminder is due after {reminder_days} days.", + date(invoice.due_date), + count(days, "day", "days"), + date(last) + ), + target: Target::Invoice(invoice.id), + prompt: format!( + "In wanderbooks, invoice {label} (invoice:{}) is overdue and was last sent {}. Draft a reminder to the customer, then record it with books.mark_sent as a reminder (the day and the channel's reference).", + invoice.id, + date(last) + ), + tool: Some(( + "books.mark_sent".into(), + serde_json::json!({ "invoice": format!("invoice:{}", invoice.id) }), + )), + }); + } + } + for a in ledger.chart().all().filter(|a| { a.account_type == AccountType::Liability && { let n = a.name.to_lowercase(); diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 500845d..c35b42e 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -243,6 +243,7 @@ pub const EVENT_TYPES: &[&str] = &[ "fx.rate_set", "invoice.created", "invoice.payment_received", + "invoice.sent", "instrument.added", FORMAT_SET, "journal.tail_repaired", diff --git a/crates/ledger-store/src/meta.rs b/crates/ledger-store/src/meta.rs index 8205453..225ff92 100644 --- a/crates/ledger-store/src/meta.rs +++ b/crates/ledger-store/src/meta.rs @@ -14,7 +14,9 @@ //! ``` use crate::error::StoreError; +use crate::journal::JournalLine; use chrono::NaiveDate; +use ledger_core::arap::{SendKind, Sent, SentVia}; use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem}; use ledger_core::settings::{Change, Source}; use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus}; @@ -361,6 +363,61 @@ pub fn entry_approved_of(p: &Value, seq: u64) -> Result<(u64, Approval), StoreEr )) } +/// A list of strings, each checked (`to` on `invoice.sent`). +fn str_list(p: &Value, event: &str, k: &str) -> Result, StoreError> { + p.get(k) + .and_then(Value::as_array) + .ok_or_else(|| corrupt(event, k))? + .iter() + .map(|v| { + v.as_str() + .map(str::to_string) + .ok_or_else(|| corrupt(event, k)) + }) + .collect() +} + +/// `invoice.sent {invoice_id, on, by, via, to, kind, reference?, note?}` +/// (plan P4b S5): a send is a fact of the document's life and moves no +/// figure. +pub fn invoice_sent(invoice: u64, s: &Sent) -> Value { + let mut o = Map::new(); + o.insert("invoice_id".into(), json!(invoice)); + o.insert("on".into(), json!(s.on.to_string())); + o.insert("by".into(), json!(s.by)); + o.insert("via".into(), json!(s.via.key())); + o.insert("to".into(), json!(s.to)); + o.insert("kind".into(), json!(s.kind.key())); + put(&mut o, "reference", s.reference.clone()); + put(&mut o, "note", s.note.clone()); + Value::Object(o) +} + +/// Read an `invoice.sent`: `(invoice, send)`. A channel or kind this +/// version doesn't know is newer, never corrupt (rules.md B-27). +pub fn invoice_sent_of(line: &JournalLine) -> Result<(u64, Sent), StoreError> { + const E: &str = "invoice.sent"; + let p = &line.payload; + let via = str_of(p, E, "via")?; + let kind = str_of(p, E, "kind")?; + Ok(( + u64_of(p, E, "invoice_id")?, + Sent { + on: date_of(&str_of(p, E, "on")?, E, "on")?, + by: str_of(p, E, "by")?, + via: SentVia::parse(&via).ok_or_else(|| { + crate::format::newer_content(line, format!("the send channel {via:?}")) + })?, + to: str_list(p, E, "to")?, + kind: SendKind::parse(&kind).ok_or_else(|| { + crate::format::newer_content(line, format!("the send kind {kind:?}")) + })?, + reference: opt_str(p, E, "reference")?, + note: opt_str(p, E, "note")?, + }, + )) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index c885425..c90c065 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 16; +pub const SCHEMA_VERSION: u32 = 17; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -417,5 +417,22 @@ ALTER TABLE bills ADD COLUMN received_on TEXT; ALTER TABLE bills ADD COLUMN terms TEXT; ALTER TABLE bills ADD COLUMN memo TEXT; ALTER TABLE bills ADD COLUMN duplicate_reason TEXT; +"#, + r#" +-- v17: sent tracking (plan P4b S5, `invoice.sent`): one row per recorded +-- send of an invoice, in the order journaled. A send moves no figure and +-- no ledger line, so this table is only the projection's copy of the +-- journal's record (parity with replay). `on` and `by` are quoted: `on` +-- is an SQL keyword. +CREATE TABLE IF NOT EXISTS invoice_sends( + invoice_id INTEGER NOT NULL, + "on" TEXT NOT NULL, + "by" TEXT NOT NULL, + via TEXT NOT NULL, + to_json TEXT NOT NULL, + kind TEXT NOT NULL, + reference TEXT NULL, + note TEXT NULL +); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 624db3c..c3f6945 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -3756,6 +3756,36 @@ impl Store { self.reverse_entry(target, date, &description) } + /// Record that an invoice went out ([`Ledger::mark_sent`], plan P4b + /// S5): journaled as `invoice.sent` (feature `documents_v2`: an older + /// version refuses the type), then applied to the books. A send moves + /// no figure, so it journals one line and no entry. + /// + /// ```text + /// GOAL: a send is journaled once and replays to the same record, in + /// every version that can read the books. + /// SOURCE: docs/spec/rules.md B-27, B-38; plan P4b S5, A-4. + /// SCENARIOS: + /// 1. IF the engine refuses it (unknown or void invoice, a date + /// before the invoice, no recipients, a text the books won't + /// keep): SHOULD refuse, journaling nothing. + /// 2. IF the books haven't declared documents_v2: SHOULD need the + /// feature, journaling nothing. + /// ``` + pub fn mark_sent( + &mut self, + invoice_id: InvoiceId, + sent: ledger_core::arap::Sent, + ) -> Result<(), StoreError> { + self.ensure_feature(crate::format::DOCUMENTS_V2)?; + self.rehearse(|l| l.mark_sent(invoice_id, sent.clone()))?; + let line = + self.append_journal("invoice.sent", crate::meta::invoice_sent(invoice_id, &sent))?; + self.ledger.mark_sent(invoice_id, sent)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + // ------------------------------------------------------------------ // Foreign exchange // ------------------------------------------------------------------ @@ -7037,6 +7067,11 @@ fn replay_line( parse_bill_fields(p)?, )?; } + "invoice.sent" => { + // A send moves no figure: it only restores the invoice's sends. + let (invoice_id, sent) = crate::meta::invoice_sent_of(line)?; + ledger.replay_invoice_sent(invoice_id, sent)?; + } "invoice.payment_received" => { // The payment entry was already rebuilt; this only moves the // invoice's paid amount and status. @@ -8019,6 +8054,26 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr ], )?; } + // A send is a document's own record, not a ledger line (plan P4b + // S5): one row each, for the projection to agree with replay. + "invoice.sent" => { + let to = p + .get("to") + .ok_or_else(|| StoreError::JournalCorrupt("invoice.sent without to".into()))?; + conn.execute( + "INSERT INTO invoice_sends(invoice_id, \"on\", \"by\", via, to_json, kind, reference, note) VALUES (?,?,?,?,?,?,?,?)", + params![ + get_u64(p, "invoice_id")? as i64, + get_str(p, "on")?, + get_str(p, "by")?, + get_str(p, "via")?, + to.to_string(), + get_str(p, "kind")?, + p.get("reference").and_then(|v| v.as_str()), + p.get("note").and_then(|v| v.as_str()), + ], + )?; + } // A payment moves no document row: what a document is paid is read // from the ledger lines tagged with it (schema v15, rules.md B-38). "invoice.payment_received" => {} diff --git a/crates/ledger-store/tests/arap_props.rs b/crates/ledger-store/tests/arap_props.rs index a014f20..89cda5d 100644 --- a/crates/ledger-store/tests/arap_props.rs +++ b/crates/ledger-store/tests/arap_props.rs @@ -64,6 +64,9 @@ enum Op { Reverse(usize, u8), /// Void document `k` on day `n`. Void(usize, u8), + /// Record a send of document `k` (an invoice; a bill is skipped) on + /// day `n`, a reminder after its first send. A send moves no figure. + MarkSent(usize, u8), /// Close month `m` (January to March), in order. Close(u8), /// A hand line on receivables (true) or payables: always refused. @@ -77,6 +80,7 @@ fn arb_op() -> impl Strategy { .prop_map(|(k, n, c, card, r)| Op::Pay(k, n, c, card, r)), 3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)), 1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)), + 2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)), 1 => (1u8..4).prop_map(Op::Close), 1 => (any::(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)), ] @@ -173,6 +177,35 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec) -> Result<( Op::Void(i, n) => s .void_document(docs[i % docs.len()], day(n), "duplicate") .map(drop), + Op::MarkSent(..) if docs.is_empty() => Ok(()), + Op::MarkSent(i, n) => match docs[i % docs.len()] { + // Bills get no sends: we receive them (plan P4b S5). + DocRef::Bill(_) => Ok(()), + DocRef::Invoice(id) => { + let kind = if s + .ledger() + .get_invoice(id) + .is_some_and(|i| !i.sends.is_empty()) + { + ledger_core::arap::SendKind::Reminder + } else { + ledger_core::arap::SendKind::Original + }; + s.mark_sent( + id, + ledger_core::arap::Sent { + on: day(n), + by: "agent".into(), + via: ledger_core::arap::SentVia::Email, + to: vec!["billing@fernhill.example".into()], + kind, + reference: None, + note: None, + }, + ) + .map(drop) + } + }, Op::Close(m) => { let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap(); let end = start + chrono::Months::new(1) - chrono::Days::new(1); diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index eec3711..18b4cb5 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -12,8 +12,8 @@ use chrono::NaiveDate; use ledger_core::LedgerError; use ledger_core::arap; -use ledger_core::arap::DocumentLine; -use ledger_core::{AccountType, Currency, Money, Side}; +use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia}; +use ledger_core::{AccountType, Currency, DocRef, Money, Side}; use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject}; use rust_decimal::Decimal; use serde_json::{Value, json}; @@ -1054,3 +1054,190 @@ fn preview_invoice_and_bill_journal_nothing() { ); s.verify().unwrap(); } + +/// A send, as these tests record one (plan P4b S5, A-4). +fn sent(on: NaiveDate, kind: SendKind, reference: Option<&str>) -> Sent { + Sent { + on, + by: "agent".into(), + via: SentVia::Email, + to: vec!["billing@fernhill.example".into()], + kind, + reference: reference.map(str::to_string), + note: None, + } +} + +/// R-A step 3 (plan P4b S5): `invoice.sent` is `documents_v2`'s, so books +/// that haven't declared it refuse a send, journaling nothing; after the +/// owner's go-ahead it journals, and the reopened books hold it. +#[test] +fn mark_sent_waits_for_documents_v2() { + let dir = test_dir("sent-waits"); + let mut s = Store::create_in_format( + &dir.0, + Currency::USD, + &ledger_store::JournalFormat::default(), + ) + .unwrap(); + let c = s.add_customer("Fernhill Labs").unwrap(); + let inv = s + .create_invoice( + c, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("Design", 10_000)], + ) + .unwrap(); + let head = s.head(); + let from = journal_len(&dir.0); + let one = sent(d(2026, 3, 2), SendKind::Original, None); + match s.mark_sent(inv, one.clone()) { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature documents_v2") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + assert_eq!(s.head(), head, "a refusal journals nothing"); + assert_eq!(journal_len(&dir.0), from); + assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead"); + s.mark_sent(inv, one).unwrap(); + let live = s.ledger().clone(); + drop(s); + let reopened = Store::open(&dir.0).unwrap(); + reopened.verify().unwrap(); + assert!( + live.differs_from(reopened.ledger()).is_empty(), + "{:?}", + live.differs_from(reopened.ledger()) + ); + assert_eq!(reopened.ledger().get_invoice(inv).unwrap().sends.len(), 1); +} + +/// R-A and R-B (plan P4b S5): a send journals one `invoice.sent` line with +/// the whole record, replays into the invoice, and projects into +/// `invoice_sends`; two sends keep their order. +#[test] +fn sends_replay_and_project() { + let dir = test_dir("sent-replay"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let c = s.add_customer("Fernhill Labs").unwrap(); + let inv = s + .create_invoice( + c, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("Design", 10_000)], + ) + .unwrap(); + let from = journal_len(&dir.0); + let original = sent(d(2026, 3, 2), SendKind::Original, None); + let reminder = sent(d(2026, 3, 20), SendKind::Reminder, Some("msg-2")); + s.mark_sent(inv, original.clone()).unwrap(); + s.mark_sent(inv, reminder.clone()).unwrap(); + let got = since(&dir.0, from); + assert_eq!(got.len(), 2, "{got:?}"); + assert_eq!(got[0].0, "invoice.sent"); + assert_eq!(got[0].1["invoice_id"], json!(inv)); + assert_eq!(got[0].1["on"], json!("2026-03-02")); + assert_eq!(got[0].1["by"], json!("agent")); + assert_eq!(got[0].1["via"], json!("email")); + assert_eq!(got[0].1["to"], json!(["billing@fernhill.example"])); + assert_eq!(got[0].1["kind"], json!("original")); + assert_eq!(got[0].1.get("reference"), None, "absent, not null"); + assert_eq!(got[1].1["kind"], json!("reminder")); + assert_eq!(got[1].1["reference"], json!("msg-2")); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + let reopened = Store::open(&dir.0).unwrap(); + reopened.verify().unwrap(); + assert!( + live.differs_from(reopened.ledger()).is_empty(), + "{:?}", + live.differs_from(reopened.ledger()) + ); + assert_eq!( + reopened.ledger().get_invoice(inv).unwrap().sends, + vec![original, reminder] + ); +} + +/// A send changes no figure (plan P4b S5, B-38): on the fixture book an +/// earlier version wrote, every report, every derived document figure and +/// the invoice's whole DocState are the same before and after it. +#[test] +fn a_send_changes_no_figure() { + let dir = fixture("sent-figure"); + let mut s = Store::open(&dir.0).unwrap(); + s.upgrade_format().unwrap(); + let before = ledger_core::reports::snapshot(s.ledger()).unwrap(); + let state_before = s.ledger().invoice_state(1, d(9999, 12, 31)).unwrap(); + let cached_before: Vec<_> = s + .ledger() + .invoices() + .into_iter() + .map(|i| (i.id, i.amount_paid, i.owed, i.status)) + .collect(); + s.mark_sent(1, sent(d(2026, 4, 1), SendKind::Original, None)) + .unwrap(); + assert_eq!( + ledger_core::reports::snapshot(s.ledger()).unwrap(), + before, + "a send moves no report" + ); + assert_eq!( + s.ledger().invoice_state(1, d(9999, 12, 31)).unwrap(), + state_before, + "a send moves no DocState" + ); + assert_eq!( + s.ledger() + .invoices() + .into_iter() + .map(|i| (i.id, i.amount_paid, i.owed, i.status)) + .collect::>(), + cached_before, + "and no derived cache" + ); + assert_eq!( + trial_balance_json(&s), + golden()["trial_balance"], + "the golden's general ledger, a send included" + ); + assert_eq!(s.ledger().get_invoice(1).unwrap().sends.len(), 1); + s.verify().unwrap(); +} + +/// A void invoice is refused a send (plan P4b S5, `DocumentVoid`), +/// journaling nothing; an invoice open on the send's date but voided after +/// it keeps the send that happened first. +#[test] +fn mark_sent_on_void_refused() { + let dir = test_dir("sent-void"); + let mut s = Store::create(&dir.0, Currency::USD).unwrap(); + let c = s.add_customer("Fernhill Labs").unwrap(); + let inv = s + .create_invoice( + c, + d(2026, 3, 1), + d(2026, 3, 31), + vec![line("Design", 10_000)], + ) + .unwrap(); + s.void_document(DocRef::Invoice(inv), d(2026, 3, 10), "duplicate") + .unwrap(); + let head = s.head(); + let from = journal_len(&dir.0); + match s.mark_sent(inv, sent(d(2026, 3, 11), SendKind::Original, None)) { + Err(StoreError::Ledger(LedgerError::DocumentVoid(id))) => assert_eq!(id, inv), + other => panic!("expected DocumentVoid, got {other:?}"), + } + assert_eq!(s.head(), head, "a refusal journals nothing"); + assert_eq!(journal_len(&dir.0), from); + // Dated before the void, the send was a real one: it replays. + s.mark_sent(inv, sent(d(2026, 3, 5), SendKind::Original, None)) + .unwrap(); + assert_eq!(s.ledger().get_invoice(inv).unwrap().sends.len(), 1); + s.verify().unwrap(); +} diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml index 061f21f..f426613 100644 --- a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml +++ b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml @@ -7,6 +7,8 @@ # cargo run --bin extras -- OUT (the edges of chrono and Decimal) # cargo run --bin extras2 -- OUT (depreciation for 10000-01; 1,044 drafts) # cargo run --bin older -- OUT (x_older_rules, with its JournalWriter) +# cargo run --bin s5_before -- OUT (p4_s5_before: run against eeee9658, +# the revision before P4b S5) # cargo run --bin p2_1099 -- OUT (p2_vendor_1099: P2's 1099 marks, run # against 9c99a0cf, the revision before P3) # cargo run --bin expect -- OUT (expected.json beside each journal) diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/s5_before.rs b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/s5_before.rs new file mode 100644 index 0000000..41ca96e --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/s5_before.rs @@ -0,0 +1,65 @@ +//! Writes `p4_s5_before`: books the version before P4b S5 (main eeee9658) +//! wrote through its Store API: a chart, a customer's invoice part paid, +//! and a vendor's bill. No send exists in that version; this version opens +//! the same journal and must report the same figures. +use chrono::NaiveDate; +use ledger_core::{AccountType, Currency, DocRef, DocumentLine, Money, Side}; +use ledger_store::Store; +use rust_decimal::Decimal; +use std::path::PathBuf; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} +fn line(what: &str, c: i64) -> DocumentLine { + DocumentLine { + description: what.into(), + amount: cents(c), + class_id: None, + account: None, + quantity: None, + unit_price: None, + } +} + +fn main() { + let root = PathBuf::from(std::env::args().nth(1).unwrap()); + let dir = root.join("p4_s5_before"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let bank = s + .add_account("1000", "Bank", AccountType::Asset, None, false) + .unwrap(); + let capital = s + .add_account("3000", "Capital", AccountType::Equity, None, false) + .unwrap(); + let seed = s.create_entry(d(2026, 1, 1), "Opening capital").unwrap(); + let m = Money::new(Decimal::from(1000), Currency::USD); + s.add_line(seed, bank, Side::Debit, m).unwrap(); + s.add_line(seed, capital, Side::Credit, m).unwrap(); + s.post_entry(seed).unwrap(); + let fernhill = s.add_customer("Fernhill Labs").unwrap(); + let vendor = s.add_vendor("Tobin Harlow Supply").unwrap(); + let inv = s + .create_invoice( + fernhill, + d(2026, 2, 1), + d(2026, 3, 3), + vec![line("Design", 120_000)], + ) + .unwrap(); + s.receive_payment(inv, d(2026, 2, 15), cents(40_000), bank) + .unwrap(); + s.create_bill( + vendor, + d(2026, 2, 10), + d(2026, 3, 12), + vec![line("Drafting", 30_000)], + ) + .unwrap(); + println!("WROTE {:?} (invoice {inv}, {:?})", dir, DocRef::Invoice(inv)); +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s5_before/expected.json b/crates/ledger-store/tests/fixtures/old-books/p4_s5_before/expected.json new file mode 100644 index 0000000..7f30b82 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s5_before/expected.json @@ -0,0 +1,265 @@ +{ + "assets": [], + "bills": [ + { + "amount_paid": "0", + "date": "2026-02-10", + "due_date": "2026-03-12", + "entry_id": 4, + "id": 1, + "lines": [ + { + "amount": "300.00", + "description": "Drafting" + } + ], + "status": "Open", + "vendor_id": 1 + } + ], + "entries": [ + { + "date": "2026-01-01", + "description": "Opening capital", + "id": 1, + "lines": [ + { + "account": 1, + "amount": { + "amount": "1000", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "1000", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-01", + "description": "Invoice 1", + "id": 2, + "lines": [ + { + "account": 3, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 4, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-15", + "description": "Payment on invoice 1", + "id": 3, + "lines": [ + { + "account": 1, + "amount": { + "amount": "400.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "400.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-10", + "description": "Bill 1", + "id": 4, + "lines": [ + { + "account": 5, + "amount": { + "amount": "300.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 6, + "amount": { + "amount": "300.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + } + ], + "entry_parties": [ + [ + 2, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 3, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 4, + [ + { + "party_id": 2, + "share": "derived" + } + ] + ] + ], + "invoices": [ + { + "amount_paid": "400.00", + "customer_id": 1, + "date": "2026-02-01", + "due_date": "2026-03-03", + "entry_id": 2, + "id": 1, + "lines": [ + { + "amount": "1200.00", + "description": "Design" + } + ], + "status": "Partial" + } + ], + "payroll_runs": [], + "periods": [], + "recurring": [], + "schedules": [], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "2200", + "balanced": true, + "current_earnings": "900", + "equity": "1900", + "liabilities": "300" + }, + "profit_and_loss": { + "expenses": "300", + "net_income": "900", + "revenue": "1200" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "0", + "debit": "1400", + "name": "Bank" + }, + { + "code": "1200", + "credit": "0", + "debit": "800", + "name": "Accounts Receivable" + }, + { + "code": "2000", + "credit": "300", + "debit": "0", + "name": "Accounts Payable" + }, + { + "code": "3000", + "credit": "1000", + "debit": "0", + "name": "Capital" + }, + { + "code": "4000", + "credit": "1200", + "debit": "0", + "name": "Revenue" + }, + { + "code": "6999", + "credit": "0", + "debit": "300", + "name": "General Expenses" + } + ], + "total_credits": "2500", + "total_debits": "2500" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s5_before/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p4_s5_before/journal.jsonl new file mode 100644 index 0000000..bb854c7 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s5_before/journal.jsonl @@ -0,0 +1,29 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-27T12:07:38Z","type":"store.created"} +{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"0bbd745853ef336fa261af81109c83ac2b5c62be3d9243eecbd7d1abc4810afc","seq":2,"ts":"2026-10-27T12:07:38Z","type":"journal.format_set"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"5b4b18ca6b1e3e4dd2dd8d2ba522d2ec8ffdf3cd2c10472092983453c5675472","seq":3,"ts":"2026-10-27T12:07:38Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"c0ab8146ae450a9204c1f2dd338f49330900ec2c4edd29bda362ce5c73665275","seq":4,"ts":"2026-10-27T12:07:38Z","type":"account.added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"ade211d7ae85ac8a12866d529418cba0785290e559183a0700dd91c95a7d41b0","seq":5,"ts":"2026-10-27T12:07:38Z","type":"entry.created"} +{"payload":{"account":1,"amount":"1000","currency":"USD","id":1,"side":"debit"},"prev":"0b1c4e86907d5b19faed67cba9633120de6726217fec3602930e09cb1c3ac6e1","seq":6,"ts":"2026-10-27T12:07:38Z","type":"entry.line_added"} +{"payload":{"account":2,"amount":"1000","currency":"USD","id":1,"side":"credit"},"prev":"f300e939ff3967ad7b04c781ec6ec0c2790cbfb938a1ad57d8428239f3c05ecb","seq":7,"ts":"2026-10-27T12:07:38Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000","currency":"USD","side":"debit"},{"account":2,"amount":"1000","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4c31e09e179aa377d03f09ff07298a94f7bcdb5463b9a56e7d9ec1649fa9b859","seq":8,"ts":"2026-10-27T12:07:38Z","type":"entry.posted"} +{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"88a5469ed669976c63355000e7ce6b358bc3d8731527da98f45833ae28cd04f8","seq":9,"ts":"2026-10-27T12:07:38Z","type":"customer.added"} +{"payload":{"id":1,"name":"Tobin Harlow Supply"},"prev":"84f6fd180671d63e367eaa0465867732877995d282996446ed2514e2b164f06d","seq":10,"ts":"2026-10-27T12:07:38Z","type":"vendor.added"} +{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":3,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"af015074dc5235c7efd9df38e447866e46c5d552d049f07ceec7f5cc94655770","seq":11,"ts":"2026-10-27T12:07:38Z","type":"account.added"} +{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Revenue","parent":null,"placeholder":false},"prev":"a17b19e0d8229d378f2cf950079ff6495b21799ecc0b4608de72853d62d5eaf2","seq":12,"ts":"2026-10-27T12:07:39Z","type":"account.added"} +{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2},"prev":"7e1bf5a0142e15f2c4471f6dd413e428586673cdf5ce94d46e40bb951d487423","seq":13,"ts":"2026-10-27T12:07:39Z","type":"entry.created"} +{"payload":{"account":3,"amount":"1200.00","currency":"USD","id":2,"side":"debit"},"prev":"0b0d2bc9815cac0aaf428d114031cd18d6c133cb14aff6e374324b9a973fe50f","seq":14,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"} +{"payload":{"account":4,"amount":"1200.00","currency":"USD","id":2,"side":"credit"},"prev":"fa319df9f4858df95f47b4035c0f83901674dca7fbc5978b820a8d93bd27e196","seq":15,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2,"lines":[{"account":3,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e0cbb496c5116f927a0d2d897c651bf7df706af670d44c468703a81cdbb6a598","seq":16,"ts":"2026-10-27T12:07:39Z","type":"entry.posted"} +{"payload":{"customer_id":1,"date":"2026-02-01","due_date":"2026-03-03","entry_id":2,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"72eee837c1eee71d7b45bc63e840224fe85d4f08f423b7cdadd5431d2e0c730c","seq":17,"ts":"2026-10-27T12:07:39Z","type":"invoice.created"} +{"payload":{"date":"2026-02-15","description":"Payment on invoice 1","id":3},"prev":"915870654047decc4b93972fb04bd76bf8ea65b5710f58a866d3661c0a3c09f5","seq":18,"ts":"2026-10-27T12:07:39Z","type":"entry.created"} +{"payload":{"account":1,"amount":"400.00","currency":"USD","id":3,"side":"debit"},"prev":"433c71f0884859468a8b1e48038b807bc9f910e23e22bd646518913287f5a1d6","seq":19,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"} +{"payload":{"account":3,"amount":"400.00","currency":"USD","id":3,"side":"credit"},"prev":"86db1b12b1c6585ed1fadbee2543d33e0adede5e9a02f0d36626a6f8acc2179a","seq":20,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-15","description":"Payment on invoice 1","id":3,"lines":[{"account":1,"amount":"400.00","currency":"USD","side":"debit"},{"account":3,"amount":"400.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c4a0a4fb8495af3b43b2b5d5e9602067998028b65d42a301e407f37017d6e6b0","seq":21,"ts":"2026-10-27T12:07:39Z","type":"entry.posted"} +{"payload":{"amount":"400.00","cash_account":1,"date":"2026-02-15","entry_id":3,"invoice_id":1},"prev":"5a29d6bff70696aeb707eb69fac9ceaffb7c5167e10434b23aa18ba9aeb4b8ff","seq":22,"ts":"2026-10-27T12:07:39Z","type":"invoice.payment_received"} +{"payload":{"account_type":"Expense","code":"6999","currency":"USD","id":5,"name":"General Expenses","parent":null,"placeholder":false},"prev":"2dc14a4d6d23f42a34c1172475ac86b9706be79ee17ba9bfd4041fde99c43231","seq":23,"ts":"2026-10-27T12:07:39Z","type":"account.added"} +{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts Payable","parent":null,"placeholder":false},"prev":"4a44d6570b6b9e1f3b705b383d41939aab2952d32902ab151f2b4d02ad3c1237","seq":24,"ts":"2026-10-27T12:07:39Z","type":"account.added"} +{"payload":{"date":"2026-02-10","description":"Bill 1","id":4},"prev":"bd11681484634f272371a9245cfabdff52024ffe516380143b650275b9af034a","seq":25,"ts":"2026-10-27T12:07:39Z","type":"entry.created"} +{"payload":{"account":5,"amount":"300.00","currency":"USD","id":4,"side":"debit"},"prev":"3df6859cd10b0f3ec4a50ff2abcfd619291db2c290e3d844399d0434be31754c","seq":26,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"} +{"payload":{"account":6,"amount":"300.00","currency":"USD","id":4,"side":"credit"},"prev":"86f685ccc47f69411c2cd424017510d10823f05ab3c9018fbf636ff15455b548","seq":27,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-10","description":"Bill 1","id":4,"lines":[{"account":5,"amount":"300.00","currency":"USD","side":"debit"},{"account":6,"amount":"300.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c25fce42aa1755ee4e915191e4903f13874e2ea327bfd915ba39df474a04ca4e","seq":28,"ts":"2026-10-27T12:07:39Z","type":"entry.posted"} +{"payload":{"date":"2026-02-10","due_date":"2026-03-12","entry_id":4,"id":1,"lines":[{"amount":"300.00","description":"Drafting"}],"vendor_id":1},"prev":"55ec1c5c73e4ac37996b917c87d6bc9a841ce1eb43ac57882d2ca9a1fdf4820a","seq":29,"ts":"2026-10-27T12:07:39Z","type":"bill.created"} diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index ecc8cf6..608142b 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -16,8 +16,9 @@ //! ``` use chrono::NaiveDate; +use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia}; use ledger_core::{AccountType, Currency, Money, Side}; -use ledger_store::format::{EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES}; +use ledger_store::format::{DOCUMENTS_V2, EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES}; use ledger_store::journal::{canonical_json, sha256_hex}; use ledger_store::{ JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store, @@ -1468,3 +1469,83 @@ fn the_replay_fuzzer_writes_every_event_type() { "the replay fuzzer never writes {missing:?}" ); } + +/// Done-when (plan P4b S5): the send event is this version's, it needs +/// `documents_v2`, and the books declare that feature before the first +/// `invoice.sent` line, so a version without it stops at the declaration +/// and reports the books as newer, never corrupt (rules.md B-27). +#[test] +fn an_older_version_stops_at_invoice_sent() { + assert!(EVENT_TYPES.contains(&"invoice.sent")); + let older = JournalFormat { + format: SUPPORTED_JOURNAL_FORMAT, + features: KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != DOCUMENTS_V2) + .map(str::to_string) + .collect(), + }; + assert!( + matches!( + older.require_feature(DOCUMENTS_V2), + Err(StoreError::FormatUpgradePending { .. }) + ), + "a reader without documents_v2 waits for it" + ); + let dir = test_dir("sent-gate"); + let mut store = + Store::create_in_format(&dir, Currency::USD, &JournalFormat::default()).unwrap(); + let customer = store.add_customer("Fernhill Labs").unwrap(); + let date = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(); + let inv = store + .create_invoice( + customer, + date, + date + chrono::Days::new(30), + vec![DocumentLine { + description: "Design".into(), + amount: Decimal::new(10_000, 2), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }], + ) + .unwrap(); + store.upgrade_format().unwrap().expect("the go-ahead"); + store + .mark_sent( + inv, + Sent { + on: date, + by: "agent".into(), + via: SentVia::Email, + to: vec!["billing@fernhill.example".into()], + kind: SendKind::Original, + reference: None, + note: None, + }, + ) + .unwrap(); + let journal = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap(); + let mut declared = false; + let mut sent = false; + for line in journal.lines() { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + if v["type"] == FORMAT_SET + && v["payload"]["features"] + .as_array() + .is_some_and(|f| f.iter().any(|x| x == DOCUMENTS_V2)) + { + declared = true; + } + if v["type"] == "invoice.sent" { + assert!(declared, "documents_v2 must be declared before the send"); + sent = true; + } + } + assert!(sent); + drop(store); + Store::open(&dir).unwrap().verify().unwrap(); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index ded2c12..f212c66 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1957,6 +1957,15 @@ fn books_written_under_older_rules_still_open() { "memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}), ) .unwrap(); + // P4b S5's new shape: the record that the invoice went out + // (`invoice.sent`, feature documents_v2). A send moves no figure, so + // the document's figures are the same before and after it. + w.append( + "invoice.sent", + json!({"invoice_id": 1, "on": "2026-04-05", "by": "Avery Quill", "via": "email", + "to": ["billing@fernhill.example"], "kind": "original", "reference": "msg-1"}), + ) + .unwrap(); drop(w); // Opening rebuilds from the journal; it must accept all of it. let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); @@ -2023,6 +2032,14 @@ fn books_written_under_older_rules_still_open() { ); assert_eq!(bill.memo.as_deref(), Some("A4 paper")); assert_eq!(bill.lines[0].account, Some(6)); + // P4b S5's send replays as written, and moves no figure. + let sends = &store.ledger().get_invoice(1).expect("the invoice").sends; + assert_eq!(sends.len(), 1); + assert_eq!(sends[0].on, NaiveDate::from_ymd_opt(2026, 4, 5).unwrap()); + assert_eq!(sends[0].by, "Avery Quill"); + assert_eq!(sends[0].to, ["billing@fernhill.example"]); + assert_eq!(sends[0].kind, ledger_core::arap::SendKind::Original); + assert_eq!(sends[0].reference.as_deref(), Some("msg-1")); } /// The books an earlier wanderbooks wrote, one directory each under diff --git a/docs/architecture.md b/docs/architecture.md index 0243c09..17bd94c 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -169,6 +169,7 @@ groups are: - `documents` — invoices, bills, payments, voids, `books.documents`, `books.document`, the control check and the agings; +- `sent` — `books.mark_sent`, the record that an invoice went out (P4b S5); - `entries` — `books.draft_entry`, `books.add_line`, `books.post_entry`, `books.reverse_entry`, `books.get_entry`, `books.search_entries`, the bank CSV import; diff --git a/docs/mcp-interface.md b/docs/mcp-interface.md index 950549e..0d39e21 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2107,9 +2107,24 @@ snapshots; see plan 8.2 S4. number and reads `void`. Refused while a payment stands on it (the refusal names the payment entry to reverse first), when void already, before its date or in a closed period (P4b S1, B-38). +- `books.mark_sent { invoice, on?, by?, via?, recipients?: [string], + kind?, reference?, note? }` → `{ invoice, sends: [{ on, by, via, to, kind, + reference?, note? }] }`. Records that an invoice went out (P4b S5, + A-4): the books never send, so the owner or a delegate sends and this + records who (`by`: the token's name unless given), how (`via`: email + the default, or mail, portal, hand, another channel's name), to whom + (`recipients`: the invoice's bill-to email, else the customer's, else + required), when (`on`: today in the company's time zone), original or + reminder (`kind`: original on the first send, reminder after), and the + channel's own id (`reference`: a message id). `invoice` is + `invoice:N` or `N`. Several sends are allowed; none moves a figure or + the due date. Refused on a void invoice, dated before it, with no + recipients, or while the books wait for `documents_v2` + (`format_upgrade_pending`). `sends` is every send on the invoice, in + order, the new one last. - `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of, kind, documents: [{ ref, party, date, due_date, total, outstanding, - status, overdue_days }], outstanding_total }`. Invoices (`kind` + status, overdue_days, last_sent? }], outstanding_total }`. Invoices (`kind` `invoices`) or bills (`bills`), due date first, every figure derived from the document's lines that stand (B-38). `status` filters: `open` (default: anything still owed, partly paid and overdue included), @@ -2117,11 +2132,14 @@ snapshots; see plan 8.2 S4. own `status` is one of `open`, `partial`, `paid`, `written_off`, `void`, `overpaid` or `unattached` (`DocStatus::key()`). `party` (a customer's or vendor's name, ignoring case) and `due_by` (due on or - before that day) narrow the list (P4b S2). + before that day) narrow the list (P4b S2). `last_sent` is the day the + invoice last went out, when a send is recorded (P4b S5); absent for an + invoice never sent and for every bill. - `books.document { ref, as_of?, pdf? }` → `{ ref, party, date, due_date, entry_id, lines: [{ description, amount, project? }], total, paid, discounted, written_off, outstanding, status, overdue_days, timeline: - [{ date, entry_id, kind, amount, reversal, stands }], pdf? }`. `ref` is + [{ date, entry_id, kind, amount, reversal, stands }], sends: [{ on, by, + via, to, kind, reference?, note? }], pdf? }`. `ref` is `invoice:N` or `bill:N`; `status` as `books.documents`' rows; the timeline is every ledger line tagged with the document, dated on or before the day, in posting order, each saying whether its entry still diff --git a/docs/parity.md b/docs/parity.md index a178871..0102219 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -45,6 +45,7 @@ No other statuses. | 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet | | Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet | | Automated cross-implementation diff harness (GnuCash first) | — (`just oracle`) | partial | live: `tests/oracle_gnucash.rs` runs 200 random books through GnuCash 5.10 in a pinned container, 15,800 exact comparisons, weekly in CI (docs/oracle.md Layer 5); open: fixture books diffed as `snapshot()` output, and the machine-readable catalog with CI enforcing this floor | +| Invoice sent tracking (who, how, to whom, when, original or reminder, the channel's reference) | Business → Invoices (Xero's "Sent"; QBO's email) | done | P4b S5; records, never sends (A-4); a send changes no figure; `invoice.sent` under `documents_v2` (D-52) | Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the egui phase starts; semantics come first. @@ -135,3 +136,18 @@ door on loopback for Caddy or a tunnel. `crates/ledger-cli/tests/two_user.rs` proves the account boundary and the move's head in CI (`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`. The cutover rule in `docs/capability-parity.md` is unchanged. + +### P4b S5: sent tracking (2026-10-27) + +The catalog gains one row: an invoice's recorded sends. The books never +send (A-4): `books.mark_sent` records who sent an invoice, how, to whom, +when, whether it was the original or a reminder, and the channel's own +reference, for an owner or a delegate such as an agent. The record is +`invoice.sent`, under `documents_v2` (D-52), and it moves no figure, so +the A/R and A/P rows' figures are unchanged. The floor never decreases: +the row is `done`, tested by `sends_replay_and_project`, +`a_send_changes_no_figure` and `mark_sent_records_who_and_how`. Sent +tracking also feeds Needs attention: an issued invoice nobody marked sent +(past `invoicing.unsent_days`) and an overdue invoice not reminded within +`invoicing.reminder_days` are one list in the viewer and in `books.start`, +each offering `books.mark_sent` (step 4; U0b's one engine). diff --git a/docs/receipt-store.md b/docs/receipt-store.md index 55751b9..0627fff 100644 --- a/docs/receipt-store.md +++ b/docs/receipt-store.md @@ -58,8 +58,10 @@ apply is safe — on startup the applier replays every journal line with - `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders). - `type`: `account.added`, `entry.posted`, `entry.reversed`, `invoice.created`, `bill.created`, `receipt.attached`, `receipt.linked`, `receipt.unlinked`, - `document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset, - period, payroll, and class events listed on `JournalLine`). + `document.filed`, `attachment.withdrawn`, `invoice.sent` (a recorded + send: a document's record, never a ledger line or a figure; P4b S5) + (plus the A/R, A/P, asset, period, payroll, and class events listed on + `JournalLine`). - `payload`: the full domain facts (account fields, entry with lines, blob sha, ...). Everything the applier needs; nothing it must look up elsewhere. An `invoice.created` or `bill.created` written after the `documents_v2` diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 385ec8d..f852c51 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1746,8 +1746,11 @@ person, a billing or shipping address, a billing email (D-55); an `invoice.created` or `bill.created` carrying a number, terms, a bill-to or seller snapshot, a customer reference or memo, a vendor reference, a receipt date or a duplicate's -reason, or a line naming its account, quantity or unit price (S4.3); and -S5 to S7's sends, settlements and write-offs. An older version would +reason, or a line naming its account, quantity or unit price (S4.3); +S5's `invoice.sent` (who sent an invoice, how, to whom and when: a record +that moves no figure); S6's payments with `line`, `discount`, +`discount_line`, `discount_reason`, `fee` or `fee_line`; and S7's +`invoice.written_off`. An older version would restore the document without them silently: every invoice line would post to the revenue role and every bill line to general expenses, and every new event type would be refused. A command asks for diff --git a/docs/testing.md b/docs/testing.md index ae66ae6..f5bf593 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3050,3 +3050,67 @@ and `BooksServer::new`'s copy (a dropped field must fail `isolation_label_is_enforced_under_the_unit`); the host's `server_flags` extension and `parse_args`' passthrough of `--isolation` and its optional `--http` value. + +## P4b S5: sent tracking (2026-10-27) + +The books record that an invoice went out; they never send it (A-4). A +send is `invoice.sent`, under `documents_v2` (D-52), and moves no figure: +`Ledger::mark_sent` pushes a `Sent` onto the invoice, the store journals +one line and no entry, replay restores it and the projection keeps it in +`schema` v17's `invoice_sends` (S4.3 took 16). + +- **`ledger-store` `tests/arap_standing.rs`:** `mark_sent_waits_for_documents_v2` + (books with no features: refused, nothing journaled; after `upgrade`, + taken, and the reopen equals what was live), `sends_replay_and_project` + (two sends: the journal's `invoice.sent` carries the whole record in + order, `verify` passes, the reopened invoice holds both), + `a_send_changes_no_figure` (on the `p4-arap-before` fixture: the report + snapshot, the invoice's `DocState` and every derived cache are equal + before and after), `mark_sent_on_void_refused` (`DocumentVoid`, + nothing journaled; a send dated before the void is taken). +- **`ledger-store` `tests/arap_props.rs`:** `status_is_a_function_of_journal_and_date` + gains `Op::MarkSent` (random sends on invoices, skipped on bills): every + figure, the control check and both agings still agree across live, + replay and snapshot. +- **`ledger-store` `tests/format.rs`:** `an_older_version_stops_at_invoice_sent` + (`invoice.sent` is this version's, a reader without `documents_v2` waits, + and the books declare the feature before the first send line). +- **8.0.6:** `books_written_under_older_rules_still_open` hand-writes an + `invoice.sent` and still opens; the fixture `old-books/p4_s5_before` + was written by eeee9658 (the revision before this unit) and opens to + the same figures; `books_p2_served_cut_over_whole` refuses a send before + consent and takes it after. +- **`ledger-cli` `tests/documents.rs`:** `mark_sent_records_who_and_how` + (two sends over the wire: the token's name, email and original by + default, then a reminder with a message id; `books.documents` shows + `last_sent`, `books.document` both sends, and the invoice still owes its + total). +- **`ledger-mcp/src/handbook.rs`:** the new `invoices-and-bills` section + renders, and its id is unique (the existing handbook tests). +- **Step 4, Attention (R-F):** `ledger-gui/tests/invoices.rs`: + `an_unsent_invoice_is_listed_until_a_send_clears_it` (an issued invoice + past `invoicing.unsent_days` is one to-do opening the invoice; the + overview draws it, `attention-unsent-invoice.png`; a send clears it), + `a_legacy_invoice_with_no_number_is_never_listed_as_unsent` (exempt), + `an_overdue_invoice_with_an_old_send_is_asked_to_remind` (due Mar 1, + last sent Feb 20: 14 days overdue, target the invoice, prompt names + `books.mark_sent`). `ledger-cli/tests/attention_parity.rs`: + `an_unsent_invoice_is_on_both_lists` (the viewer's list and `books.start` + both carry the item, and the step is `books.mark_sent` on `invoice:1`). + `the_viewer_and_books_start_see_the_same_attention` stays green. + +**For P12 (mutation targets):** `Ledger::mark_sent`'s refusals (unknown +invoice, a send dated before it, `DocumentVoid`, empty `to`, and each of +the three `check_text` calls; the note's check is stricter than the plan's +list, to keep a full TIN out of the journal); `SentVia::parse`'s arms and +its `other:` prefix; `SendKind::parse`'s two arms; `sent_via`'s arms and +its empty fallback; `send_kind`'s error arm; the handler's `kind` default +(`original` when `sends` is empty, `reminder` after), its `to` fallback +(bill-to, then party, then refused) and its `by` fallback; `invoice_id`'s +`invoice:` strip; `last_sent`'s `max`; the replay reader's +`newer_content` arms for an unknown channel or kind; and the two attention +predicates in `ledger-package::attention`: `invoice.number.is_some()` +(the legacy exemption), `Open | Partial`, `sends.is_empty()`, the +`checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the +`sends.max()` last send, and the `reminder_days` comparison (`>`), plus +each item's target (`Target::Invoice`) and `books.mark_sent` step args. diff --git a/docs/verification.md b/docs/verification.md index abb3b91..a35ea9e 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -542,6 +542,24 @@ come from the kernel: No Lean model: these are two arithmetic laws, not a new `Ledger` command (S4.1 adds fields to the existing document commands). +### Sent tracking (P4b S5) + +34. **A send changes no figure.** `Ledger::mark_sent` is a command, so it + is a `Step` in the model (AGENTS.md's formal-methods bar; the unit's + "no Lean change" phrase was overruled by it, and says so in its As + built block). `Documents.lean`'s `Books` gains `sends`, and + `Step.markSent` records one on a document the lines carry, touching + no line. Every control balance, every document's sum and what is + unattached are therefore the same before and after, through any day, + and `Reachable.tagged` (the control lines are all documents') covers + the new step; the engine's side is the same statement, checked by + `a_send_changes_no_figure` on the fixture book. + - Lean `Documents.Step.markSent_changes_no_figure` (no axioms) · + `Documents.Reachable.tagged`, `Documents.Reachable.control_is_documents` + cover the new step · tests `sends_replay_and_project`, + `a_send_changes_no_figure`, `mark_sent_on_void_refused`, + `mark_sent_waits_for_documents_v2` + ## What the proofs found The engine already had 190+ tests, property tests, mutation testing, diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index 3175d3a..8efbe58 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -27,7 +27,8 @@ const TYPES: &[&str] = &[ "asset.registered", "attachment.withdrawn", "bill.created", "bill.paid", "class.added", "customer.added", "document.filed", "entry.created", "entry.line_added", "entry.line_class_set", "entry.posted", "entry.reversed", "entry.vendor_set", - "fx.rate_set", "invoice.created", "invoice.payment_received", "journal.tail_repaired", + "fx.rate_set", "invoice.created", "invoice.payment_received", "invoice.sent", + "journal.tail_repaired", "line.cleared", "payroll.run", "period.closed", "period.created", "receipt.attached", "receipt.linked", "receipt.unlinked", "recurring.generated", "recurring.registered", "schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added", @@ -61,6 +62,7 @@ const FIELDS: &[&str] = &[ "rule", "contributor_adjusted_basis", "fmv_at_contribution", "sec704c_method", "tax_basis_723", "depreciable_basis", "built_in_gain_704c", "on", "convention", "status", "party_id", "started_on", "ended_on", "links", "notes", "projects", + "via", "by", "to", "reference", "note", ]; /// Journal features a `journal.format_set` may declare. diff --git a/plan/served.md b/plan/served.md index 0f04e19..55a0134 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1355,7 +1355,16 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1 5. **Files:** owns the `Sent` types and `Ledger::mark_sent`, the `invoice.sent` arms, `books.mark_sent`; touches the 8.0.3 files. 6. **Tests and proofs:** named above; no kernel change, no Lean change (a send moves no money). 8.0.6 items 1-4, 6. 7. **Done when:** (1) the tests pass; (2) a send on a fixture book changes no report and no `DocState` (`a_send_changes_no_figure`); (3) an older version stops at the `invoice.sent` line with `NewerJournal` (the `tests/format.rs` arm); (4) `just check` green; (5) attention parity holds. -8. **As built:** none yet. +8. **As built** (2026-10-27, branch `S5`): Build steps 1-5 are done. ~~step 4 (the two attention items) is deferred until U0b lands~~ → built after U0b landed (`cc4f104f`), on main `ed637f0e` (D-55). Departures: + - ~~no Lean change (a send moves no money)~~ → AGENTS.md's formal-methods bar (I-13) wins: `Ledger::mark_sent` is a new `Ledger` command, so `verify/lean/Wanderbooks/Documents.lean` gains `sends` on `Books`, `Step.markSent` and `Step.markSent_changes_no_figure` (no axioms), listed in `Audit.lean` and in `docs/verification.md` rule 34. + - Step 4: `ledger-package::attention` gains `Target::Invoice` and two items, `invoice_never_sent` (an issued invoice still Open or Partial, no send, past `invoicing.unsent_days`; a legacy invoice with no number is exempt) and `invoice_not_reminded` (overdue, last send older than `invoicing.reminder_days`); both are Todo, open the invoice, and offer `books.mark_sent` with the invoice. The viewer maps the target (one line in its Target→Route table; it owns no item) and draws them; `books.start` turns them into next steps from the one function. Tests: `an_unsent_invoice_is_listed_until_a_send_clears_it` (`attention-unsent-invoice.png`), `a_legacy_invoice_with_no_number_is_never_listed_as_unsent`, `an_overdue_invoice_with_an_old_send_is_asked_to_remind`, `an_unsent_invoice_is_on_both_lists`; `the_viewer_and_books_start_see_the_same_attention` stays green. + - The MCP parameter for the recipients is `recipients`, not `to`: `no_tool_takes_a_path` refuses a parameter named `to` (a whole-word path-name rule, I-2). The journal event and the result keep the plan's `to`. + - `Ledger::mark_sent` also passes `note` through `parties::check_text`, beside `to` and `reference`: a note must not carry a full TIN (I-7). + - `LedgerError::DocumentVoid` (the plan's name) and `LedgerError::InvalidSend` (the text refusals) are new variants. + - Schema 17 at merge: S4.3 took 16 with the document fields, so this unit took the next number (8.0.3's numbering rule; the provisional 17 held). + - `books.mark_sent` lives in its own group, `crates/ledger-mcp/src/tools/sent.rs`, as the EM directed (S4.4 owns `documents.rs`); the EM approved the `sent` name and `docs/architecture.md` lists it. `sent_out` and `last_sent`, which the read tools share, sit in `tools/helpers.rs`. + - `books.document` shows every send in its own `sends` array; its `timeline` stays the ledger lines only (a send moves no line). + - `handbook/invoices-and-bills.md` is S4.5's file and had not landed, so this unit creates it (R-E) with the send procedure; S4.5 extends it with the rest of the procedure. #### P4b S6: paying on terms (multi-document payments, discounts, the processor fee, bills due, aging exports) diff --git a/verify/lean/Audit.lean b/verify/lean/Audit.lean index c015231..dd05949 100644 --- a/verify/lean/Audit.lean +++ b/verify/lean/Audit.lean @@ -94,5 +94,6 @@ open Wanderbooks #print axioms Documents.Step.document_unattached #print axioms Documents.Step.reverse_moves #print axioms Documents.reverse_twice +#print axioms Documents.Step.markSent_changes_no_figure #print axioms Documents.Reachable.unattached_zero #print axioms Documents.Reachable.control_is_documents diff --git a/verify/lean/Wanderbooks/Documents.lean b/verify/lean/Wanderbooks/Documents.lean index 4575fc3..17ed148 100644 --- a/verify/lean/Wanderbooks/Documents.lean +++ b/verify/lean/Wanderbooks/Documents.lean @@ -37,6 +37,15 @@ structure TLine where doc : Option Nat deriving DecidableEq +/-- A recorded send of an invoice (plan P4b S5, `Sent`, `invoice.sent`): +the invoice, the day, and a code for who sent it, how, to whom and the +channel's own reference. No figure reads it: a send moves no money. -/ +structure Send where + doc : Nat + day : Nat + code : Nat + deriving DecidableEq + /-- A line's signed amount on account `a` (zero on another account). -/ def onAcct (a : Nat) (l : TLine) : Int := if l.line.account = a then l.line.signed else 0 @@ -159,11 +168,17 @@ control guard requires of every entry a command posts. -/ def Tagged (control : List Nat) (ls : List TLine) : Prop := ∀ l ∈ ls, l.line.account ∈ control → l.doc ≠ none -/-- The books: the control accounts (the receivables and payables roles) -and every posted line, in order. -/ +/-- The books: the control accounts (the receivables and payables roles), +every posted line in order, and the sends recorded (each on its invoice; +plan P4b S5). -/ structure Books where control : List Nat lines : List TLine + sends : List Send + +/-- The documents some lines carry: the invoices a send can name. -/ +def Books.docs (B : Books) : List Nat := + B.lines.filterMap (fun l => l.doc) /-- What the engine allows (S1): a document command, or any entry whose control lines are all tagged (`Step.document`); a reversal of posted @@ -184,6 +199,11 @@ inductive Step : Books → Books → Prop Step B { B with control := c :: B.control } | release (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc = none) : Step B { B with control := B.control.erase c } + /-- `mark_sent` (`Ledger::mark_sent`, plan P4b S5): a send is recorded on + an invoice the books hold, and nothing else changes. It moves no line, + so every figure stays where it was. -/ + | markSent (B : Books) (s : Send) (h : s.doc ∈ B.docs) : + Step B { B with sends := s :: B.sends } /-- **A document step never moves what is unattached** on a control account: no command adds an untagged control line. -/ @@ -216,9 +236,24 @@ theorem reverse_twice (a d D D' : Nat) (e : List TLine) : docSum a d ((e.map (mirror D)).map (mirror D')) = docSum a d e := by rw [docSum_mirror, docSum_mirror]; omega +/-- **A send changes no figure** (plan P4b S5): recording one leaves every +control account's balance, every document's sum on it and what is +unattached exactly as they were, through any day. A send adds a record and +no line, so the engine's own statement (`Ledger::mark_sent`) that a +document's DocState is the same before and after is this, line for line. -/ +theorem Step.markSent_changes_no_figure (B : Books) (s : Send) (_h : s.doc ∈ B.docs) + (a d D : Nat) : + gl a (through D ({ B with sends := s :: B.sends } : Books).lines) + = gl a (through D B.lines) + ∧ docSum a d (through D ({ B with sends := s :: B.sends } : Books).lines) + = docSum a d (through D B.lines) + ∧ unattached a (through D ({ B with sends := s :: B.sends } : Books).lines) + = unattached a (through D B.lines) := by + exact ⟨rfl, rfl, rfl⟩ + /-- States reachable from books with no lines. -/ inductive Reachable : Books → Prop - | empty (control : List Nat) : Reachable ⟨control, []⟩ + | empty (control : List Nat) : Reachable ⟨control, [], []⟩ | step {B B' : Books} : Reachable B → Step B B' → Reachable B' theorem Reachable.tagged {B : Books} (r : Reachable B) : Tagged B.control B.lines := by @@ -245,6 +280,9 @@ theorem Reachable.tagged {B : Books} (r : Reachable B) : Tagged B.control B.line | release c h => intro l hl hc exact ih l hl (List.mem_of_mem_erase hc) + | markSent _ _ => + -- A send touches no line: the control lines are the ones it found. + exact ih /-- **Nothing is ever unattached on a control account** in books these steps build, through any day, roles assigned and released included: