merge: P4b S5, sent tracking (branch S5, ad649be3)
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s

The second of the paused branches. invoice.sent, Ledger::mark_sent (with
its Lean step), books.mark_sent in its own group, the two attention
items, schema 17. Conflicts, resolved as plan 8.0.2 says: the shared
over-the-wire test file crates/ledger-cli/tests/documents.rs (S4.4's
file kept whole, S5's test appended, its create call given the
confirmed: true the S4.4 contract now needs), types.rs (both appends
kept), docs/parity.md (the review's oracle row and S5's new row).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:21:21 -06:00
commit 3a3f03d884
44 files changed
+1847 -30

No files matched your search

+50
View File
@@ -789,6 +789,36 @@ pub enum SentVia {
Other(String),
}
impl SentVia {
/// Its key as the journal, the tools and the viewer name it: `email`,
/// `mail`, `portal`, `hand`, or `other:NAME` for another channel,
/// named (plan P4b S5).
pub fn key(&self) -> String {
match self {
SentVia::Email => "email".to_string(),
SentVia::Mail => "mail".to_string(),
SentVia::Portal => "portal".to_string(),
SentVia::Hand => "hand".to_string(),
SentVia::Other(name) => format!("other:{name}"),
}
}
/// [`Self::key`] the other way; `None` for a channel this version
/// doesn't know (a newer version's `sent_via`).
pub fn parse(key: &str) -> Option<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")]
@@ -799,6 +829,26 @@ pub enum SendKind {
Reminder,
}
impl SendKind {
/// Its key as the journal, the tools and the viewer name it.
pub fn key(self) -> &'static str {
match self {
SendKind::Original => "original",
SendKind::Reminder => "reminder",
}
}
/// [`Self::key`] the other way; `None` for a kind this version
/// doesn't know.
pub fn parse(key: &str) -> Option<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
@@ -5751,6 +5751,108 @@ impl Ledger {
Ok(())
}
/// Record that an invoice went out (plan P4b S5): who sent it, how, to
/// whom, when, whether it was the original or a reminder, and the
/// channel's own reference (a message id). The books never send
/// anything themselves (A-4: "Wanderbooks just does books").
///
/// ```text
/// GOAL: record a send as a fact of the document's life, without
/// touching a figure: a send moves no money.
/// SOURCE: docs/spec/rules.md B-38 (a document keeps no figure of its
/// own), B-27 (the invoice.sent line, feature documents_v2);
/// plan P4b S5, A-4 (the books record sends; they never send).
/// INVARIANTS:
/// 1. The invoice MUST exist, and a send MUST NOT be dated before it.
/// 2. A void invoice MUST NOT be sent (DocumentVoid): it should never
/// have reached the customer. Open, partial, paid and written-off
/// invoices all may be (a receipt copy, a reminder).
/// 3. `to` MUST hold at least one recipient; every recipient, the
/// reference and the note MUST pass parties::check_text (no full
/// TIN, no control characters, at most 500 characters).
/// 4. A send MUST change no figure: the invoice's DocState is the
/// same before and after, on every date.
/// 5. A refusal MUST leave the books unchanged.
/// SCENARIOS:
/// 1. IF the invoice is unknown, void, dated after the send, has no
/// recipients, or a text breaks the books' rule: SHOULD refuse.
/// ```
pub fn mark_sent(&mut self, invoice: InvoiceId, sent: arap::Sent) -> Result<(), LedgerError> {
self.atomically(|l| l.mark_sent_steps(invoice, sent))
}
/// The steps of [`Self::mark_sent`], run inside [`Self::atomically`].
fn mark_sent_steps(&mut self, invoice: InvoiceId, sent: arap::Sent) -> Result<(), LedgerError> {
let issued = self
.invoices
.get(&invoice)
.ok_or(LedgerError::UnknownInvoice(invoice))?
.date;
if sent.on < issued {
return Err(LedgerError::InvalidSend(format!(
"invoice {invoice} is dated {issued}: a send can't be dated before it, on {}",
sent.on
)));
}
// The invoice's state on the day it went out: a void later than the
// send doesn't un-send it, and a void before it does.
let state = self
.doc_state(arap::DocRef::Invoice(invoice), sent.on)?
.ok_or(LedgerError::UnknownInvoice(invoice))?;
match state.status {
arap::DocStatus::Open
| arap::DocStatus::Partial
| arap::DocStatus::Paid
| arap::DocStatus::WrittenOff => {}
arap::DocStatus::Void => return Err(LedgerError::DocumentVoid(invoice)),
arap::DocStatus::Overpaid | arap::DocStatus::Unattached => {
return Err(LedgerError::InvalidSend(format!(
"invoice {invoice} is {status} on {on}: only an open, partial, paid or written-off invoice can be sent",
status = state.status.key(),
on = sent.on
)));
}
}
if sent.to.is_empty() {
return Err(LedgerError::InvalidSend(format!(
"a send of invoice {invoice} needs at least one recipient"
)));
}
for to in &sent.to {
crate::parties::check_text("a recipient", to).map_err(LedgerError::InvalidSend)?;
}
if let Some(reference) = &sent.reference {
crate::parties::check_text("the reference", reference)
.map_err(LedgerError::InvalidSend)?;
}
if let Some(note) = &sent.note {
crate::parties::check_text("the note", note).map_err(LedgerError::InvalidSend)?;
}
self.invoices
.get_mut(&invoice)
.expect("checked above")
.sends
.push(sent);
Ok(())
}
/// Record a send without checking it: replay support for journaling
/// stores. Nothing is validated on replay (rules.md B-28); the invoice
/// must already have been replayed from its own events.
#[doc(hidden)]
pub fn replay_invoice_sent(
&mut self,
invoice_id: InvoiceId,
sent: arap::Sent,
) -> Result<(), LedgerError> {
let invoice = self
.invoices
.get_mut(&invoice_id)
.ok_or(LedgerError::UnknownInvoice(invoice_id))?;
invoice.sends.push(sent);
Ok(())
}
/// Look up an invoice.
pub fn get_invoice(&self, id: InvoiceId) -> Option<&Invoice> {
self.invoices.get(&id)