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
@@ -12,8 +12,8 @@
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::LedgerError;
|
||||
use ledger_core::arap;
|
||||
use ledger_core::arap::DocumentLine;
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia};
|
||||
use ledger_core::{AccountType, Currency, DocRef, Money, Side};
|
||||
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
@@ -1054,3 +1054,190 @@ fn preview_invoice_and_bill_journal_nothing() {
|
||||
);
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// A send, as these tests record one (plan P4b S5, A-4).
|
||||
fn sent(on: NaiveDate, kind: SendKind, reference: Option<&str>) -> Sent {
|
||||
Sent {
|
||||
on,
|
||||
by: "agent".into(),
|
||||
via: SentVia::Email,
|
||||
to: vec!["billing@fernhill.example".into()],
|
||||
kind,
|
||||
reference: reference.map(str::to_string),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// R-A step 3 (plan P4b S5): `invoice.sent` is `documents_v2`'s, so books
|
||||
/// that haven't declared it refuse a send, journaling nothing; after the
|
||||
/// owner's go-ahead it journals, and the reopened books hold it.
|
||||
#[test]
|
||||
fn mark_sent_waits_for_documents_v2() {
|
||||
let dir = test_dir("sent-waits");
|
||||
let mut s = Store::create_in_format(
|
||||
&dir.0,
|
||||
Currency::USD,
|
||||
&ledger_store::JournalFormat::default(),
|
||||
)
|
||||
.unwrap();
|
||||
let c = s.add_customer("Fernhill Labs").unwrap();
|
||||
let inv = s
|
||||
.create_invoice(
|
||||
c,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![line("Design", 10_000)],
|
||||
)
|
||||
.unwrap();
|
||||
let head = s.head();
|
||||
let from = journal_len(&dir.0);
|
||||
let one = sent(d(2026, 3, 2), SendKind::Original, None);
|
||||
match s.mark_sent(inv, one.clone()) {
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert_eq!(need, "the journal feature documents_v2")
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
assert_eq!(s.head(), head, "a refusal journals nothing");
|
||||
assert_eq!(journal_len(&dir.0), from);
|
||||
assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead");
|
||||
s.mark_sent(inv, one).unwrap();
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
let reopened = Store::open(&dir.0).unwrap();
|
||||
reopened.verify().unwrap();
|
||||
assert!(
|
||||
live.differs_from(reopened.ledger()).is_empty(),
|
||||
"{:?}",
|
||||
live.differs_from(reopened.ledger())
|
||||
);
|
||||
assert_eq!(reopened.ledger().get_invoice(inv).unwrap().sends.len(), 1);
|
||||
}
|
||||
|
||||
/// R-A and R-B (plan P4b S5): a send journals one `invoice.sent` line with
|
||||
/// the whole record, replays into the invoice, and projects into
|
||||
/// `invoice_sends`; two sends keep their order.
|
||||
#[test]
|
||||
fn sends_replay_and_project() {
|
||||
let dir = test_dir("sent-replay");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let c = s.add_customer("Fernhill Labs").unwrap();
|
||||
let inv = s
|
||||
.create_invoice(
|
||||
c,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![line("Design", 10_000)],
|
||||
)
|
||||
.unwrap();
|
||||
let from = journal_len(&dir.0);
|
||||
let original = sent(d(2026, 3, 2), SendKind::Original, None);
|
||||
let reminder = sent(d(2026, 3, 20), SendKind::Reminder, Some("msg-2"));
|
||||
s.mark_sent(inv, original.clone()).unwrap();
|
||||
s.mark_sent(inv, reminder.clone()).unwrap();
|
||||
let got = since(&dir.0, from);
|
||||
assert_eq!(got.len(), 2, "{got:?}");
|
||||
assert_eq!(got[0].0, "invoice.sent");
|
||||
assert_eq!(got[0].1["invoice_id"], json!(inv));
|
||||
assert_eq!(got[0].1["on"], json!("2026-03-02"));
|
||||
assert_eq!(got[0].1["by"], json!("agent"));
|
||||
assert_eq!(got[0].1["via"], json!("email"));
|
||||
assert_eq!(got[0].1["to"], json!(["billing@fernhill.example"]));
|
||||
assert_eq!(got[0].1["kind"], json!("original"));
|
||||
assert_eq!(got[0].1.get("reference"), None, "absent, not null");
|
||||
assert_eq!(got[1].1["kind"], json!("reminder"));
|
||||
assert_eq!(got[1].1["reference"], json!("msg-2"));
|
||||
s.verify().unwrap();
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
let reopened = Store::open(&dir.0).unwrap();
|
||||
reopened.verify().unwrap();
|
||||
assert!(
|
||||
live.differs_from(reopened.ledger()).is_empty(),
|
||||
"{:?}",
|
||||
live.differs_from(reopened.ledger())
|
||||
);
|
||||
assert_eq!(
|
||||
reopened.ledger().get_invoice(inv).unwrap().sends,
|
||||
vec![original, reminder]
|
||||
);
|
||||
}
|
||||
|
||||
/// A send changes no figure (plan P4b S5, B-38): on the fixture book an
|
||||
/// earlier version wrote, every report, every derived document figure and
|
||||
/// the invoice's whole DocState are the same before and after it.
|
||||
#[test]
|
||||
fn a_send_changes_no_figure() {
|
||||
let dir = fixture("sent-figure");
|
||||
let mut s = Store::open(&dir.0).unwrap();
|
||||
s.upgrade_format().unwrap();
|
||||
let before = ledger_core::reports::snapshot(s.ledger()).unwrap();
|
||||
let state_before = s.ledger().invoice_state(1, d(9999, 12, 31)).unwrap();
|
||||
let cached_before: Vec<_> = s
|
||||
.ledger()
|
||||
.invoices()
|
||||
.into_iter()
|
||||
.map(|i| (i.id, i.amount_paid, i.owed, i.status))
|
||||
.collect();
|
||||
s.mark_sent(1, sent(d(2026, 4, 1), SendKind::Original, None))
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
ledger_core::reports::snapshot(s.ledger()).unwrap(),
|
||||
before,
|
||||
"a send moves no report"
|
||||
);
|
||||
assert_eq!(
|
||||
s.ledger().invoice_state(1, d(9999, 12, 31)).unwrap(),
|
||||
state_before,
|
||||
"a send moves no DocState"
|
||||
);
|
||||
assert_eq!(
|
||||
s.ledger()
|
||||
.invoices()
|
||||
.into_iter()
|
||||
.map(|i| (i.id, i.amount_paid, i.owed, i.status))
|
||||
.collect::<Vec<_>>(),
|
||||
cached_before,
|
||||
"and no derived cache"
|
||||
);
|
||||
assert_eq!(
|
||||
trial_balance_json(&s),
|
||||
golden()["trial_balance"],
|
||||
"the golden's general ledger, a send included"
|
||||
);
|
||||
assert_eq!(s.ledger().get_invoice(1).unwrap().sends.len(), 1);
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// A void invoice is refused a send (plan P4b S5, `DocumentVoid`),
|
||||
/// journaling nothing; an invoice open on the send's date but voided after
|
||||
/// it keeps the send that happened first.
|
||||
#[test]
|
||||
fn mark_sent_on_void_refused() {
|
||||
let dir = test_dir("sent-void");
|
||||
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
|
||||
let c = s.add_customer("Fernhill Labs").unwrap();
|
||||
let inv = s
|
||||
.create_invoice(
|
||||
c,
|
||||
d(2026, 3, 1),
|
||||
d(2026, 3, 31),
|
||||
vec![line("Design", 10_000)],
|
||||
)
|
||||
.unwrap();
|
||||
s.void_document(DocRef::Invoice(inv), d(2026, 3, 10), "duplicate")
|
||||
.unwrap();
|
||||
let head = s.head();
|
||||
let from = journal_len(&dir.0);
|
||||
match s.mark_sent(inv, sent(d(2026, 3, 11), SendKind::Original, None)) {
|
||||
Err(StoreError::Ledger(LedgerError::DocumentVoid(id))) => assert_eq!(id, inv),
|
||||
other => panic!("expected DocumentVoid, got {other:?}"),
|
||||
}
|
||||
assert_eq!(s.head(), head, "a refusal journals nothing");
|
||||
assert_eq!(journal_len(&dir.0), from);
|
||||
// Dated before the void, the send was a real one: it replays.
|
||||
s.mark_sent(inv, sent(d(2026, 3, 5), SendKind::Original, None))
|
||||
.unwrap();
|
||||
assert_eq!(s.ledger().get_invoice(inv).unwrap().sends.len(), 1);
|
||||
s.verify().unwrap();
|
||||
}
|
||||
Reference in new issue
Block a user