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.
This commit is contained in:
swaits committed 2026-10-27 13:24:10 -06:00
1 parent ed637f0e27
commit d1fb512e4b
16 files changed
+979 -5

No files matched your search

+50
View File
@@ -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<SentVia> {
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<SendKind> {
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)]
+11
View File
@@ -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),
+102
View File
@@ -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)