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;