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.
This commit is contained in:
swaits committed 2026-10-27 13:24:10 -06:00
1 parent d1fb512e4b
commit 75d8c23d9b
10 files changed
+416 -14

No files matched your search

+94
View File
@@ -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"
);
}
+17
View File
@@ -703,6 +703,16 @@ fn books_p2_served_cut_over_whole() {
// A project by name alone is a plain class, which P2 reads. // A project by name alone is a plain class, which P2 reads.
a.call("books.add_project", json!({"name": "Lighthouse"})) a.call("books.add_project", json!({"name": "Lighthouse"}))
.unwrap(); .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 waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![ let refused: Vec<(&str, Value)> = vec![
// An invoice's or bill's line naming its project // 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", json!({"decided_by": "Avery Quill",
"changes": [{"key": "invoicing.default_terms", "value": "Net 30"}]}), "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", "books.add_account",
json!({"code": "4900", "name": "Gain on sale", "account_type": "revenue", json!({"code": "4900", "name": "Gain on sale", "account_type": "revenue",
+1 -1
View File
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
// Tool listing: the narrow tools; chart is read-only. // Tool listing: the narrow tools; chart is read-only.
let list = mcp.request("tools/list", json!({})); let list = mcp.request("tools/list", json!({}));
let tools = list["result"]["tools"].as_array().unwrap(); 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. // 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.begin_upload"));
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file")); assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
@@ -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).
+4
View File
@@ -103,6 +103,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
"tracing-figures", "tracing-figures",
include_str!("../handbook/tracing-figures.md"), 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. /// The resource URI of the handbook's index, and the prefix of a section's.
+3 -1
View File
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
const K: Needs = PostForApprovalRules; const K: Needs = PostForApprovalRules;
/// Every tool and what it needs (INVARIANT 1). /// Every tool and what it needs (INVARIANT 1).
pub const TABLE: [(&str, Needs); 122] = [ pub const TABLE: [(&str, Needs); 123] = [
("books.handbook", R), ("books.handbook", R),
("books.start", R), ("books.start", R),
// Settings are committed at once: the settings commit of the plan. // 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.receive_payment", P),
("books.pay_bill", P), ("books.pay_bill", P),
("books.void_document", P), ("books.void_document", P),
// Records a send: changes the books' record, never a figure.
("books.mark_sent", W),
("books.control_check", R), ("books.control_check", R),
("books.documents", R), ("books.documents", R),
("books.document", R), ("books.document", R),
+2 -1
View File
@@ -42,7 +42,7 @@ fn annotations_complete() {
const D: Hints = (Some(false), Some(true), None); const D: Hints = (Some(false), Some(true), None);
const WI: Hints = (Some(false), Some(false), Some(true)); const WI: Hints = (Some(false), Some(false), Some(true));
const DI: Hints = (Some(false), Some(true), 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.handbook", R),
("books.start", R), ("books.start", R),
("books.set_setting", DI), ("books.set_setting", DI),
@@ -145,6 +145,7 @@ fn annotations_complete() {
("books.receive_payment", D), ("books.receive_payment", D),
("books.pay_bill", D), ("books.pay_bill", D),
("books.void_document", D), ("books.void_document", D),
("books.mark_sent", W),
("books.control_check", R), ("books.control_check", R),
("books.documents", R), ("books.documents", R),
("books.document", R), ("books.document", R),
+168 -7
View File
@@ -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<String> {
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<u64, String> {
let t = r.trim();
let t = t.strip_prefix("invoice:").map(str::trim).unwrap_or(t);
t.parse::<u64>()
.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<ledger_core::arap::SentVia, String> {
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, String> {
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 /// An invoice's or bill's lines as the tool takes them: each amount
/// parsed and each project resolved ([`resolve_class`]) before anything is /// parsed and each project resolved ([`resolve_class`]) before anything is
/// written. /// 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<MarkSentParams>,
) -> Result<Json<MarkSentOut>, 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( #[tool(
name = "books.documents", name = "books.documents",
title = "List invoices or bills", title = "List invoices or bills",
@@ -345,7 +472,13 @@ impl BooksServer {
} }
let store = self.lock()?; let store = self.lock()?;
let ledger = store.ledger(); 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<String>,
)> = if invoices {
ledger ledger
.invoices() .invoices()
.into_iter() .into_iter()
@@ -354,7 +487,13 @@ impl BooksServer {
.get_customer(i.customer_id) .get_customer(i.customer_id)
.map(|c| c.name.clone()) .map(|c| c.name.clone())
.unwrap_or_default(); .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() .collect()
} else { } else {
@@ -366,14 +505,20 @@ impl BooksServer {
.get_vendor(b.vendor_id) .get_vendor(b.vendor_id)
.map(|v| v.name.clone()) .map(|v| v.name.clone())
.unwrap_or_default(); .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() .collect()
}; };
let party = p.party.as_deref().map(|s| s.trim().to_lowercase()); let party = p.party.as_deref().map(|s| s.trim().to_lowercase());
let mut rows = Vec::new(); let mut rows = Vec::new();
let mut owed = Decimal::ZERO; 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 if let Some(want_party) = &party
&& who.to_lowercase() != *want_party && who.to_lowercase() != *want_party
&& doc_id(doc).to_string() != *want_party && doc_id(doc).to_string() != *want_party
@@ -411,6 +556,7 @@ impl BooksServer {
outstanding: money(st.outstanding), outstanding: money(st.outstanding),
status: key.to_string(), status: key.to_string(),
overdue_days: st.overdue_days, overdue_days: st.overdue_days,
last_sent,
}, },
)); ));
} }
@@ -441,7 +587,7 @@ impl BooksServer {
let doc = parse_doc_ref(&p.r#ref)?; let doc = parse_doc_ref(&p.r#ref)?;
let store = self.lock()?; let store = self.lock()?;
let ledger = store.ledger(); 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) => { ledger_core::DocRef::Invoice(id) => {
let i = ledger let i = ledger
.get_invoice(id) .get_invoice(id)
@@ -450,7 +596,14 @@ impl BooksServer {
.get_customer(i.customer_id) .get_customer(i.customer_id)
.map(|c| c.name.clone()) .map(|c| c.name.clone())
.unwrap_or_default(); .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) => { ledger_core::DocRef::Bill(id) => {
let b = ledger let b = ledger
@@ -460,7 +613,14 @@ impl BooksServer {
.get_vendor(b.vendor_id) .get_vendor(b.vendor_id)
.map(|v| v.name.clone()) .map(|v| v.name.clone())
.unwrap_or_default(); .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 let st = ledger
@@ -532,6 +692,7 @@ impl BooksServer {
status: st.status.key().to_string(), status: st.status.key().to_string(),
overdue_days: st.overdue_days, overdue_days: st.overdue_days,
timeline, timeline,
sends,
pdf, pdf,
})) }))
} }
+78 -1
View File
@@ -3170,6 +3170,10 @@ pub struct DocumentRowOut {
pub status: String, pub status: String,
/// Days past due while something is owed; 0 otherwise. /// Days past due while something is owed; 0 otherwise.
pub overdue_days: i64, 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<String>,
} }
/// Output of `books.documents`. /// Output of `books.documents`.
@@ -3263,6 +3267,11 @@ pub struct DocumentOut {
pub overdue_days: i64, pub overdue_days: i64,
/// Every ledger line tagged with it, in posting order. /// Every ledger line tagged with it, in posting order.
pub timeline: Vec<DocumentEventOut>, pub timeline: Vec<DocumentEventOut>,
/// 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<SentOut>,
/// The invoice's PDF, handed over (with `pdf`): run its download. /// The invoice's PDF, handed over (with `pdf`): run its download.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
pub pdf: Option<HandedFile>, pub pdf: Option<HandedFile>,
@@ -4719,7 +4728,7 @@ pub struct ReconciliationsOut {
/// Parameters for `books.handbook`. /// Parameters for `books.handbook`.
#[derive(Debug, Default, Deserialize, JsonSchema)] #[derive(Debug, Default, Deserialize, JsonSchema)]
pub struct HandbookParams { 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)] #[serde(default)]
pub section: Option<String>, pub section: Option<String>,
} }
@@ -4758,3 +4767,71 @@ pub struct HandbookOut {
/// The section asked for (null: none was). /// The section asked for (null: none was).
pub section: Option<HandbookTextOut>, pub section: Option<HandbookTextOut>,
} }
// 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<String>,
/// Who sent it: the owner, or a named delegate such as an agent
/// (omitted: this session's token name).
#[serde(default)]
pub by: Option<String>,
/// The channel: email (the default), mail, portal, hand, or another
/// channel's own name.
#[serde(default)]
pub via: Option<String>,
/// 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<Vec<String>>,
/// original (the default for a first send) or reminder (the default
/// after one).
#[serde(default)]
pub kind: Option<String>,
/// The channel's own id (a message id, a tracking number).
#[serde(default)]
pub reference: Option<String>,
/// A note about the send.
#[serde(default)]
pub note: Option<String>,
}
/// 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<String>,
/// original or reminder.
pub kind: String,
/// The channel's own id, when given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub reference: Option<String>,
/// A note about the send, when given.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
/// 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<SentOut>,
}
+21 -3
View File
@@ -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 number and reads `void`. Refused while a payment stands on it (the
refusal names the payment entry to reverse first), when void already, refusal names the payment entry to reverse first), when void already,
before its date or in a closed period (P4b S1, B-38). 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, - `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of,
kind, documents: [{ ref, party, date, due_date, total, outstanding, 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 `invoices`) or bills (`bills`), due date first, every figure derived
from the document's lines that stand (B-38). `status` filters: `open` from the document's lines that stand (B-38). `status` filters: `open`
(default: anything still owed, partly paid and overdue included), (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`, own `status` is one of `open`, `partial`, `paid`, `written_off`,
`void`, `overpaid` or `unattached` (`DocStatus::key()`). `party` (a `void`, `overpaid` or `unattached` (`DocStatus::key()`). `party` (a
customer's or vendor's name, ignoring case) and `due_by` (due on or 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, - `books.document { ref, as_of?, pdf? }` → `{ ref, party, date, due_date,
entry_id, lines: [{ description, amount, project? }], total, paid, entry_id, lines: [{ description, amount, project? }], total, paid,
discounted, written_off, outstanding, status, overdue_days, timeline: 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 `invoice:N` or `bill:N`; `status` as `books.documents`' rows; the
timeline is every ledger line tagged with the document, dated on or timeline is every ledger line tagged with the document, dated on or
before the day, in posting order, each saying whether its entry still before the day, in posting order, each saying whether its entry still