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:
1 parent
ed637f0e27
commit
d1fb512e4b
16 files changed
+979
-5
No files matched your search
@@ -16,8 +16,9 @@
|
||||
//! ```
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::format::{EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES};
|
||||
use ledger_store::format::{DOCUMENTS_V2, EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES};
|
||||
use ledger_store::journal::{canonical_json, sha256_hex};
|
||||
use ledger_store::{
|
||||
JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store,
|
||||
@@ -1468,3 +1469,83 @@ fn the_replay_fuzzer_writes_every_event_type() {
|
||||
"the replay fuzzer never writes {missing:?}"
|
||||
);
|
||||
}
|
||||
|
||||
/// Done-when (plan P4b S5): the send event is this version's, it needs
|
||||
/// `documents_v2`, and the books declare that feature before the first
|
||||
/// `invoice.sent` line, so a version without it stops at the declaration
|
||||
/// and reports the books as newer, never corrupt (rules.md B-27).
|
||||
#[test]
|
||||
fn an_older_version_stops_at_invoice_sent() {
|
||||
assert!(EVENT_TYPES.contains(&"invoice.sent"));
|
||||
let older = JournalFormat {
|
||||
format: SUPPORTED_JOURNAL_FORMAT,
|
||||
features: KNOWN_FEATURES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| *f != DOCUMENTS_V2)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
};
|
||||
assert!(
|
||||
matches!(
|
||||
older.require_feature(DOCUMENTS_V2),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
),
|
||||
"a reader without documents_v2 waits for it"
|
||||
);
|
||||
let dir = test_dir("sent-gate");
|
||||
let mut store =
|
||||
Store::create_in_format(&dir, Currency::USD, &JournalFormat::default()).unwrap();
|
||||
let customer = store.add_customer("Fernhill Labs").unwrap();
|
||||
let date = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap();
|
||||
let inv = store
|
||||
.create_invoice(
|
||||
customer,
|
||||
date,
|
||||
date + chrono::Days::new(30),
|
||||
vec![DocumentLine {
|
||||
description: "Design".into(),
|
||||
amount: Decimal::new(10_000, 2),
|
||||
class_id: None,
|
||||
account: None,
|
||||
quantity: None,
|
||||
unit_price: None,
|
||||
}],
|
||||
)
|
||||
.unwrap();
|
||||
store.upgrade_format().unwrap().expect("the go-ahead");
|
||||
store
|
||||
.mark_sent(
|
||||
inv,
|
||||
Sent {
|
||||
on: date,
|
||||
by: "agent".into(),
|
||||
via: SentVia::Email,
|
||||
to: vec!["billing@fernhill.example".into()],
|
||||
kind: SendKind::Original,
|
||||
reference: None,
|
||||
note: None,
|
||||
},
|
||||
)
|
||||
.unwrap();
|
||||
let journal = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap();
|
||||
let mut declared = false;
|
||||
let mut sent = false;
|
||||
for line in journal.lines() {
|
||||
let v: serde_json::Value = serde_json::from_str(line).unwrap();
|
||||
if v["type"] == FORMAT_SET
|
||||
&& v["payload"]["features"]
|
||||
.as_array()
|
||||
.is_some_and(|f| f.iter().any(|x| x == DOCUMENTS_V2))
|
||||
{
|
||||
declared = true;
|
||||
}
|
||||
if v["type"] == "invoice.sent" {
|
||||
assert!(declared, "documents_v2 must be declared before the send");
|
||||
sent = true;
|
||||
}
|
||||
}
|
||||
assert!(sent);
|
||||
drop(store);
|
||||
Store::open(&dir).unwrap().verify().unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user