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

+57
View File
@@ -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<Vec<String>, 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::*;