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

+55
View File
@@ -3698,6 +3698,36 @@ impl Store {
self.reverse_entry(target, date, &description)
}
/// Record that an invoice went out ([`Ledger::mark_sent`], plan P4b
/// S5): journaled as `invoice.sent` (feature `documents_v2`: an older
/// version refuses the type), then applied to the books. A send moves
/// no figure, so it journals one line and no entry.
///
/// ```text
/// GOAL: a send is journaled once and replays to the same record, in
/// every version that can read the books.
/// SOURCE: docs/spec/rules.md B-27, B-38; plan P4b S5, A-4.
/// SCENARIOS:
/// 1. IF the engine refuses it (unknown or void invoice, a date
/// before the invoice, no recipients, a text the books won't
/// keep): SHOULD refuse, journaling nothing.
/// 2. IF the books haven't declared documents_v2: SHOULD need the
/// feature, journaling nothing.
/// ```
pub fn mark_sent(
&mut self,
invoice_id: InvoiceId,
sent: ledger_core::arap::Sent,
) -> Result<(), StoreError> {
self.ensure_feature(crate::format::DOCUMENTS_V2)?;
self.rehearse(|l| l.mark_sent(invoice_id, sent.clone()))?;
let line =
self.append_journal("invoice.sent", crate::meta::invoice_sent(invoice_id, &sent))?;
self.ledger.mark_sent(invoice_id, sent)?;
apply_line_to_sqlite(&self.conn, &line)?;
Ok(())
}
// ------------------------------------------------------------------
// Foreign exchange
// ------------------------------------------------------------------
@@ -6979,6 +7009,11 @@ fn replay_line(
parse_bill_fields(p)?,
)?;
}
"invoice.sent" => {
// A send moves no figure: it only restores the invoice's sends.
let (invoice_id, sent) = crate::meta::invoice_sent_of(line)?;
ledger.replay_invoice_sent(invoice_id, sent)?;
}
"invoice.payment_received" => {
// The payment entry was already rebuilt; this only moves the
// invoice's paid amount and status.
@@ -7961,6 +7996,26 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
],
)?;
}
// A send is a document's own record, not a ledger line (plan P4b
// S5): one row each, for the projection to agree with replay.
"invoice.sent" => {
let to = p
.get("to")
.ok_or_else(|| StoreError::JournalCorrupt("invoice.sent without to".into()))?;
conn.execute(
"INSERT INTO invoice_sends(invoice_id, \"on\", \"by\", via, to_json, kind, reference, note) VALUES (?,?,?,?,?,?,?,?)",
params![
get_u64(p, "invoice_id")? as i64,
get_str(p, "on")?,
get_str(p, "by")?,
get_str(p, "via")?,
to.to_string(),
get_str(p, "kind")?,
p.get("reference").and_then(|v| v.as_str()),
p.get("note").and_then(|v| v.as_str()),
],
)?;
}
// A payment moves no document row: what a document is paid is read
// from the ledger lines tagged with it (schema v15, rules.md B-38).
"invoice.payment_received" => {}