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
@@ -742,6 +742,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")]
|
||||
@@ -752,6 +782,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)]
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -5724,6 +5724,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)
|
||||
|
||||
Reference in new issue
Block a user