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
@@ -243,6 +243,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"fx.rate_set",
|
||||
"invoice.created",
|
||||
"invoice.payment_received",
|
||||
"invoice.sent",
|
||||
"instrument.added",
|
||||
FORMAT_SET,
|
||||
"journal.tail_repaired",
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 16;
|
||||
pub const SCHEMA_VERSION: u32 = 17;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -417,5 +417,22 @@ ALTER TABLE bills ADD COLUMN received_on TEXT;
|
||||
ALTER TABLE bills ADD COLUMN terms TEXT;
|
||||
ALTER TABLE bills ADD COLUMN memo TEXT;
|
||||
ALTER TABLE bills ADD COLUMN duplicate_reason TEXT;
|
||||
"#,
|
||||
r#"
|
||||
-- v17: sent tracking (plan P4b S5, `invoice.sent`): one row per recorded
|
||||
-- send of an invoice, in the order journaled. A send moves no figure and
|
||||
-- no ledger line, so this table is only the projection's copy of the
|
||||
-- journal's record (parity with replay). `on` and `by` are quoted: `on`
|
||||
-- is an SQL keyword.
|
||||
CREATE TABLE IF NOT EXISTS invoice_sends(
|
||||
invoice_id INTEGER NOT NULL,
|
||||
"on" TEXT NOT NULL,
|
||||
"by" TEXT NOT NULL,
|
||||
via TEXT NOT NULL,
|
||||
to_json TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
reference TEXT NULL,
|
||||
note TEXT NULL
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -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" => {}
|
||||
|
||||
Reference in new issue
Block a user