From d1fb512e4bf2bfa645a38ba53cb1538857397bbe Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 07:19:42 -0600 Subject: [PATCH 01/10] feat(arap): record an invoice's sends as invoice.sent (P4b S5) The books never send invoices (A-4), but they must remember that one went out: who sent it, how, to whom, when, whether it was the original or a reminder, and the channel's own reference. That record is a fact of the document's life, not a figure: a send moves no money, so it is one journal line (`invoice.sent`) and no entry, and every derived figure (outstanding, aging, DocState) is exactly as it was. `Ledger::mark_sent` refuses a send on a void invoice (it should never have reached the customer), a send dated before the invoice, no recipients, and any recipient, reference or note that would put a full TIN or control characters into the journal. Open, partial, paid and written-off invoices can be sent (a copy for the customer's records, a reminder). The event is `documents_v2`'s (D-52): an older version refuses the type rather than dropping the record. Replay restores it and the projection keeps it in schema 16's `invoice_sends` table, so live, replay and the viewer agree. The new old-books fixture `p4_s5_before` was written by eeee9658 and opens to the same figures; a hand-written send in `books_written_under_older_rules_still_open` proves an older journal with the new line still opens. Tests: mark_sent_waits_for_documents_v2, sends_replay_and_project, a_send_changes_no_figure, mark_sent_on_void_refused, Op::MarkSent in status_is_a_function_of_journal_and_date, an_older_version_stops_at_invoice_sent. --- crates/ledger-core/src/arap.rs | 50 ++++ crates/ledger-core/src/error.rs | 11 + crates/ledger-core/src/ledger.rs | 102 +++++++ crates/ledger-store/src/format.rs | 1 + crates/ledger-store/src/meta.rs | 57 ++++ crates/ledger-store/src/schema.rs | 19 +- crates/ledger-store/src/store.rs | 55 ++++ crates/ledger-store/tests/arap_props.rs | 33 +++ crates/ledger-store/tests/arap_standing.rs | 191 ++++++++++++- .../fixtures/old-books-writer/Cargo.toml | 2 + .../old-books-writer/src/bin/s5_before.rs | 65 +++++ .../old-books/p4_s5_before/expected.json | 265 ++++++++++++++++++ .../old-books/p4_s5_before/journal.jsonl | 29 ++ crates/ledger-store/tests/format.rs | 83 +++++- crates/ledger-store/tests/store.rs | 17 ++ fuzz/fuzz_targets/fuzz_store_replay.rs | 4 +- 16 files changed, 979 insertions(+), 5 deletions(-) create mode 100644 crates/ledger-store/tests/fixtures/old-books-writer/src/bin/s5_before.rs create mode 100644 crates/ledger-store/tests/fixtures/old-books/p4_s5_before/expected.json create mode 100644 crates/ledger-store/tests/fixtures/old-books/p4_s5_before/journal.jsonl diff --git a/crates/ledger-core/src/arap.rs b/crates/ledger-core/src/arap.rs index cd8ebd5..18102c6 100644 --- a/crates/ledger-core/src/arap.rs +++ b/crates/ledger-core/src/arap.rs @@ -742,6 +742,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")] @@ -752,6 +782,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 a24a24c..1f1343c 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -5724,6 +5724,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-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 055ff0b..5c6933c 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -3698,6 +3698,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 // ------------------------------------------------------------------ @@ -6979,6 +7009,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. @@ -7961,6 +7996,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 412fecc..61b1d2b 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -1946,6 +1946,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())); @@ -2007,6 +2016,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/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. From 75d8c23d9b62f3f6fdc54897a956a23af6f70a05 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 07:19:49 -0600 Subject: [PATCH 02/10] feat(mcp): books.mark_sent, and the send in the read tools (P4b S5) `books.mark_sent` records the send the human or a delegate made: the invoice (`invoice:7` or its id), the day, who sent it (the token's name unless given), how (email by default, or mail, portal, hand, another channel), to whom (the bill-to email, else the customer's), whether it was the original or a reminder (original on the first send, reminder after), and the channel's own id. It answers with every send on the invoice, newest last. `books.documents` rows gain `last_sent` and `books.document` shows the sends, so "did we send it, and when" is one read away, while a bill (which we receive) has none. The schema lint refuses a parameter named `to` (a path-name rule), so the recipients parameter is `recipients`; the journal event and the result keep the plan's `to`. The tool needs `books:write`: it changes the record, never a figure. The handbook gains the invoices-and-bills section (S4.5 had not landed yet) with the send procedure: the books never send, the PDF goes to the human, and each send is recorded afterwards. Over the wire, mark_sent_records_who_and_how: two sends (the second a reminder with a message id), the list's `last_sent`, the document's `sends`, and the invoice still owing its total. The P2 cutover refuses a send before consent and takes it after. --- crates/ledger-cli/tests/documents.rs | 94 ++++++++++ crates/ledger-cli/tests/format_upgrade.rs | 17 ++ crates/ledger-cli/tests/mcp_journeys.rs | 2 +- .../ledger-mcp/handbook/invoices-and-bills.md | 28 +++ crates/ledger-mcp/src/handbook.rs | 4 + crates/ledger-mcp/src/scopes.rs | 4 +- crates/ledger-mcp/src/server_tests.rs | 3 +- crates/ledger-mcp/src/tools/documents.rs | 175 +++++++++++++++++- crates/ledger-mcp/src/types.rs | 79 +++++++- docs/mcp-interface.md | 24 ++- 10 files changed, 416 insertions(+), 14 deletions(-) create mode 100644 crates/ledger-cli/tests/documents.rs create mode 100644 crates/ledger-mcp/handbook/invoices-and-bills.md diff --git a/crates/ledger-cli/tests/documents.rs b/crates/ledger-cli/tests/documents.rs new file mode 100644 index 0000000..e6051b4 --- /dev/null +++ b/crates/ledger-cli/tests/documents.rs @@ -0,0 +1,94 @@ +//! P4b S5 over the wire: `books.mark_sent` records who sent an invoice, +//! how, to whom and when, and the read tools show it (`books.documents`' +//! `last_sent`, `books.document`'s `sends`). Expected behavior comes only +//! from the tool descriptions and parameter/result doc comments in +//! `src/types.rs` (the declared interface, as `tools/list` serves it) and +//! `docs/mcp-interface.md` ("Tools — receipts & documents"); plan +//! `served.md` P4b S5 and A-4 (the books record sends; they never send). + +mod common; + +use common::answer_of; +use serde_json::json; + +/// 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 = answer_of(&wire.call("books.add_customer", json!({"name": "Fernhill Labs"}))) + .unwrap()["id"] + .as_u64() + .unwrap(); + let invoice = 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"}]}), + )) + .unwrap()["id"] + .as_u64() + .unwrap(); + + let first = 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 = 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 = 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 = 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 375e3c7..09404b3 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -703,6 +703,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"}]}), + ) + .unwrap()["id"] + .clone(); let waiting = journal(&dir); let refused: Vec<(&str, Value)> = vec![ // An invoice's or bill's line naming its project @@ -729,6 +739,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-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 2814e5d..b78b369 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_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 7b057c0..5d4f98b 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -77,6 +77,56 @@ fn doc_label(doc: ledger_core::DocRef) -> String { } } +/// A recorded send on the wire (plan P4b S5). +fn sent_out(s: &ledger_core::arap::Sent) -> 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. +fn last_sent(sends: &[ledger_core::arap::Sent]) -> Option { + sends.iter().map(|s| s.on).max().map(|d| d.to_string()) +} + +/// `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")) +} + /// An invoice's or bill's lines as the tool takes them: each amount /// parsed and each project resolved ([`resolve_class`]) before anything is /// written. @@ -308,6 +358,83 @@ 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(), + })) + } + #[tool( name = "books.documents", title = "List invoices or bills", @@ -345,7 +472,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() @@ -354,7 +487,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 { @@ -366,14 +505,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 @@ -411,6 +556,7 @@ impl BooksServer { outstanding: money(st.outstanding), status: key.to_string(), overdue_days: st.overdue_days, + last_sent, }, )); } @@ -441,7 +587,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) @@ -450,7 +596,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 @@ -460,7 +613,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 @@ -532,6 +692,7 @@ impl BooksServer { status: st.status.key().to_string(), overdue_days: st.overdue_days, timeline, + sends, pdf, })) } diff --git a/crates/ledger-mcp/src/types.rs b/crates/ledger-mcp/src/types.rs index 7a4ef1b..c20bb15 100644 --- a/crates/ledger-mcp/src/types.rs +++ b/crates/ledger-mcp/src/types.rs @@ -3170,6 +3170,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`. @@ -3263,6 +3267,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, @@ -4719,7 +4728,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, } @@ -4758,3 +4767,71 @@ pub struct HandbookOut { /// The section asked for (null: none was). pub section: Option, } + +// 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/docs/mcp-interface.md b/docs/mcp-interface.md index d3734d5..8837d8a 100644 --- a/docs/mcp-interface.md +++ b/docs/mcp-interface.md @@ -2029,9 +2029,24 @@ S4 changes `books.create_invoice` and `books.create_bill`; 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), @@ -2039,11 +2054,14 @@ S4 changes `books.create_invoice` and `books.create_bill`; 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 From 47c0e4396ec04e7535d0ba72f0cf521a8b88e3df Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 07:19:57 -0600 Subject: [PATCH 03/10] verify(lean): Step.markSent, a send moves no figure (P4b S5) AGENTS.md's formal-methods bar makes every new Ledger command a Lean Step with a preservation proof, so `Ledger::mark_sent` gets one even though the unit expected no Lean change (the As-built block records that). `Documents.lean`'s `Books` gains the sends recorded, and `Step.markSent` adds one on a document the lines carry. A send touches no line, so every control balance, every document's sum and what is unattached are unchanged through any day (`Step.markSent_changes_no_figure`, no axioms), and `Reachable.tagged` covers the new step. `Audit.lean` lists the theorem; rules.md B-38's model now covers the record side of a document's life as well as its figures. --- docs/verification.md | 18 +++++++++++ verify/lean/Audit.lean | 1 + verify/lean/Wanderbooks/Documents.lean | 44 ++++++++++++++++++++++++-- 3 files changed, 60 insertions(+), 3 deletions(-) diff --git a/docs/verification.md b/docs/verification.md index 9d03108..7762f45 100644 --- a/docs/verification.md +++ b/docs/verification.md @@ -504,6 +504,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/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: From 7c5a2fe3ce5ddc7b2c4d6e3d06c45338cd281130 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 07:19:57 -0600 Subject: [PATCH 04/10] docs: sent tracking in the rules, parity and test records (P4b S5) rules.md B-27 lists `documents_v2`, the one feature S4 to S7 share (D-52), including `invoice.sent`; receipt-store.md lists the event; parity.md gains the sent-tracking row (done: implemented, tested, and golden-covered by the trial balance of the fixture book around a send); testing.md records the tests and what P12 must mutate, and names the two attention predicates as deferred with step 4 until U0b lands. --- docs/parity.md | 12 ++++++++++ docs/receipt-store.md | 6 +++-- docs/spec/rules.md | 7 ++++-- docs/testing.md | 51 +++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 72 insertions(+), 4 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index e27be0b..50450f8 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) | — (our `xtask oracle`) | todo | roadmap phase 3 | +| 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. @@ -104,3 +105,14 @@ 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`. diff --git a/docs/receipt-store.md b/docs/receipt-store.md index bde7404..f6b28a3 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 161e093..2acfcdb 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1744,8 +1744,11 @@ which an earlier version refuses (S4.2); a `party.updated` with an email or a default terms fact (S4.2); an `invoice.created` or `bill.created` 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 c1f814b..53f302d 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3050,3 +3050,54 @@ 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::Sent` (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). + +**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`; and the replay reader's +`newer_content` arms for an unknown channel or kind. The two attention +predicates ("invoice never sent", "overdue invoice not reminded") and +their day comparisons are deferred with the unit's step 4 until U0b +lands, and are named here when they do. From b0d4ae7f8e345ad000894e85f193654494ef6675 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 07:19:57 -0600 Subject: [PATCH 05/10] docs(plan): P4b S5 as built (P4b S5) Records the departures: step 4 (the two attention items) deferred until U0b (R-F forbids them before it); the Lean step that AGENTS.md's bar requires over the unit's "no Lean change"; the recipients parameter renamed for the path-name lint; the note checked for a full TIN; schema 16 taken at merge (S4.3 had not landed); and the handbook section created before S4.5. --- plan/served.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/plan/served.md b/plan/served.md index 65948c9..3169370 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1339,7 +1339,15 @@ 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-3 and 5 are done; **step 4 (the two attention items) is deferred until U0b lands** (8.0.5 R-F: no attention item before it), as the EM directed. 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. + - 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) From 458ca19965e31a80392ccd0cd3e47dc5f19be5e8 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 07:59:30 -0600 Subject: [PATCH 06/10] test(mcp): exercise books.mark_sent in the every-tool journey (P4b S5) `inv1_every_tool_result_matches_its_output_schema` fails listing any tool the happy-path journey never calls successfully; the new send tool must be in it. The journey records one send on the invoice it already created (the books never send), so its output schema is exercised on every run like every other tool's. --- crates/ledger-cli/tests/spec_mcp.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ledger-cli/tests/spec_mcp.rs b/crates/ledger-cli/tests/spec_mcp.rs index ede855a..cd10e93 100644 --- a/crates/ledger-cli/tests/spec_mcp.rs +++ b/crates/ledger-cli/tests/spec_mcp.rs @@ -896,6 +896,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( From 3ea008bb543f7854282bb0aa7e67607ffe8ceed3 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 08:50:25 -0600 Subject: [PATCH 07/10] feat(viewer): the journal says an invoice went out (P4b S5) R-B item 7: every journal event gets a plain-language sentence, so the new `invoice.sent` reads "Invoice 7 sent by email" (or "reminder") instead of showing its event name. A send has no entry to open, so the line has no route: it is a document event, not a posting. --- crates/ledger-gui/src/pages/journal.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) 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)), From adc8804f61513da82e323b855fa5a4ce32177967 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 10:53:49 -0600 Subject: [PATCH 08/10] refactor(mcp): books.mark_sent in its own sent group (P4b S5) S4.4 is working in tools/documents.rs, so the send tool moves beside it rather than into it: `crates/ledger-mcp/src/tools/sent.rs`, declared like every other group and named in docs/architecture.md's layout. The two helpers the document read tools share (`sent_out`, `last_sent`) go to tools/helpers.rs, which the groups share; the handler and its own argument readers stay private to `sent`. No tool name, schema or behavior changes: books.mark_sent is where it was on the wire, only in a file of its own. --- crates/ledger-mcp/src/server.rs | 3 + crates/ledger-mcp/src/tools/documents.rs | 127 ----------------------- crates/ledger-mcp/src/tools/helpers.rs | 18 ++++ crates/ledger-mcp/src/tools/sent.rs | 118 +++++++++++++++++++++ docs/architecture.md | 1 + 5 files changed, 140 insertions(+), 127 deletions(-) create mode 100644 crates/ledger-mcp/src/tools/sent.rs 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/tools/documents.rs b/crates/ledger-mcp/src/tools/documents.rs index 5d4f98b..c955945 100644 --- a/crates/ledger-mcp/src/tools/documents.rs +++ b/crates/ledger-mcp/src/tools/documents.rs @@ -77,56 +77,6 @@ fn doc_label(doc: ledger_core::DocRef) -> String { } } -/// A recorded send on the wire (plan P4b S5). -fn sent_out(s: &ledger_core::arap::Sent) -> 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. -fn last_sent(sends: &[ledger_core::arap::Sent]) -> Option { - sends.iter().map(|s| s.on).max().map(|d| d.to_string()) -} - -/// `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")) -} - /// An invoice's or bill's lines as the tool takes them: each amount /// parsed and each project resolved ([`resolve_class`]) before anything is /// written. @@ -358,83 +308,6 @@ 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(), - })) - } - #[tool( name = "books.documents", title = "List invoices or bills", 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/docs/architecture.md b/docs/architecture.md index 13ce3bb..5ab20e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -149,6 +149,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; From 485bd4ae5ff6f8b1c7c6d2d08113bfc45e6c0f00 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 13:46:43 -0600 Subject: [PATCH 09/10] feat(attention): unsent and unreminded invoices (P4b S5) The books record that an invoice went out but do not chase it. U0b's one attention engine now lists two items the sent record makes possible, in the viewer's Needs attention and in books.start's next steps alike: - "Invoice INV-0007 was never sent": an issued invoice (a number) still Open or Partial, with no send recorded, once today is past its issue date plus invoicing.unsent_days (default 2). A legacy invoice with no number is exempt, and so is a void or settled one. - "Invoice INV-0007 is overdue: remind the customer": the invoice is overdue and its last send is older than invoicing.reminder_days (default 14). An invoice never sent gets the first item, not this one. Both are Todo, open the invoice, and offer books.mark_sent with its invoice and the tool's defaults. The viewer maps the new Target::Invoice to its invoice page (one line; items still come only from ledger-package), and the two lists cannot drift. Tests: an_unsent_invoice_is_listed_until_a_send_clears_it (the overview draws it; attention-unsent-invoice.png; a send clears it), a_legacy_invoice_with_no_number_is_never_listed_as_unsent, an_overdue_invoice_with_an_old_send_is_asked_to_remind, and an_unsent_invoice_is_on_both_lists (viewer and books.start, step books.mark_sent on invoice:1). The existing attention parity test stays green. --- crates/ledger-cli/tests/attention_parity.rs | 47 ++++++++ crates/ledger-gui/src/attention.rs | 1 + crates/ledger-gui/tests/invoices.rs | 126 ++++++++++++++++++++ crates/ledger-package/src/attention.rs | 92 +++++++++++++- 4 files changed, 263 insertions(+), 3 deletions(-) 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-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/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-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(); From ad649be3d5620fcf22e477555175269b74233425 Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Tue, 27 Oct 2026 13:46:43 -0600 Subject: [PATCH 10/10] docs: S5 step 4 landed; attention predicates in the test record (P4b S5) The As-built block records step 4 built after U0b, its two items and their tests; docs/testing.md names them and what P12 must mutate (the number exemption, the open/partial check, sends.is_empty(), the two day comparisons with their checked date arithmetic, the last-send max); docs/parity.md says sent tracking also feeds Needs attention. --- docs/parity.md | 6 +++++- docs/testing.md | 25 +++++++++++++++++++------ plan/served.md | 3 ++- 3 files changed, 26 insertions(+), 8 deletions(-) diff --git a/docs/parity.md b/docs/parity.md index 50450f8..562cad0 100644 --- a/docs/parity.md +++ b/docs/parity.md @@ -115,4 +115,8 @@ 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`. +`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/testing.md b/docs/testing.md index 53f302d..4b53918 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -3069,7 +3069,7 @@ one line and no entry, replay restores it and the projection keeps it in 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::Sent` (random sends on invoices, skipped on bills): every + 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` @@ -3087,6 +3087,17 @@ one line and no entry, replay restores it and the projection keeps it in 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 @@ -3096,8 +3107,10 @@ 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`; and the replay reader's -`newer_content` arms for an unknown channel or kind. The two attention -predicates ("invoice never sent", "overdue invoice not reminded") and -their day comparisons are deferred with the unit's step 4 until U0b -lands, and are named here when they do. +`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/plan/served.md b/plan/served.md index 3169370..aeb1507 100644 --- a/plan/served.md +++ b/plan/served.md @@ -1339,8 +1339,9 @@ 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** (2026-10-27, branch `S5`): Build steps 1-3 and 5 are done; **step 4 (the two attention items) is deferred until U0b lands** (8.0.5 R-F: no attention item before it), as the EM directed. Departures: +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.