merge: P4b S5, sent tracking (branch S5, ad649be3)
The second of the paused branches. invoice.sent, Ledger::mark_sent (with its Lean step), books.mark_sent in its own group, the two attention items, schema 17. Conflicts, resolved as plan 8.0.2 says: the shared over-the-wire test file crates/ledger-cli/tests/documents.rs (S4.4's file kept whole, S5's test appended, its create call given the confirmed: true the S4.4 contract now needs), types.rs (both appends kept), docs/parity.md (the review's oracle row and S5's new row). Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
3a3f03d884
44 files changed
+1847
-30
No files matched your search
@@ -250,3 +250,50 @@ fn the_viewer_and_books_start_see_the_same_attention() {
|
|||||||
compare_and_expect(&place, &mut wire, today, "doesn't match the journal");
|
compare_and_expect(&place, &mut wire, today, "doesn't match the journal");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Plan P4b S5, step 4 (R-F): an issued invoice nobody marked sent is on
|
||||||
|
/// the viewer's list and in `books.start`'s next steps, from the one
|
||||||
|
/// engine, with the step naming `books.mark_sent`.
|
||||||
|
#[test]
|
||||||
|
fn an_unsent_invoice_is_on_both_lists() {
|
||||||
|
let today = ledger_core::settings::today_in(None);
|
||||||
|
let place = common::Place::new("attention-unsent-parity");
|
||||||
|
let dir = place.books_data();
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
let mut st = ledger_store::Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
st.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let client = st.add_customer("Harbor Client").unwrap();
|
||||||
|
st.create_invoice_with(
|
||||||
|
client,
|
||||||
|
today - chrono::Days::new(40),
|
||||||
|
today + chrono::Days::new(20),
|
||||||
|
vec![ledger_core::arap::DocumentLine {
|
||||||
|
description: "Consulting".into(),
|
||||||
|
amount: Decimal::new(50_000, 2),
|
||||||
|
class_id: None,
|
||||||
|
account: None,
|
||||||
|
quantity: None,
|
||||||
|
unit_price: None,
|
||||||
|
}],
|
||||||
|
ledger_core::arap::InvoiceFields {
|
||||||
|
number: Some("INV-0001".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(st);
|
||||||
|
let mut wire = place.start(Some(SETTINGS));
|
||||||
|
wire.initialize("attention-unsent-parity");
|
||||||
|
compare_and_expect(&place, &mut wire, today, "Invoice INV-0001 was never sent");
|
||||||
|
// The step offers the fix: books.mark_sent on that invoice.
|
||||||
|
let answer = start(&mut wire);
|
||||||
|
let step = answer["next_steps"]
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.find(|s| s["why"] == json!("Invoice INV-0001 was never sent"))
|
||||||
|
.unwrap_or_else(|| panic!("no unsent step: {answer}"));
|
||||||
|
assert_eq!(step["tool"], json!("books.mark_sent"), "{answer}");
|
||||||
|
assert_eq!(step["args"]["invoice"], json!("invoice:1"), "{answer}");
|
||||||
|
}
|
||||||
@@ -709,3 +709,89 @@ fn an_invoice_bill_to_uses_the_billing_details() {
|
|||||||
assert_eq!(bill_to["phone"], "555-0100", "{bill_to}");
|
assert_eq!(bill_to["phone"], "555-0100", "{bill_to}");
|
||||||
assert_eq!(bill_to["shipping_address"], "Gate 2", "{bill_to}");
|
assert_eq!(bill_to["shipping_address"], "Gate 2", "{bill_to}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P4b S5: sent tracking (merged from branch S5).
|
||||||
|
|
||||||
|
/// Two sends on one invoice: the first the original (defaults: the
|
||||||
|
/// token's name, email, today's company day), the second a reminder with
|
||||||
|
/// the channel's message id; then `books.documents` shows the last send
|
||||||
|
/// and `books.document` the whole record.
|
||||||
|
#[test]
|
||||||
|
fn mark_sent_records_who_and_how() {
|
||||||
|
let books = common::Books::new("p4b-s5-mark-sent");
|
||||||
|
let mut wire = books.session("p4b-s5");
|
||||||
|
let customer =
|
||||||
|
common::answer_of(&wire.call("books.add_customer", json!({"name": "Fernhill Labs"})))
|
||||||
|
.unwrap()["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
let invoice = common::answer_of(&wire.call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"party_id": customer, "date": "2026-03-01", "due_date": "2026-03-31",
|
||||||
|
"lines": [{"description": "Design", "amount": "1200.00"}], "confirmed": true}),
|
||||||
|
))
|
||||||
|
.unwrap()["id"]
|
||||||
|
.as_u64()
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let first = common::answer_of(&wire.call(
|
||||||
|
"books.mark_sent",
|
||||||
|
json!({"invoice": format!("invoice:{invoice}"), "on": "2026-03-05",
|
||||||
|
"by": "Avery Quill", "recipients": ["billing@fernhill.example"]}),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(first["invoice"], json!(format!("invoice:{invoice}")));
|
||||||
|
assert_eq!(first["sends"][0]["on"], json!("2026-03-05"));
|
||||||
|
assert_eq!(first["sends"][0]["by"], json!("Avery Quill"));
|
||||||
|
assert_eq!(
|
||||||
|
first["sends"][0]["via"],
|
||||||
|
json!("email"),
|
||||||
|
"the default channel"
|
||||||
|
);
|
||||||
|
assert_eq!(first["sends"][0]["to"], json!(["billing@fernhill.example"]));
|
||||||
|
assert_eq!(
|
||||||
|
first["sends"][0]["kind"],
|
||||||
|
json!("original"),
|
||||||
|
"the first send is the original"
|
||||||
|
);
|
||||||
|
|
||||||
|
// The second names no `by`, `via` or `kind`: this session's token
|
||||||
|
// (the agent), email, and a reminder now that one send is recorded.
|
||||||
|
let second = common::answer_of(&wire.call(
|
||||||
|
"books.mark_sent",
|
||||||
|
json!({"invoice": invoice.to_string(), "on": "2026-03-20",
|
||||||
|
"recipients": ["billing@fernhill.example"], "reference": "msg-2",
|
||||||
|
"note": "Second copy"}),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
let sends = second["sends"].as_array().unwrap();
|
||||||
|
assert_eq!(sends.len(), 2, "{second}");
|
||||||
|
assert_eq!(sends[1]["by"], json!("test"), "the token's name");
|
||||||
|
assert_eq!(sends[1]["via"], json!("email"));
|
||||||
|
assert_eq!(sends[1]["kind"], json!("reminder"));
|
||||||
|
assert_eq!(sends[1]["reference"], json!("msg-2"));
|
||||||
|
assert_eq!(sends[1]["note"], json!("Second copy"));
|
||||||
|
|
||||||
|
let listed =
|
||||||
|
common::answer_of(&wire.call("books.documents", json!({"kind": "invoices"}))).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
listed["documents"][0]["ref"],
|
||||||
|
json!(format!("invoice:{invoice}"))
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
listed["documents"][0]["last_sent"],
|
||||||
|
json!("2026-03-20"),
|
||||||
|
"the list shows the latest send"
|
||||||
|
);
|
||||||
|
let one = common::answer_of(&wire.call(
|
||||||
|
"books.document",
|
||||||
|
json!({"ref": format!("invoice:{invoice}"), "as_of": "2026-03-31"}),
|
||||||
|
))
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(one["sends"].as_array().unwrap().len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
one["outstanding"],
|
||||||
|
json!("1200.00"),
|
||||||
|
"a send changes no figure"
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -760,6 +760,16 @@ fn books_p2_served_cut_over_whole() {
|
|||||||
// A project by name alone is a plain class, which P2 reads.
|
// A project by name alone is a plain class, which P2 reads.
|
||||||
a.call("books.add_project", json!({"name": "Lighthouse"}))
|
a.call("books.add_project", json!({"name": "Lighthouse"}))
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// An invoice for the send below: a document P2 reads (no new fields),
|
||||||
|
// so it is written before the go-ahead; its send waits for it.
|
||||||
|
let invoice = a
|
||||||
|
.call(
|
||||||
|
"books.create_invoice",
|
||||||
|
json!({"party_id": 1, "date": "2026-01-05", "due_date": "2026-02-04",
|
||||||
|
"lines": [{"description": "Design", "amount": "400.00"}], "confirmed": true}),
|
||||||
|
)
|
||||||
|
.unwrap()["id"]
|
||||||
|
.clone();
|
||||||
let waiting = journal(&dir);
|
let waiting = journal(&dir);
|
||||||
let refused: Vec<(&str, Value)> = vec![
|
let refused: Vec<(&str, Value)> = vec![
|
||||||
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and
|
// An invoice carrying documents_v2's fields (terms: P4b S4.4) and
|
||||||
@@ -793,6 +803,13 @@ fn books_p2_served_cut_over_whole() {
|
|||||||
json!({"decided_by": "Avery Quill",
|
json!({"decided_by": "Avery Quill",
|
||||||
"changes": [{"key": "invoicing.default_terms", "value": "Net 30"}]}),
|
"changes": [{"key": "invoicing.default_terms", "value": "Net 30"}]}),
|
||||||
),
|
),
|
||||||
|
// A recorded send (invoice.sent, documents_v2, P4b S5): an older
|
||||||
|
// version refuses the type.
|
||||||
|
(
|
||||||
|
"books.mark_sent",
|
||||||
|
json!({"invoice": invoice.to_string(), "on": "2026-01-06",
|
||||||
|
"by": "Avery Quill", "recipients": ["billing@fernhill.example"]}),
|
||||||
|
),
|
||||||
(
|
(
|
||||||
"books.add_account",
|
"books.add_account",
|
||||||
json!({"code": "4900", "name": "Gain on sale", "account_type": "revenue",
|
json!({"code": "4900", "name": "Gain on sale", "account_type": "revenue",
|
||||||
|
|||||||
@@ -125,7 +125,7 @@ fn mcp_end_to_end() {
|
|||||||
// Tool listing: the narrow tools; chart is read-only.
|
// Tool listing: the narrow tools; chart is read-only.
|
||||||
let list = mcp.request("tools/list", json!({}));
|
let list = mcp.request("tools/list", json!({}));
|
||||||
let tools = list["result"]["tools"].as_array().unwrap();
|
let tools = list["result"]["tools"].as_array().unwrap();
|
||||||
assert_eq!(tools.len(), 122, "expected 122 tools, got {}", tools.len());
|
assert_eq!(tools.len(), 123, "expected 123 tools, got {}", tools.len());
|
||||||
// Files come in as tool input: books.begin_upload, never a local path.
|
// Files come in as tool input: books.begin_upload, never a local path.
|
||||||
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
assert!(tools.iter().any(|t| t["name"] == "books.begin_upload"));
|
||||||
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
assert!(!tools.iter().any(|t| t["name"] == "books.import_local_file"));
|
||||||
|
|||||||
@@ -906,6 +906,13 @@ fn happy_path(name: &str) -> Mcp {
|
|||||||
json!({"kind": "invoices", "status": "all"}),
|
json!({"kind": "invoices", "status": "all"}),
|
||||||
);
|
);
|
||||||
m.ok("books.document", json!({"ref": format!("invoice:{inv}")}));
|
m.ok("books.document", json!({"ref": format!("invoice:{inv}")}));
|
||||||
|
// P4b S5: the invoice went out (the books record the send, never make it).
|
||||||
|
m.ok(
|
||||||
|
"books.mark_sent",
|
||||||
|
json!({"invoice": format!("invoice:{inv}"), "on": "2026-02-03",
|
||||||
|
"by": "Avery Quill", "recipients": ["billing@client.example"],
|
||||||
|
"reference": "msg-1"}),
|
||||||
|
);
|
||||||
|
|
||||||
// Fixed assets.
|
// Fixed assets.
|
||||||
let registered = m.ok(
|
let registered = m.ok(
|
||||||
|
|||||||
@@ -789,6 +789,36 @@ pub enum SentVia {
|
|||||||
Other(String),
|
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).
|
/// Whether a send was the original or a reminder (plan P4b S5).
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
@@ -799,6 +829,26 @@ pub enum SendKind {
|
|||||||
Reminder,
|
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
|
/// 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.
|
/// whom, and the channel's own reference. A send changes no figure.
|
||||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
|||||||
@@ -778,6 +778,17 @@ pub enum LedgerError {
|
|||||||
#[error("{0}")]
|
#[error("{0}")]
|
||||||
InvalidVoid(String),
|
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.
|
/// Invoice/bill due date is before its date.
|
||||||
#[error("due date {0} is before document date")]
|
#[error("due date {0} is before document date")]
|
||||||
DueBeforeDate(String),
|
DueBeforeDate(String),
|
||||||
|
|||||||
@@ -5751,6 +5751,108 @@ impl Ledger {
|
|||||||
Ok(())
|
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.
|
/// Look up an invoice.
|
||||||
pub fn get_invoice(&self, id: InvoiceId) -> Option<&Invoice> {
|
pub fn get_invoice(&self, id: InvoiceId) -> Option<&Invoice> {
|
||||||
self.invoices.get(&id)
|
self.invoices.get(&id)
|
||||||
|
|||||||
@@ -67,6 +67,7 @@ fn route(t: ledger_package::Target) -> Option<Route> {
|
|||||||
ledger_package::Target::Assets => Route::Assets,
|
ledger_package::Target::Assets => Route::Assets,
|
||||||
ledger_package::Target::Asset(a) => Route::Asset(a),
|
ledger_package::Target::Asset(a) => Route::Asset(a),
|
||||||
ledger_package::Target::Documents => Route::Documents,
|
ledger_package::Target::Documents => Route::Documents,
|
||||||
|
ledger_package::Target::Invoice(i) => Route::Invoice(i),
|
||||||
ledger_package::Target::Journal => Route::Journal,
|
ledger_package::Target::Journal => Route::Journal,
|
||||||
ledger_package::Target::Report(r) => Route::Reports(match r {
|
ledger_package::Target::Report(r) => Route::Reports(match r {
|
||||||
ledger_package::Report::Vendor1099 => ReportKind::Vendor1099,
|
ledger_package::Report::Vendor1099 => ReportKind::Vendor1099,
|
||||||
|
|||||||
@@ -807,6 +807,24 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
|||||||
format!("Payment received, {}", amount(p)),
|
format!("Payment received, {}", amount(p)),
|
||||||
entry_id.map(Route::Entry),
|
entry_id.map(Route::Entry),
|
||||||
),
|
),
|
||||||
|
"invoice.sent" => {
|
||||||
|
let invoice = p.get("invoice_id").and_then(|v| v.as_u64()).unwrap_or(0);
|
||||||
|
let via = p
|
||||||
|
.get("via")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|v| v.strip_prefix("other:").unwrap_or(v))
|
||||||
|
.unwrap_or("a channel");
|
||||||
|
let what = if p.get("kind").and_then(|v| v.as_str()) == Some("reminder") {
|
||||||
|
"reminder "
|
||||||
|
} else {
|
||||||
|
""
|
||||||
|
};
|
||||||
|
ev(
|
||||||
|
icon::INVOICE,
|
||||||
|
format!("Invoice {invoice} {what}sent by {via}"),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
}
|
||||||
"bill.paid" => ev(
|
"bill.paid" => ev(
|
||||||
icon::MONEY,
|
icon::MONEY,
|
||||||
format!("Bill paid, {}", amount(p)),
|
format!("Bill paid, {}", amount(p)),
|
||||||
|
|||||||
@@ -8,6 +8,7 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use egui_kittest::Harness;
|
use egui_kittest::Harness;
|
||||||
use egui_kittest::kittest::Queryable;
|
use egui_kittest::kittest::Queryable;
|
||||||
|
use ledger_core::arap::{InvoiceFields, SendKind, Sent, SentVia};
|
||||||
use ledger_core::{AccountType, Currency, DocumentLine};
|
use ledger_core::{AccountType, Currency, DocumentLine};
|
||||||
use ledger_gui::nav::Route;
|
use ledger_gui::nav::Route;
|
||||||
use ledger_gui::{App, Source, data};
|
use ledger_gui::{App, Source, data};
|
||||||
@@ -208,3 +209,128 @@ fn books_without_documents_show_no_overview_tiles_for_them() {
|
|||||||
shot(&mut h, "receivables-empty");
|
shot(&mut h, "receivables-empty");
|
||||||
assert!(h.query_by_label("No invoices").is_some());
|
assert!(h.query_by_label("No invoices").is_some());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Books with one issued invoice (a number, so the sent-tracking items
|
||||||
|
/// apply) and a checking account, for the attention items (plan P4b S5).
|
||||||
|
fn issued_invoice(dir: &TempDir, due: NaiveDate) -> u64 {
|
||||||
|
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||||
|
s.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let client = s.add_customer("Harbor Client").unwrap();
|
||||||
|
s.create_invoice_with(
|
||||||
|
client,
|
||||||
|
d(2026, 2, 1),
|
||||||
|
due,
|
||||||
|
vec![line("Consulting", 50_000)],
|
||||||
|
InvoiceFields {
|
||||||
|
number: Some("INV-0001".into()),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A send as these tests record one.
|
||||||
|
fn sent(on: NaiveDate) -> Sent {
|
||||||
|
Sent {
|
||||||
|
on,
|
||||||
|
by: "agent".into(),
|
||||||
|
via: SentVia::Email,
|
||||||
|
to: vec!["billing@harbor.example".into()],
|
||||||
|
kind: SendKind::Original,
|
||||||
|
reference: None,
|
||||||
|
note: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attention_titles(dir: &TempDir) -> Vec<String> {
|
||||||
|
data::load_once(&Source {
|
||||||
|
data_dir: dir.0.join("books-data"),
|
||||||
|
today: Some(d(2026, 3, 15)),
|
||||||
|
})
|
||||||
|
.unwrap()
|
||||||
|
.attention
|
||||||
|
.into_iter()
|
||||||
|
.map(|a| a.title)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Plan P4b S5, step 4 (R-F): an issued invoice nobody marked sent, past
|
||||||
|
/// `invoicing.unsent_days` (2 by default), is a to-do that opens the
|
||||||
|
/// invoice; recording a send clears it. The overview draws it (the
|
||||||
|
/// screenshot is the R-F pattern's).
|
||||||
|
#[test]
|
||||||
|
fn an_unsent_invoice_is_listed_until_a_send_clears_it() {
|
||||||
|
let dir = TempDir::new("attention-unsent");
|
||||||
|
let id = issued_invoice(&dir, d(2026, 4, 30));
|
||||||
|
let titles = attention_titles(&dir);
|
||||||
|
assert!(
|
||||||
|
titles
|
||||||
|
.iter()
|
||||||
|
.any(|t| t == "Invoice INV-0001 was never sent"),
|
||||||
|
"{titles:?}"
|
||||||
|
);
|
||||||
|
let mut h = open(&dir, Route::Overview);
|
||||||
|
assert!(
|
||||||
|
h.query_by_label_contains("Invoice INV-0001 was never sent")
|
||||||
|
.is_some()
|
||||||
|
);
|
||||||
|
shot(&mut h, "attention-unsent-invoice");
|
||||||
|
let mut s = Store::open(&dir.0.join("books-data")).unwrap();
|
||||||
|
s.mark_sent(id, sent(d(2026, 2, 10))).unwrap();
|
||||||
|
drop(s);
|
||||||
|
let titles = attention_titles(&dir);
|
||||||
|
assert!(
|
||||||
|
!titles.iter().any(|t| t.contains("never sent")),
|
||||||
|
"a send clears the item: {titles:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The legacy case: an invoice with no number (issued before
|
||||||
|
/// `documents_v2`) is exempt from "never sent", however old.
|
||||||
|
#[test]
|
||||||
|
fn a_legacy_invoice_with_no_number_is_never_listed_as_unsent() {
|
||||||
|
let dir = TempDir::new("attention-legacy");
|
||||||
|
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
|
||||||
|
s.add_account("1000", "Checking", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let client = s.add_customer("Harbor Client").unwrap();
|
||||||
|
s.create_invoice(
|
||||||
|
client,
|
||||||
|
d(2026, 1, 5),
|
||||||
|
d(2026, 4, 30),
|
||||||
|
vec![line("Consulting", 50_000)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
drop(s);
|
||||||
|
let titles = attention_titles(&dir);
|
||||||
|
assert!(
|
||||||
|
!titles.iter().any(|t| t.contains("never sent")),
|
||||||
|
"{titles:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// An overdue invoice whose last send is older than
|
||||||
|
/// `invoicing.reminder_days` (14 by default) asks for a reminder, on the
|
||||||
|
/// invoice's page.
|
||||||
|
#[test]
|
||||||
|
fn an_overdue_invoice_with_an_old_send_is_asked_to_remind() {
|
||||||
|
let dir = TempDir::new("attention-remind");
|
||||||
|
let id = issued_invoice(&dir, d(2026, 3, 1));
|
||||||
|
let mut s = Store::open(&dir.0.join("books-data")).unwrap();
|
||||||
|
s.mark_sent(id, sent(d(2026, 2, 20))).unwrap();
|
||||||
|
drop(s);
|
||||||
|
let books = data::load_once(&Source {
|
||||||
|
data_dir: dir.0.join("books-data"),
|
||||||
|
today: Some(d(2026, 3, 15)),
|
||||||
|
})
|
||||||
|
.unwrap();
|
||||||
|
let item = books
|
||||||
|
.attention
|
||||||
|
.iter()
|
||||||
|
.find(|a| a.title.contains("remind the customer"))
|
||||||
|
.unwrap_or_else(|| panic!("no reminder item: {:?}", attention_titles(&dir)));
|
||||||
|
assert_eq!(item.target, Some(Route::Invoice(id)));
|
||||||
|
assert!(item.detail.contains("14 days overdue"), "{}", item.detail);
|
||||||
|
assert!(item.prompt.contains("books.mark_sent"), "{}", item.prompt);
|
||||||
|
}
|
||||||
@@ -0,0 +1,28 @@
|
|||||||
|
# Invoices and bills
|
||||||
|
|
||||||
|
An invoice bills a customer: it posts Dr Accounts Receivable and Cr the
|
||||||
|
revenue its lines name. A bill is a vendor's invoice to us: it posts Dr
|
||||||
|
the expense or asset its lines name, and Cr Accounts Payable. A document's
|
||||||
|
own figures are read from the ledger lines tagged with it, never kept on
|
||||||
|
the document: a reversed payment, a void or a write-off comes out right
|
||||||
|
without another copy to correct (B-38).
|
||||||
|
|
||||||
|
## Sending an invoice
|
||||||
|
|
||||||
|
- The books never send mail, and never say they did. To send an invoice,
|
||||||
|
create and post it (`books.create_invoice`), hand its PDF to the human
|
||||||
|
(`books.document` with `pdf: true`), and let the owner or a delegate
|
||||||
|
send it.
|
||||||
|
- Then record the send with `books.mark_sent`: the invoice (`invoice:7`),
|
||||||
|
the day it went out, who sent it (`by`: the owner, or the agent acting
|
||||||
|
for them), how (`via`: `email`, or `mail`, `portal`, `hand`, or another
|
||||||
|
channel's own name), to whom (`to`: the bill-to email, else the
|
||||||
|
customer's email), whether it was the original or a reminder (`kind`:
|
||||||
|
`original` for the first send, `reminder` after), and the channel's own
|
||||||
|
reference when there is one (a message id).
|
||||||
|
- A send changes no figure and never moves the due date. Several sends
|
||||||
|
are allowed: each is recorded in order, and `books.documents` shows the
|
||||||
|
latest as `last_sent`.
|
||||||
|
- A void invoice can't be marked sent: it should never have reached the
|
||||||
|
customer. An invoice already open, partly paid, paid or written off can
|
||||||
|
(a copy for the customer's records, a reminder).
|
||||||
@@ -103,6 +103,10 @@ pub const SECTIONS: &[(&str, &str)] = &[
|
|||||||
"tracing-figures",
|
"tracing-figures",
|
||||||
include_str!("../handbook/tracing-figures.md"),
|
include_str!("../handbook/tracing-figures.md"),
|
||||||
),
|
),
|
||||||
|
(
|
||||||
|
"invoices-and-bills",
|
||||||
|
include_str!("../handbook/invoices-and-bills.md"),
|
||||||
|
),
|
||||||
];
|
];
|
||||||
|
|
||||||
/// The resource URI of the handbook's index, and the prefix of a section's.
|
/// The resource URI of the handbook's index, and the prefix of a section's.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ const C: Needs = PostWhenConfirmed;
|
|||||||
const K: Needs = PostForApprovalRules;
|
const K: Needs = PostForApprovalRules;
|
||||||
|
|
||||||
/// Every tool and what it needs (INVARIANT 1).
|
/// Every tool and what it needs (INVARIANT 1).
|
||||||
pub const TABLE: [(&str, Needs); 122] = [
|
pub const TABLE: [(&str, Needs); 123] = [
|
||||||
("books.handbook", R),
|
("books.handbook", R),
|
||||||
("books.start", R),
|
("books.start", R),
|
||||||
// Settings are committed at once: the settings commit of the plan.
|
// Settings are committed at once: the settings commit of the plan.
|
||||||
@@ -166,6 +166,8 @@ pub const TABLE: [(&str, Needs); 122] = [
|
|||||||
("books.receive_payment", P),
|
("books.receive_payment", P),
|
||||||
("books.pay_bill", P),
|
("books.pay_bill", P),
|
||||||
("books.void_document", P),
|
("books.void_document", P),
|
||||||
|
// Records a send: changes the books' record, never a figure.
|
||||||
|
("books.mark_sent", W),
|
||||||
("books.control_check", R),
|
("books.control_check", R),
|
||||||
("books.documents", R),
|
("books.documents", R),
|
||||||
("books.document", R),
|
("books.document", R),
|
||||||
|
|||||||
@@ -566,6 +566,7 @@ impl BooksServer {
|
|||||||
+ Self::instruments_router()
|
+ Self::instruments_router()
|
||||||
+ Self::projects_router()
|
+ Self::projects_router()
|
||||||
+ Self::documents_router()
|
+ Self::documents_router()
|
||||||
|
+ Self::sent_router()
|
||||||
+ Self::entries_router()
|
+ Self::entries_router()
|
||||||
+ Self::reports_router()
|
+ Self::reports_router()
|
||||||
+ Self::assets_router()
|
+ Self::assets_router()
|
||||||
@@ -1420,6 +1421,8 @@ mod reconciliation;
|
|||||||
mod reports_tools;
|
mod reports_tools;
|
||||||
#[path = "tools/schedules.rs"]
|
#[path = "tools/schedules.rs"]
|
||||||
mod schedules;
|
mod schedules;
|
||||||
|
#[path = "tools/sent.rs"]
|
||||||
|
mod sent;
|
||||||
#[path = "tools/settings.rs"]
|
#[path = "tools/settings.rs"]
|
||||||
mod settings_tools;
|
mod settings_tools;
|
||||||
#[path = "tools/tax.rs"]
|
#[path = "tools/tax.rs"]
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ fn annotations_complete() {
|
|||||||
const D: Hints = (Some(false), Some(true), None);
|
const D: Hints = (Some(false), Some(true), None);
|
||||||
const WI: Hints = (Some(false), Some(false), Some(true));
|
const WI: Hints = (Some(false), Some(false), Some(true));
|
||||||
const DI: Hints = (Some(false), Some(true), Some(true));
|
const DI: Hints = (Some(false), Some(true), Some(true));
|
||||||
let table: [(&str, Hints); 122] = [
|
let table: [(&str, Hints); 123] = [
|
||||||
("books.handbook", R),
|
("books.handbook", R),
|
||||||
("books.start", R),
|
("books.start", R),
|
||||||
("books.set_setting", DI),
|
("books.set_setting", DI),
|
||||||
@@ -145,6 +145,7 @@ fn annotations_complete() {
|
|||||||
("books.receive_payment", D),
|
("books.receive_payment", D),
|
||||||
("books.pay_bill", D),
|
("books.pay_bill", D),
|
||||||
("books.void_document", D),
|
("books.void_document", D),
|
||||||
|
("books.mark_sent", W),
|
||||||
("books.control_check", R),
|
("books.control_check", R),
|
||||||
("books.documents", R),
|
("books.documents", R),
|
||||||
("books.document", R),
|
("books.document", R),
|
||||||
|
|||||||
@@ -984,7 +984,13 @@ impl BooksServer {
|
|||||||
}
|
}
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let ledger = store.ledger();
|
||||||
let docs: Vec<(ledger_core::DocRef, String, NaiveDate, NaiveDate)> = if invoices {
|
let docs: Vec<(
|
||||||
|
ledger_core::DocRef,
|
||||||
|
String,
|
||||||
|
NaiveDate,
|
||||||
|
NaiveDate,
|
||||||
|
Option<String>,
|
||||||
|
)> = if invoices {
|
||||||
ledger
|
ledger
|
||||||
.invoices()
|
.invoices()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
@@ -993,7 +999,13 @@ impl BooksServer {
|
|||||||
.get_customer(i.customer_id)
|
.get_customer(i.customer_id)
|
||||||
.map(|c| c.name.clone())
|
.map(|c| c.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(ledger_core::DocRef::Invoice(i.id), who, i.date, i.due_date)
|
(
|
||||||
|
ledger_core::DocRef::Invoice(i.id),
|
||||||
|
who,
|
||||||
|
i.date,
|
||||||
|
i.due_date,
|
||||||
|
last_sent(&i.sends),
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
} else {
|
} else {
|
||||||
@@ -1005,14 +1017,20 @@ impl BooksServer {
|
|||||||
.get_vendor(b.vendor_id)
|
.get_vendor(b.vendor_id)
|
||||||
.map(|v| v.name.clone())
|
.map(|v| v.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(ledger_core::DocRef::Bill(b.id), who, b.date, b.due_date)
|
(
|
||||||
|
ledger_core::DocRef::Bill(b.id),
|
||||||
|
who,
|
||||||
|
b.date,
|
||||||
|
b.due_date,
|
||||||
|
None,
|
||||||
|
)
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
};
|
};
|
||||||
let party = p.party.as_deref().map(|s| s.trim().to_lowercase());
|
let party = p.party.as_deref().map(|s| s.trim().to_lowercase());
|
||||||
let mut rows = Vec::new();
|
let mut rows = Vec::new();
|
||||||
let mut owed = Decimal::ZERO;
|
let mut owed = Decimal::ZERO;
|
||||||
for (doc, who, date, due) in docs {
|
for (doc, who, date, due, last_sent) in docs {
|
||||||
if let Some(want_party) = &party
|
if let Some(want_party) = &party
|
||||||
&& who.to_lowercase() != *want_party
|
&& who.to_lowercase() != *want_party
|
||||||
&& doc_id(doc).to_string() != *want_party
|
&& doc_id(doc).to_string() != *want_party
|
||||||
@@ -1050,6 +1068,7 @@ impl BooksServer {
|
|||||||
outstanding: money(st.outstanding),
|
outstanding: money(st.outstanding),
|
||||||
status: key.to_string(),
|
status: key.to_string(),
|
||||||
overdue_days: st.overdue_days,
|
overdue_days: st.overdue_days,
|
||||||
|
last_sent,
|
||||||
},
|
},
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
@@ -1080,7 +1099,7 @@ impl BooksServer {
|
|||||||
let doc = parse_doc_ref(&p.r#ref)?;
|
let doc = parse_doc_ref(&p.r#ref)?;
|
||||||
let store = self.lock()?;
|
let store = self.lock()?;
|
||||||
let ledger = store.ledger();
|
let ledger = store.ledger();
|
||||||
let (who, date, due, entry_id, lines) = match doc {
|
let (who, date, due, entry_id, lines, sends) = match doc {
|
||||||
ledger_core::DocRef::Invoice(id) => {
|
ledger_core::DocRef::Invoice(id) => {
|
||||||
let i = ledger
|
let i = ledger
|
||||||
.get_invoice(id)
|
.get_invoice(id)
|
||||||
@@ -1089,7 +1108,14 @@ impl BooksServer {
|
|||||||
.get_customer(i.customer_id)
|
.get_customer(i.customer_id)
|
||||||
.map(|c| c.name.clone())
|
.map(|c| c.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(who, i.date, i.due_date, i.entry_id, i.lines.clone())
|
(
|
||||||
|
who,
|
||||||
|
i.date,
|
||||||
|
i.due_date,
|
||||||
|
i.entry_id,
|
||||||
|
i.lines.clone(),
|
||||||
|
i.sends.iter().map(sent_out).collect(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
ledger_core::DocRef::Bill(id) => {
|
ledger_core::DocRef::Bill(id) => {
|
||||||
let b = ledger
|
let b = ledger
|
||||||
@@ -1099,7 +1125,14 @@ impl BooksServer {
|
|||||||
.get_vendor(b.vendor_id)
|
.get_vendor(b.vendor_id)
|
||||||
.map(|v| v.name.clone())
|
.map(|v| v.name.clone())
|
||||||
.unwrap_or_default();
|
.unwrap_or_default();
|
||||||
(who, b.date, b.due_date, b.entry_id, b.lines.clone())
|
(
|
||||||
|
who,
|
||||||
|
b.date,
|
||||||
|
b.due_date,
|
||||||
|
b.entry_id,
|
||||||
|
b.lines.clone(),
|
||||||
|
Vec::new(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
let st = ledger
|
let st = ledger
|
||||||
@@ -1171,6 +1204,7 @@ impl BooksServer {
|
|||||||
status: st.status.key().to_string(),
|
status: st.status.key().to_string(),
|
||||||
overdue_days: st.overdue_days,
|
overdue_days: st.overdue_days,
|
||||||
timeline,
|
timeline,
|
||||||
|
sends,
|
||||||
pdf,
|
pdf,
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -708,3 +708,21 @@ pub(crate) fn resolve_instrument(store: &Store, r: &str) -> Result<InstrumentId,
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A recorded send on the wire (plan P4b S5).
|
||||||
|
pub(crate) fn sent_out(s: &ledger_core::arap::Sent) -> SentOut {
|
||||||
|
SentOut {
|
||||||
|
on: s.on.to_string(),
|
||||||
|
by: s.by.clone(),
|
||||||
|
via: s.via.key(),
|
||||||
|
to: s.to.clone(),
|
||||||
|
kind: s.kind.key().to_string(),
|
||||||
|
reference: s.reference.clone(),
|
||||||
|
note: s.note.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The day an invoice last went out, when a send is recorded.
|
||||||
|
pub(crate) fn last_sent(sends: &[ledger_core::arap::Sent]) -> Option<String> {
|
||||||
|
sends.iter().map(|s| s.on).max().map(|d| d.to_string())
|
||||||
|
}
|
||||||
@@ -0,0 +1,118 @@
|
|||||||
|
//! `books.mark_sent`'s group: the record that an invoice went out (plan
|
||||||
|
//! P4b S5, A-4). The books never send; this records who did, how, to whom,
|
||||||
|
//! when, whether it was the original or a reminder, and the channel's own
|
||||||
|
//! reference. A send moves no figure.
|
||||||
|
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
/// `books.mark_sent`'s `invoice`: `invoice:7` (as books.documents names
|
||||||
|
/// it), or a bare id.
|
||||||
|
fn invoice_id(r: &str) -> Result<u64, String> {
|
||||||
|
let t = r.trim();
|
||||||
|
let t = t.strip_prefix("invoice:").map(str::trim).unwrap_or(t);
|
||||||
|
t.parse::<u64>()
|
||||||
|
.map_err(|_| format!("bad invoice {r:?}: expected invoice:7 or 7"))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The channel a send went by: email, mail, portal, hand, or another
|
||||||
|
/// channel's own name (`Other`).
|
||||||
|
fn sent_via(s: &str) -> Result<ledger_core::arap::SentVia, String> {
|
||||||
|
use ledger_core::arap::SentVia;
|
||||||
|
let t = s.trim();
|
||||||
|
match t.to_ascii_lowercase().as_str() {
|
||||||
|
"email" => Ok(SentVia::Email),
|
||||||
|
"mail" | "post" | "paper mail" => Ok(SentVia::Mail),
|
||||||
|
"portal" => Ok(SentVia::Portal),
|
||||||
|
"hand" | "in person" => Ok(SentVia::Hand),
|
||||||
|
_ if t.is_empty() => {
|
||||||
|
Err("via is empty: give email, mail, portal, hand or another channel".into())
|
||||||
|
}
|
||||||
|
_ => Ok(SentVia::Other(t.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// original or reminder.
|
||||||
|
fn send_kind(s: &str) -> Result<ledger_core::arap::SendKind, String> {
|
||||||
|
ledger_core::arap::SendKind::parse(s.trim())
|
||||||
|
.ok_or_else(|| format!("bad kind {s:?}: expected original or reminder"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool_router(router = sent_router, vis = "pub(super)")]
|
||||||
|
impl BooksServer {
|
||||||
|
#[tool(
|
||||||
|
name = "books.mark_sent",
|
||||||
|
title = "Record an invoice sent",
|
||||||
|
description = "Record that an invoice went out: who sent it (by), how (via: email, mail, portal, hand, or another channel's name), to whom, the day, original or reminder, and the channel's own reference (a message id). The books never send mail: the owner or a delegate sends, then this records it. Several sends are allowed (reminders). A send changes no figure and never moves the due date. Refused on a void invoice, dated before it, with no recipients, or while the books wait for their format upgrade (documents_v2).",
|
||||||
|
annotations(
|
||||||
|
read_only_hint = false,
|
||||||
|
destructive_hint = false,
|
||||||
|
open_world_hint = false
|
||||||
|
)
|
||||||
|
)]
|
||||||
|
async fn mark_sent(
|
||||||
|
&self,
|
||||||
|
params: Parameters<MarkSentParams>,
|
||||||
|
) -> Result<Json<MarkSentOut>, String> {
|
||||||
|
use ledger_core::arap::{SendKind, Sent};
|
||||||
|
let p = params.0;
|
||||||
|
let on = self.as_of(p.on.as_deref())?;
|
||||||
|
let id = invoice_id(&p.invoice)?;
|
||||||
|
let mut store = self.lock()?;
|
||||||
|
let (to, kind) = {
|
||||||
|
let invoice = store
|
||||||
|
.ledger()
|
||||||
|
.get_invoice(id)
|
||||||
|
.ok_or_else(|| format!("no invoice {id}: see books.documents"))?;
|
||||||
|
let to = match p.recipients {
|
||||||
|
Some(to) if !to.is_empty() => to,
|
||||||
|
_ => {
|
||||||
|
let bill_to = invoice.bill_to.as_ref().and_then(|b| b.email.clone());
|
||||||
|
let party = store
|
||||||
|
.ledger()
|
||||||
|
.party_of_customer(invoice.customer_id)
|
||||||
|
.and_then(|pid| store.ledger().get_party(pid))
|
||||||
|
.and_then(|p| p.email.clone());
|
||||||
|
vec![bill_to.or(party).ok_or_else(|| {
|
||||||
|
format!(
|
||||||
|
"invoice {id} has no bill-to email and its customer no email: give to"
|
||||||
|
)
|
||||||
|
})?]
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let kind = match p.kind.as_deref() {
|
||||||
|
Some(k) => send_kind(k)?,
|
||||||
|
None if invoice.sends.is_empty() => SendKind::Original,
|
||||||
|
None => SendKind::Reminder,
|
||||||
|
};
|
||||||
|
(to, kind)
|
||||||
|
};
|
||||||
|
let by = p.by.unwrap_or_else(|| {
|
||||||
|
CALLER
|
||||||
|
.with(|c| c.borrow().as_ref().map(|(_, name)| name.clone()))
|
||||||
|
.unwrap_or_else(|| ledger_store::auth::DEFAULT_NAME.to_string())
|
||||||
|
});
|
||||||
|
let via = sent_via(p.via.as_deref().unwrap_or("email"))?;
|
||||||
|
store
|
||||||
|
.mark_sent(
|
||||||
|
id,
|
||||||
|
Sent {
|
||||||
|
on,
|
||||||
|
by,
|
||||||
|
via,
|
||||||
|
to,
|
||||||
|
kind,
|
||||||
|
reference: p.reference,
|
||||||
|
note: p.note,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map_err(|e| e.to_string())?;
|
||||||
|
let invoice = store
|
||||||
|
.ledger()
|
||||||
|
.get_invoice(id)
|
||||||
|
.ok_or_else(|| format!("invoice {id} not found after its send"))?;
|
||||||
|
Ok(Json(MarkSentOut {
|
||||||
|
invoice: format!("invoice:{id}"),
|
||||||
|
sends: invoice.sends.iter().map(sent_out).collect(),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3249,6 +3249,10 @@ pub struct DocumentRowOut {
|
|||||||
pub status: String,
|
pub status: String,
|
||||||
/// Days past due while something is owed; 0 otherwise.
|
/// Days past due while something is owed; 0 otherwise.
|
||||||
pub overdue_days: i64,
|
pub overdue_days: i64,
|
||||||
|
/// The day it last went out, when a send is recorded (P4b S5);
|
||||||
|
/// absent for a document never sent and for every bill.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub last_sent: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Output of `books.documents`.
|
/// Output of `books.documents`.
|
||||||
@@ -3342,6 +3346,11 @@ pub struct DocumentOut {
|
|||||||
pub overdue_days: i64,
|
pub overdue_days: i64,
|
||||||
/// Every ledger line tagged with it, in posting order.
|
/// Every ledger line tagged with it, in posting order.
|
||||||
pub timeline: Vec<DocumentEventOut>,
|
pub timeline: Vec<DocumentEventOut>,
|
||||||
|
/// Every recorded send, in order (P4b S5): who, how, to whom, when,
|
||||||
|
/// original or reminder, and the channel's own reference. Empty for a
|
||||||
|
/// bill and for an invoice never sent.
|
||||||
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
|
pub sends: Vec<SentOut>,
|
||||||
/// The invoice's PDF, handed over (with `pdf`): run its download.
|
/// The invoice's PDF, handed over (with `pdf`): run its download.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub pdf: Option<HandedFile>,
|
pub pdf: Option<HandedFile>,
|
||||||
@@ -4839,7 +4848,7 @@ pub struct ReconciliationsOut {
|
|||||||
/// Parameters for `books.handbook`.
|
/// Parameters for `books.handbook`.
|
||||||
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
#[derive(Debug, Default, Deserialize, JsonSchema)]
|
||||||
pub struct HandbookParams {
|
pub struct HandbookParams {
|
||||||
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, or "all" for the whole handbook in order. Omit for the index.
|
/// The section to read: contract, entries, drafts-and-reversals, evidence-and-receipts, attachments, files-in-out, reconciliation, fixed-assets, prepaid-and-recurring, revenue-and-payouts, projects, parties-and-tins, 1099, cpa-handoff, settings-and-conventions, viewer, backups, self-host, format-upgrade, rules-that-change, control-roles, tracing-figures, invoices-and-bills, or "all" for the whole handbook in order. Omit for the index.
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub section: Option<String>,
|
pub section: Option<String>,
|
||||||
}
|
}
|
||||||
@@ -5006,3 +5015,71 @@ pub struct BillOut {
|
|||||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||||
pub notes: Vec<String>,
|
pub notes: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// P4b S5: sent tracking.
|
||||||
|
|
||||||
|
/// Parameters for `books.mark_sent`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct MarkSentParams {
|
||||||
|
/// The invoice: `invoice:7` (as books.documents names it) or its id,
|
||||||
|
/// `7`.
|
||||||
|
pub invoice: String,
|
||||||
|
/// The day it went out, YYYY-MM-DD (omitted: today in the company's
|
||||||
|
/// time zone).
|
||||||
|
#[serde(default)]
|
||||||
|
pub on: Option<String>,
|
||||||
|
/// Who sent it: the owner, or a named delegate such as an agent
|
||||||
|
/// (omitted: this session's token name).
|
||||||
|
#[serde(default)]
|
||||||
|
pub by: Option<String>,
|
||||||
|
/// The channel: email (the default), mail, portal, hand, or another
|
||||||
|
/// channel's own name.
|
||||||
|
#[serde(default)]
|
||||||
|
pub via: Option<String>,
|
||||||
|
/// The recipients (omitted: the invoice's bill-to email, else the
|
||||||
|
/// customer's email; refused when neither is set). The journal keeps
|
||||||
|
/// them as the send's `to`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub recipients: Option<Vec<String>>,
|
||||||
|
/// original (the default for a first send) or reminder (the default
|
||||||
|
/// after one).
|
||||||
|
#[serde(default)]
|
||||||
|
pub kind: Option<String>,
|
||||||
|
/// The channel's own id (a message id, a tracking number).
|
||||||
|
#[serde(default)]
|
||||||
|
pub reference: Option<String>,
|
||||||
|
/// A note about the send.
|
||||||
|
#[serde(default)]
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One recorded send of an invoice (P4b S5).
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct SentOut {
|
||||||
|
/// The day it went out.
|
||||||
|
pub on: String,
|
||||||
|
/// Who sent it.
|
||||||
|
pub by: String,
|
||||||
|
/// How: email, mail, portal, hand, or another channel's name.
|
||||||
|
pub via: String,
|
||||||
|
/// The recipients.
|
||||||
|
pub to: Vec<String>,
|
||||||
|
/// original or reminder.
|
||||||
|
pub kind: String,
|
||||||
|
/// The channel's own id, when given.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub reference: Option<String>,
|
||||||
|
/// A note about the send, when given.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub note: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Output of `books.mark_sent`.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
|
||||||
|
pub struct MarkSentOut {
|
||||||
|
/// The invoice, as `invoice:7`.
|
||||||
|
pub invoice: String,
|
||||||
|
/// Every send on it, in order, the newest last.
|
||||||
|
pub sends: Vec<SentOut>,
|
||||||
|
}
|
||||||
@@ -37,7 +37,12 @@
|
|||||||
//! books.set_entry_instrument; a year whose worksheet can't be made
|
//! books.set_entry_instrument; a year whose worksheet can't be made
|
||||||
//! for want of named cash accounts while vendors were paid in it
|
//! for want of named cash accounts while vendors were paid in it
|
||||||
//! (Todo); overdue bills, and overdue
|
//! (Todo); overdue bills, and overdue
|
||||||
//! invoices (Problem, one each, with the total); money owed to
|
//! invoices (Problem, one each, with the total); an issued invoice
|
||||||
|
//! (`number` set) still Open or Partial with no send and past
|
||||||
|
//! `invoicing.unsent_days` (Todo, its prompt naming books.mark_sent;
|
||||||
|
//! a legacy invoice with no number is exempt); an overdue invoice
|
||||||
|
//! whose last send is older than `invoicing.reminder_days` (Todo, the
|
||||||
|
//! same tool); money owed to
|
||||||
//! owners (Info, per account with a balance); the database differing
|
//! owners (Info, per account with a balance); the database differing
|
||||||
//! from the journal or unreadable, and damaged or missing stored
|
//! from the journal or unreadable, and damaged or missing stored
|
||||||
//! files (Problem); depreciation due through the last finished month
|
//! files (Problem); depreciation due through the last finished month
|
||||||
@@ -83,8 +88,8 @@ use std::collections::{BTreeSet, HashMap, HashSet};
|
|||||||
use chrono::{Datelike, NaiveDate};
|
use chrono::{Datelike, NaiveDate};
|
||||||
use ledger_core::parties::MoneyAccounts;
|
use ledger_core::parties::MoneyAccounts;
|
||||||
use ledger_core::{
|
use ledger_core::{
|
||||||
AccountId, AccountType, AssetId, EntryId, EntryStatus, Ledger, LedgerError, Party, PartyId,
|
AccountId, AccountType, AssetId, DocRef, DocStatus, EntryId, EntryStatus, Ledger, LedgerError,
|
||||||
PartyMember, PeriodStatus,
|
Party, PartyId, PartyMember, PeriodStatus,
|
||||||
};
|
};
|
||||||
use ledger_store::{Attachment, DiskCheck, JournalLine, Reconciliation, Statement, Subject};
|
use ledger_store::{Attachment, DiskCheck, JournalLine, Reconciliation, Statement, Subject};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
@@ -131,6 +136,8 @@ pub enum Target {
|
|||||||
Asset(AssetId),
|
Asset(AssetId),
|
||||||
/// The documents and attachments list.
|
/// The documents and attachments list.
|
||||||
Documents,
|
Documents,
|
||||||
|
/// One invoice's page.
|
||||||
|
Invoice(ledger_core::InvoiceId),
|
||||||
/// The journal.
|
/// The journal.
|
||||||
Journal,
|
Journal,
|
||||||
/// One of the reports.
|
/// One of the reports.
|
||||||
@@ -717,6 +724,85 @@ pub fn find_with(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Invoices: did each go out, and were overdue ones reminded? (plan P4b
|
||||||
|
// S5, A-4: the books record sends, they never make them.) The day
|
||||||
|
// counts are the owner's settings, with their documented defaults.
|
||||||
|
let setting_days = |key: &str, fallback: u32| {
|
||||||
|
ledger
|
||||||
|
.settings()
|
||||||
|
.get(key)
|
||||||
|
.and_then(|r| r.value.as_u64())
|
||||||
|
.and_then(|n| u32::try_from(n).ok())
|
||||||
|
.unwrap_or(fallback)
|
||||||
|
};
|
||||||
|
let unsent_days = setting_days("invoicing.unsent_days", 2);
|
||||||
|
let reminder_days = setting_days("invoicing.reminder_days", 14);
|
||||||
|
for invoice in ledger.invoices() {
|
||||||
|
let Ok(Some(state)) = ledger.doc_state(DocRef::Invoice(invoice.id), today) else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let label = invoice
|
||||||
|
.number
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| invoice.id.to_string());
|
||||||
|
let last_sent = invoice.sends.iter().map(|s| s.on).max();
|
||||||
|
let open = matches!(state.status, DocStatus::Open | DocStatus::Partial);
|
||||||
|
let listed_after = invoice
|
||||||
|
.date
|
||||||
|
.checked_add_days(chrono::Days::new(u64::from(unsent_days)));
|
||||||
|
if invoice.number.is_some()
|
||||||
|
&& open
|
||||||
|
&& last_sent.is_none()
|
||||||
|
&& listed_after.is_some_and(|limit| today > limit)
|
||||||
|
{
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Todo,
|
||||||
|
kind: "invoice_never_sent",
|
||||||
|
title: format!("Invoice {label} was never sent"),
|
||||||
|
detail: format!(
|
||||||
|
"Issued {}, with no send recorded. It is listed once {unsent_days} days have passed.",
|
||||||
|
date(invoice.date)
|
||||||
|
),
|
||||||
|
target: Target::Invoice(invoice.id),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, invoice {label} (invoice:{}) has no recorded send. Ask me when and how it went out, then record it with books.mark_sent (the recipient, how it went, and the channel's reference when there is one).",
|
||||||
|
invoice.id
|
||||||
|
),
|
||||||
|
tool: Some((
|
||||||
|
"books.mark_sent".into(),
|
||||||
|
serde_json::json!({ "invoice": format!("invoice:{}", invoice.id) }),
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if state.overdue_days > 0
|
||||||
|
&& let Some(last) = last_sent
|
||||||
|
&& today.signed_duration_since(last).num_days() > i64::from(reminder_days)
|
||||||
|
{
|
||||||
|
let days = usize::try_from(state.overdue_days).unwrap_or(usize::MAX);
|
||||||
|
out.push(Attention {
|
||||||
|
severity: Severity::Todo,
|
||||||
|
kind: "invoice_not_reminded",
|
||||||
|
title: format!("Invoice {label} is overdue: remind the customer"),
|
||||||
|
detail: format!(
|
||||||
|
"Due {}, {} overdue; last sent {}. A reminder is due after {reminder_days} days.",
|
||||||
|
date(invoice.due_date),
|
||||||
|
count(days, "day", "days"),
|
||||||
|
date(last)
|
||||||
|
),
|
||||||
|
target: Target::Invoice(invoice.id),
|
||||||
|
prompt: format!(
|
||||||
|
"In wanderbooks, invoice {label} (invoice:{}) is overdue and was last sent {}. Draft a reminder to the customer, then record it with books.mark_sent as a reminder (the day and the channel's reference).",
|
||||||
|
invoice.id,
|
||||||
|
date(last)
|
||||||
|
),
|
||||||
|
tool: Some((
|
||||||
|
"books.mark_sent".into(),
|
||||||
|
serde_json::json!({ "invoice": format!("invoice:{}", invoice.id) }),
|
||||||
|
)),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
for a in ledger.chart().all().filter(|a| {
|
for a in ledger.chart().all().filter(|a| {
|
||||||
a.account_type == AccountType::Liability && {
|
a.account_type == AccountType::Liability && {
|
||||||
let n = a.name.to_lowercase();
|
let n = a.name.to_lowercase();
|
||||||
|
|||||||
@@ -243,6 +243,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
|||||||
"fx.rate_set",
|
"fx.rate_set",
|
||||||
"invoice.created",
|
"invoice.created",
|
||||||
"invoice.payment_received",
|
"invoice.payment_received",
|
||||||
|
"invoice.sent",
|
||||||
"instrument.added",
|
"instrument.added",
|
||||||
FORMAT_SET,
|
FORMAT_SET,
|
||||||
"journal.tail_repaired",
|
"journal.tail_repaired",
|
||||||
|
|||||||
@@ -14,7 +14,9 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use crate::error::StoreError;
|
use crate::error::StoreError;
|
||||||
|
use crate::journal::JournalLine;
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::arap::{SendKind, Sent, SentVia};
|
||||||
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||||
use ledger_core::settings::{Change, Source};
|
use ledger_core::settings::{Change, Source};
|
||||||
use ledger_core::{ClassId, ProjectFacts, ProjectKind, ProjectStatus};
|
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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
//! is the current version.
|
//! is the current version.
|
||||||
|
|
||||||
/// Current schema 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`.
|
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||||
pub const MIGRATIONS: &[&str] = &[
|
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 terms TEXT;
|
||||||
ALTER TABLE bills ADD COLUMN memo TEXT;
|
ALTER TABLE bills ADD COLUMN memo TEXT;
|
||||||
ALTER TABLE bills ADD COLUMN duplicate_reason 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
|
||||||
|
);
|
||||||
"#,
|
"#,
|
||||||
];
|
];
|
||||||
@@ -3756,6 +3756,36 @@ impl Store {
|
|||||||
self.reverse_entry(target, date, &description)
|
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
|
// Foreign exchange
|
||||||
// ------------------------------------------------------------------
|
// ------------------------------------------------------------------
|
||||||
@@ -7037,6 +7067,11 @@ fn replay_line(
|
|||||||
parse_bill_fields(p)?,
|
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" => {
|
"invoice.payment_received" => {
|
||||||
// The payment entry was already rebuilt; this only moves the
|
// The payment entry was already rebuilt; this only moves the
|
||||||
// invoice's paid amount and status.
|
// invoice's paid amount and status.
|
||||||
@@ -8019,6 +8054,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
|
// 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).
|
// from the ledger lines tagged with it (schema v15, rules.md B-38).
|
||||||
"invoice.payment_received" => {}
|
"invoice.payment_received" => {}
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ enum Op {
|
|||||||
Reverse(usize, u8),
|
Reverse(usize, u8),
|
||||||
/// Void document `k` on day `n`.
|
/// Void document `k` on day `n`.
|
||||||
Void(usize, u8),
|
Void(usize, u8),
|
||||||
|
/// Record a send of document `k` (an invoice; a bill is skipped) on
|
||||||
|
/// day `n`, a reminder after its first send. A send moves no figure.
|
||||||
|
MarkSent(usize, u8),
|
||||||
/// Close month `m` (January to March), in order.
|
/// Close month `m` (January to March), in order.
|
||||||
Close(u8),
|
Close(u8),
|
||||||
/// A hand line on receivables (true) or payables: always refused.
|
/// A hand line on receivables (true) or payables: always refused.
|
||||||
@@ -77,6 +80,7 @@ fn arb_op() -> impl Strategy<Value = Op> {
|
|||||||
.prop_map(|(k, n, c, card, r)| Op::Pay(k, n, c, card, r)),
|
.prop_map(|(k, n, c, card, r)| Op::Pay(k, n, c, card, r)),
|
||||||
3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)),
|
3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)),
|
||||||
1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)),
|
1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)),
|
||||||
|
2 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::MarkSent(k, n)),
|
||||||
1 => (1u8..4).prop_map(Op::Close),
|
1 => (1u8..4).prop_map(Op::Close),
|
||||||
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
|
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
|
||||||
]
|
]
|
||||||
@@ -173,6 +177,35 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec<DocRef>) -> Result<(
|
|||||||
Op::Void(i, n) => s
|
Op::Void(i, n) => s
|
||||||
.void_document(docs[i % docs.len()], day(n), "duplicate")
|
.void_document(docs[i % docs.len()], day(n), "duplicate")
|
||||||
.map(drop),
|
.map(drop),
|
||||||
|
Op::MarkSent(..) if docs.is_empty() => Ok(()),
|
||||||
|
Op::MarkSent(i, n) => match docs[i % docs.len()] {
|
||||||
|
// Bills get no sends: we receive them (plan P4b S5).
|
||||||
|
DocRef::Bill(_) => Ok(()),
|
||||||
|
DocRef::Invoice(id) => {
|
||||||
|
let kind = if s
|
||||||
|
.ledger()
|
||||||
|
.get_invoice(id)
|
||||||
|
.is_some_and(|i| !i.sends.is_empty())
|
||||||
|
{
|
||||||
|
ledger_core::arap::SendKind::Reminder
|
||||||
|
} else {
|
||||||
|
ledger_core::arap::SendKind::Original
|
||||||
|
};
|
||||||
|
s.mark_sent(
|
||||||
|
id,
|
||||||
|
ledger_core::arap::Sent {
|
||||||
|
on: day(n),
|
||||||
|
by: "agent".into(),
|
||||||
|
via: ledger_core::arap::SentVia::Email,
|
||||||
|
to: vec!["billing@fernhill.example".into()],
|
||||||
|
kind,
|
||||||
|
reference: None,
|
||||||
|
note: None,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.map(drop)
|
||||||
|
}
|
||||||
|
},
|
||||||
Op::Close(m) => {
|
Op::Close(m) => {
|
||||||
let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap();
|
let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap();
|
||||||
let end = start + chrono::Months::new(1) - chrono::Days::new(1);
|
let end = start + chrono::Months::new(1) - chrono::Days::new(1);
|
||||||
|
|||||||
@@ -12,8 +12,8 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use ledger_core::LedgerError;
|
use ledger_core::LedgerError;
|
||||||
use ledger_core::arap;
|
use ledger_core::arap;
|
||||||
use ledger_core::arap::DocumentLine;
|
use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia};
|
||||||
use ledger_core::{AccountType, Currency, Money, Side};
|
use ledger_core::{AccountType, Currency, DocRef, Money, Side};
|
||||||
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject};
|
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde_json::{Value, json};
|
use serde_json::{Value, json};
|
||||||
@@ -1054,3 +1054,190 @@ fn preview_invoice_and_bill_journal_nothing() {
|
|||||||
);
|
);
|
||||||
s.verify().unwrap();
|
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();
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@
|
|||||||
# cargo run --bin extras -- OUT (the edges of chrono and Decimal)
|
# cargo run --bin extras -- OUT (the edges of chrono and Decimal)
|
||||||
# cargo run --bin extras2 -- OUT (depreciation for 10000-01; 1,044 drafts)
|
# cargo run --bin extras2 -- OUT (depreciation for 10000-01; 1,044 drafts)
|
||||||
# cargo run --bin older -- OUT (x_older_rules, with its JournalWriter)
|
# cargo run --bin older -- OUT (x_older_rules, with its JournalWriter)
|
||||||
|
# cargo run --bin s5_before -- OUT (p4_s5_before: run against eeee9658,
|
||||||
|
# the revision before P4b S5)
|
||||||
# cargo run --bin p2_1099 -- OUT (p2_vendor_1099: P2's 1099 marks, run
|
# cargo run --bin p2_1099 -- OUT (p2_vendor_1099: P2's 1099 marks, run
|
||||||
# against 9c99a0cf, the revision before P3)
|
# against 9c99a0cf, the revision before P3)
|
||||||
# cargo run --bin expect -- OUT (expected.json beside each journal)
|
# cargo run --bin expect -- OUT (expected.json beside each journal)
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
//! Writes `p4_s5_before`: books the version before P4b S5 (main eeee9658)
|
||||||
|
//! wrote through its Store API: a chart, a customer's invoice part paid,
|
||||||
|
//! and a vendor's bill. No send exists in that version; this version opens
|
||||||
|
//! the same journal and must report the same figures.
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::{AccountType, Currency, DocRef, DocumentLine, Money, Side};
|
||||||
|
use ledger_store::Store;
|
||||||
|
use rust_decimal::Decimal;
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
fn d(y: i32, m: u32, day: u32) -> NaiveDate {
|
||||||
|
NaiveDate::from_ymd_opt(y, m, day).unwrap()
|
||||||
|
}
|
||||||
|
fn cents(c: i64) -> Decimal {
|
||||||
|
Decimal::new(c, 2)
|
||||||
|
}
|
||||||
|
fn line(what: &str, c: i64) -> DocumentLine {
|
||||||
|
DocumentLine {
|
||||||
|
description: what.into(),
|
||||||
|
amount: cents(c),
|
||||||
|
class_id: None,
|
||||||
|
account: None,
|
||||||
|
quantity: None,
|
||||||
|
unit_price: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
let root = PathBuf::from(std::env::args().nth(1).unwrap());
|
||||||
|
let dir = root.join("p4_s5_before");
|
||||||
|
let _ = std::fs::remove_dir_all(&dir);
|
||||||
|
std::fs::create_dir_all(&dir).unwrap();
|
||||||
|
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||||
|
let bank = s
|
||||||
|
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let capital = s
|
||||||
|
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||||
|
.unwrap();
|
||||||
|
let seed = s.create_entry(d(2026, 1, 1), "Opening capital").unwrap();
|
||||||
|
let m = Money::new(Decimal::from(1000), Currency::USD);
|
||||||
|
s.add_line(seed, bank, Side::Debit, m).unwrap();
|
||||||
|
s.add_line(seed, capital, Side::Credit, m).unwrap();
|
||||||
|
s.post_entry(seed).unwrap();
|
||||||
|
let fernhill = s.add_customer("Fernhill Labs").unwrap();
|
||||||
|
let vendor = s.add_vendor("Tobin Harlow Supply").unwrap();
|
||||||
|
let inv = s
|
||||||
|
.create_invoice(
|
||||||
|
fernhill,
|
||||||
|
d(2026, 2, 1),
|
||||||
|
d(2026, 3, 3),
|
||||||
|
vec![line("Design", 120_000)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
s.receive_payment(inv, d(2026, 2, 15), cents(40_000), bank)
|
||||||
|
.unwrap();
|
||||||
|
s.create_bill(
|
||||||
|
vendor,
|
||||||
|
d(2026, 2, 10),
|
||||||
|
d(2026, 3, 12),
|
||||||
|
vec![line("Drafting", 30_000)],
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
println!("WROTE {:?} (invoice {inv}, {:?})", dir, DocRef::Invoice(inv));
|
||||||
|
}
|
||||||
@@ -0,0 +1,265 @@
|
|||||||
|
{
|
||||||
|
"assets": [],
|
||||||
|
"bills": [
|
||||||
|
{
|
||||||
|
"amount_paid": "0",
|
||||||
|
"date": "2026-02-10",
|
||||||
|
"due_date": "2026-03-12",
|
||||||
|
"entry_id": 4,
|
||||||
|
"id": 1,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"amount": "300.00",
|
||||||
|
"description": "Drafting"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "Open",
|
||||||
|
"vendor_id": 1
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"entries": [
|
||||||
|
{
|
||||||
|
"date": "2026-01-01",
|
||||||
|
"description": "Opening capital",
|
||||||
|
"id": 1,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"account": 1,
|
||||||
|
"amount": {
|
||||||
|
"amount": "1000",
|
||||||
|
"currency": [
|
||||||
|
85,
|
||||||
|
83,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"side": "Debit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"account": 2,
|
||||||
|
"amount": {
|
||||||
|
"amount": "1000",
|
||||||
|
"currency": [
|
||||||
|
85,
|
||||||
|
83,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"side": "Credit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reversal_of": null,
|
||||||
|
"status": "Posted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-02-01",
|
||||||
|
"description": "Invoice 1",
|
||||||
|
"id": 2,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"account": 3,
|
||||||
|
"amount": {
|
||||||
|
"amount": "1200.00",
|
||||||
|
"currency": [
|
||||||
|
85,
|
||||||
|
83,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"side": "Debit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"account": 4,
|
||||||
|
"amount": {
|
||||||
|
"amount": "1200.00",
|
||||||
|
"currency": [
|
||||||
|
85,
|
||||||
|
83,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"side": "Credit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reversal_of": null,
|
||||||
|
"status": "Posted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-02-15",
|
||||||
|
"description": "Payment on invoice 1",
|
||||||
|
"id": 3,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"account": 1,
|
||||||
|
"amount": {
|
||||||
|
"amount": "400.00",
|
||||||
|
"currency": [
|
||||||
|
85,
|
||||||
|
83,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"side": "Debit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"account": 3,
|
||||||
|
"amount": {
|
||||||
|
"amount": "400.00",
|
||||||
|
"currency": [
|
||||||
|
85,
|
||||||
|
83,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"side": "Credit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reversal_of": null,
|
||||||
|
"status": "Posted"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"date": "2026-02-10",
|
||||||
|
"description": "Bill 1",
|
||||||
|
"id": 4,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"account": 5,
|
||||||
|
"amount": {
|
||||||
|
"amount": "300.00",
|
||||||
|
"currency": [
|
||||||
|
85,
|
||||||
|
83,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"side": "Debit"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"account": 6,
|
||||||
|
"amount": {
|
||||||
|
"amount": "300.00",
|
||||||
|
"currency": [
|
||||||
|
85,
|
||||||
|
83,
|
||||||
|
68
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"side": "Credit"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"reversal_of": null,
|
||||||
|
"status": "Posted"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"entry_parties": [
|
||||||
|
[
|
||||||
|
2,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"party_id": 1,
|
||||||
|
"share": "derived"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
3,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"party_id": 1,
|
||||||
|
"share": "derived"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
],
|
||||||
|
[
|
||||||
|
4,
|
||||||
|
[
|
||||||
|
{
|
||||||
|
"party_id": 2,
|
||||||
|
"share": "derived"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
|
],
|
||||||
|
"invoices": [
|
||||||
|
{
|
||||||
|
"amount_paid": "400.00",
|
||||||
|
"customer_id": 1,
|
||||||
|
"date": "2026-02-01",
|
||||||
|
"due_date": "2026-03-03",
|
||||||
|
"entry_id": 2,
|
||||||
|
"id": 1,
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"amount": "1200.00",
|
||||||
|
"description": "Design"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"status": "Partial"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"payroll_runs": [],
|
||||||
|
"periods": [],
|
||||||
|
"recurring": [],
|
||||||
|
"schedules": [],
|
||||||
|
"snapshot": {
|
||||||
|
"Ok": {
|
||||||
|
"balance_sheet": {
|
||||||
|
"assets": "2200",
|
||||||
|
"balanced": true,
|
||||||
|
"current_earnings": "900",
|
||||||
|
"equity": "1900",
|
||||||
|
"liabilities": "300"
|
||||||
|
},
|
||||||
|
"profit_and_loss": {
|
||||||
|
"expenses": "300",
|
||||||
|
"net_income": "900",
|
||||||
|
"revenue": "1200"
|
||||||
|
},
|
||||||
|
"trial_balance": {
|
||||||
|
"lines": [
|
||||||
|
{
|
||||||
|
"code": "1000",
|
||||||
|
"credit": "0",
|
||||||
|
"debit": "1400",
|
||||||
|
"name": "Bank"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "1200",
|
||||||
|
"credit": "0",
|
||||||
|
"debit": "800",
|
||||||
|
"name": "Accounts Receivable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "2000",
|
||||||
|
"credit": "300",
|
||||||
|
"debit": "0",
|
||||||
|
"name": "Accounts Payable"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "3000",
|
||||||
|
"credit": "1000",
|
||||||
|
"debit": "0",
|
||||||
|
"name": "Capital"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "4000",
|
||||||
|
"credit": "1200",
|
||||||
|
"debit": "0",
|
||||||
|
"name": "Revenue"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"code": "6999",
|
||||||
|
"credit": "0",
|
||||||
|
"debit": "300",
|
||||||
|
"name": "General Expenses"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"total_credits": "2500",
|
||||||
|
"total_debits": "2500"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"verify": {
|
||||||
|
"Ok": null
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-27T12:07:38Z","type":"store.created"}
|
||||||
|
{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"0bbd745853ef336fa261af81109c83ac2b5c62be3d9243eecbd7d1abc4810afc","seq":2,"ts":"2026-10-27T12:07:38Z","type":"journal.format_set"}
|
||||||
|
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"5b4b18ca6b1e3e4dd2dd8d2ba522d2ec8ffdf3cd2c10472092983453c5675472","seq":3,"ts":"2026-10-27T12:07:38Z","type":"account.added"}
|
||||||
|
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"c0ab8146ae450a9204c1f2dd338f49330900ec2c4edd29bda362ce5c73665275","seq":4,"ts":"2026-10-27T12:07:38Z","type":"account.added"}
|
||||||
|
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"ade211d7ae85ac8a12866d529418cba0785290e559183a0700dd91c95a7d41b0","seq":5,"ts":"2026-10-27T12:07:38Z","type":"entry.created"}
|
||||||
|
{"payload":{"account":1,"amount":"1000","currency":"USD","id":1,"side":"debit"},"prev":"0b1c4e86907d5b19faed67cba9633120de6726217fec3602930e09cb1c3ac6e1","seq":6,"ts":"2026-10-27T12:07:38Z","type":"entry.line_added"}
|
||||||
|
{"payload":{"account":2,"amount":"1000","currency":"USD","id":1,"side":"credit"},"prev":"f300e939ff3967ad7b04c781ec6ec0c2790cbfb938a1ad57d8428239f3c05ecb","seq":7,"ts":"2026-10-27T12:07:38Z","type":"entry.line_added"}
|
||||||
|
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000","currency":"USD","side":"debit"},{"account":2,"amount":"1000","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4c31e09e179aa377d03f09ff07298a94f7bcdb5463b9a56e7d9ec1649fa9b859","seq":8,"ts":"2026-10-27T12:07:38Z","type":"entry.posted"}
|
||||||
|
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"88a5469ed669976c63355000e7ce6b358bc3d8731527da98f45833ae28cd04f8","seq":9,"ts":"2026-10-27T12:07:38Z","type":"customer.added"}
|
||||||
|
{"payload":{"id":1,"name":"Tobin Harlow Supply"},"prev":"84f6fd180671d63e367eaa0465867732877995d282996446ed2514e2b164f06d","seq":10,"ts":"2026-10-27T12:07:38Z","type":"vendor.added"}
|
||||||
|
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":3,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"af015074dc5235c7efd9df38e447866e46c5d552d049f07ceec7f5cc94655770","seq":11,"ts":"2026-10-27T12:07:38Z","type":"account.added"}
|
||||||
|
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Revenue","parent":null,"placeholder":false},"prev":"a17b19e0d8229d378f2cf950079ff6495b21799ecc0b4608de72853d62d5eaf2","seq":12,"ts":"2026-10-27T12:07:39Z","type":"account.added"}
|
||||||
|
{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2},"prev":"7e1bf5a0142e15f2c4471f6dd413e428586673cdf5ce94d46e40bb951d487423","seq":13,"ts":"2026-10-27T12:07:39Z","type":"entry.created"}
|
||||||
|
{"payload":{"account":3,"amount":"1200.00","currency":"USD","id":2,"side":"debit"},"prev":"0b0d2bc9815cac0aaf428d114031cd18d6c133cb14aff6e374324b9a973fe50f","seq":14,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"}
|
||||||
|
{"payload":{"account":4,"amount":"1200.00","currency":"USD","id":2,"side":"credit"},"prev":"fa319df9f4858df95f47b4035c0f83901674dca7fbc5978b820a8d93bd27e196","seq":15,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"}
|
||||||
|
{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2,"lines":[{"account":3,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"e0cbb496c5116f927a0d2d897c651bf7df706af670d44c468703a81cdbb6a598","seq":16,"ts":"2026-10-27T12:07:39Z","type":"entry.posted"}
|
||||||
|
{"payload":{"customer_id":1,"date":"2026-02-01","due_date":"2026-03-03","entry_id":2,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"72eee837c1eee71d7b45bc63e840224fe85d4f08f423b7cdadd5431d2e0c730c","seq":17,"ts":"2026-10-27T12:07:39Z","type":"invoice.created"}
|
||||||
|
{"payload":{"date":"2026-02-15","description":"Payment on invoice 1","id":3},"prev":"915870654047decc4b93972fb04bd76bf8ea65b5710f58a866d3661c0a3c09f5","seq":18,"ts":"2026-10-27T12:07:39Z","type":"entry.created"}
|
||||||
|
{"payload":{"account":1,"amount":"400.00","currency":"USD","id":3,"side":"debit"},"prev":"433c71f0884859468a8b1e48038b807bc9f910e23e22bd646518913287f5a1d6","seq":19,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"}
|
||||||
|
{"payload":{"account":3,"amount":"400.00","currency":"USD","id":3,"side":"credit"},"prev":"86db1b12b1c6585ed1fadbee2543d33e0adede5e9a02f0d36626a6f8acc2179a","seq":20,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"}
|
||||||
|
{"payload":{"date":"2026-02-15","description":"Payment on invoice 1","id":3,"lines":[{"account":1,"amount":"400.00","currency":"USD","side":"debit"},{"account":3,"amount":"400.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c4a0a4fb8495af3b43b2b5d5e9602067998028b65d42a301e407f37017d6e6b0","seq":21,"ts":"2026-10-27T12:07:39Z","type":"entry.posted"}
|
||||||
|
{"payload":{"amount":"400.00","cash_account":1,"date":"2026-02-15","entry_id":3,"invoice_id":1},"prev":"5a29d6bff70696aeb707eb69fac9ceaffb7c5167e10434b23aa18ba9aeb4b8ff","seq":22,"ts":"2026-10-27T12:07:39Z","type":"invoice.payment_received"}
|
||||||
|
{"payload":{"account_type":"Expense","code":"6999","currency":"USD","id":5,"name":"General Expenses","parent":null,"placeholder":false},"prev":"2dc14a4d6d23f42a34c1172475ac86b9706be79ee17ba9bfd4041fde99c43231","seq":23,"ts":"2026-10-27T12:07:39Z","type":"account.added"}
|
||||||
|
{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts Payable","parent":null,"placeholder":false},"prev":"4a44d6570b6b9e1f3b705b383d41939aab2952d32902ab151f2b4d02ad3c1237","seq":24,"ts":"2026-10-27T12:07:39Z","type":"account.added"}
|
||||||
|
{"payload":{"date":"2026-02-10","description":"Bill 1","id":4},"prev":"bd11681484634f272371a9245cfabdff52024ffe516380143b650275b9af034a","seq":25,"ts":"2026-10-27T12:07:39Z","type":"entry.created"}
|
||||||
|
{"payload":{"account":5,"amount":"300.00","currency":"USD","id":4,"side":"debit"},"prev":"3df6859cd10b0f3ec4a50ff2abcfd619291db2c290e3d844399d0434be31754c","seq":26,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"}
|
||||||
|
{"payload":{"account":6,"amount":"300.00","currency":"USD","id":4,"side":"credit"},"prev":"86f685ccc47f69411c2cd424017510d10823f05ab3c9018fbf636ff15455b548","seq":27,"ts":"2026-10-27T12:07:39Z","type":"entry.line_added"}
|
||||||
|
{"payload":{"date":"2026-02-10","description":"Bill 1","id":4,"lines":[{"account":5,"amount":"300.00","currency":"USD","side":"debit"},{"account":6,"amount":"300.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c25fce42aa1755ee4e915191e4903f13874e2ea327bfd915ba39df474a04ca4e","seq":28,"ts":"2026-10-27T12:07:39Z","type":"entry.posted"}
|
||||||
|
{"payload":{"date":"2026-02-10","due_date":"2026-03-12","entry_id":4,"id":1,"lines":[{"amount":"300.00","description":"Drafting"}],"vendor_id":1},"prev":"55ec1c5c73e4ac37996b917c87d6bc9a841ce1eb43ac57882d2ca9a1fdf4820a","seq":29,"ts":"2026-10-27T12:07:39Z","type":"bill.created"}
|
||||||
@@ -16,8 +16,9 @@
|
|||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
|
use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia};
|
||||||
use ledger_core::{AccountType, Currency, Money, Side};
|
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::{canonical_json, sha256_hex};
|
||||||
use ledger_store::{
|
use ledger_store::{
|
||||||
JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, 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:?}"
|
"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();
|
||||||
|
}
|
||||||
@@ -1957,6 +1957,15 @@ fn books_written_under_older_rules_still_open() {
|
|||||||
"memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}),
|
"memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}),
|
||||||
)
|
)
|
||||||
.unwrap();
|
.unwrap();
|
||||||
|
// P4b S5's new shape: the record that the invoice went out
|
||||||
|
// (`invoice.sent`, feature documents_v2). A send moves no figure, so
|
||||||
|
// the document's figures are the same before and after it.
|
||||||
|
w.append(
|
||||||
|
"invoice.sent",
|
||||||
|
json!({"invoice_id": 1, "on": "2026-04-05", "by": "Avery Quill", "via": "email",
|
||||||
|
"to": ["billing@fernhill.example"], "kind": "original", "reference": "msg-1"}),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
drop(w);
|
drop(w);
|
||||||
// Opening rebuilds from the journal; it must accept all of it.
|
// Opening rebuilds from the journal; it must accept all of it.
|
||||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||||
@@ -2023,6 +2032,14 @@ fn books_written_under_older_rules_still_open() {
|
|||||||
);
|
);
|
||||||
assert_eq!(bill.memo.as_deref(), Some("A4 paper"));
|
assert_eq!(bill.memo.as_deref(), Some("A4 paper"));
|
||||||
assert_eq!(bill.lines[0].account, Some(6));
|
assert_eq!(bill.lines[0].account, Some(6));
|
||||||
|
// P4b S5's send replays as written, and moves no figure.
|
||||||
|
let sends = &store.ledger().get_invoice(1).expect("the invoice").sends;
|
||||||
|
assert_eq!(sends.len(), 1);
|
||||||
|
assert_eq!(sends[0].on, NaiveDate::from_ymd_opt(2026, 4, 5).unwrap());
|
||||||
|
assert_eq!(sends[0].by, "Avery Quill");
|
||||||
|
assert_eq!(sends[0].to, ["billing@fernhill.example"]);
|
||||||
|
assert_eq!(sends[0].kind, ledger_core::arap::SendKind::Original);
|
||||||
|
assert_eq!(sends[0].reference.as_deref(), Some("msg-1"));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The books an earlier wanderbooks wrote, one directory each under
|
/// The books an earlier wanderbooks wrote, one directory each under
|
||||||
|
|||||||
@@ -169,6 +169,7 @@ groups are:
|
|||||||
|
|
||||||
- `documents` — invoices, bills, payments, voids, `books.documents`,
|
- `documents` — invoices, bills, payments, voids, `books.documents`,
|
||||||
`books.document`, the control check and the agings;
|
`books.document`, the control check and the agings;
|
||||||
|
- `sent` — `books.mark_sent`, the record that an invoice went out (P4b S5);
|
||||||
- `entries` — `books.draft_entry`, `books.add_line`, `books.post_entry`,
|
- `entries` — `books.draft_entry`, `books.add_line`, `books.post_entry`,
|
||||||
`books.reverse_entry`, `books.get_entry`, `books.search_entries`, the
|
`books.reverse_entry`, `books.get_entry`, `books.search_entries`, the
|
||||||
bank CSV import;
|
bank CSV import;
|
||||||
|
|||||||
+21
-3
@@ -2107,9 +2107,24 @@ snapshots; see plan 8.2 S4.
|
|||||||
number and reads `void`. Refused while a payment stands on it (the
|
number and reads `void`. Refused while a payment stands on it (the
|
||||||
refusal names the payment entry to reverse first), when void already,
|
refusal names the payment entry to reverse first), when void already,
|
||||||
before its date or in a closed period (P4b S1, B-38).
|
before its date or in a closed period (P4b S1, B-38).
|
||||||
|
- `books.mark_sent { invoice, on?, by?, via?, recipients?: [string],
|
||||||
|
kind?, reference?, note? }` → `{ invoice, sends: [{ on, by, via, to, kind,
|
||||||
|
reference?, note? }] }`. Records that an invoice went out (P4b S5,
|
||||||
|
A-4): the books never send, so the owner or a delegate sends and this
|
||||||
|
records who (`by`: the token's name unless given), how (`via`: email
|
||||||
|
the default, or mail, portal, hand, another channel's name), to whom
|
||||||
|
(`recipients`: the invoice's bill-to email, else the customer's, else
|
||||||
|
required), when (`on`: today in the company's time zone), original or
|
||||||
|
reminder (`kind`: original on the first send, reminder after), and the
|
||||||
|
channel's own id (`reference`: a message id). `invoice` is
|
||||||
|
`invoice:N` or `N`. Several sends are allowed; none moves a figure or
|
||||||
|
the due date. Refused on a void invoice, dated before it, with no
|
||||||
|
recipients, or while the books wait for `documents_v2`
|
||||||
|
(`format_upgrade_pending`). `sends` is every send on the invoice, in
|
||||||
|
order, the new one last.
|
||||||
- `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of,
|
- `books.documents { kind, status?, party?, due_by?, as_of? }` → `{ as_of,
|
||||||
kind, documents: [{ ref, party, date, due_date, total, outstanding,
|
kind, documents: [{ ref, party, date, due_date, total, outstanding,
|
||||||
status, overdue_days }], outstanding_total }`. Invoices (`kind`
|
status, overdue_days, last_sent? }], outstanding_total }`. Invoices (`kind`
|
||||||
`invoices`) or bills (`bills`), due date first, every figure derived
|
`invoices`) or bills (`bills`), due date first, every figure derived
|
||||||
from the document's lines that stand (B-38). `status` filters: `open`
|
from the document's lines that stand (B-38). `status` filters: `open`
|
||||||
(default: anything still owed, partly paid and overdue included),
|
(default: anything still owed, partly paid and overdue included),
|
||||||
@@ -2117,11 +2132,14 @@ snapshots; see plan 8.2 S4.
|
|||||||
own `status` is one of `open`, `partial`, `paid`, `written_off`,
|
own `status` is one of `open`, `partial`, `paid`, `written_off`,
|
||||||
`void`, `overpaid` or `unattached` (`DocStatus::key()`). `party` (a
|
`void`, `overpaid` or `unattached` (`DocStatus::key()`). `party` (a
|
||||||
customer's or vendor's name, ignoring case) and `due_by` (due on or
|
customer's or vendor's name, ignoring case) and `due_by` (due on or
|
||||||
before that day) narrow the list (P4b S2).
|
before that day) narrow the list (P4b S2). `last_sent` is the day the
|
||||||
|
invoice last went out, when a send is recorded (P4b S5); absent for an
|
||||||
|
invoice never sent and for every bill.
|
||||||
- `books.document { ref, as_of?, pdf? }` → `{ ref, party, date, due_date,
|
- `books.document { ref, as_of?, pdf? }` → `{ ref, party, date, due_date,
|
||||||
entry_id, lines: [{ description, amount, project? }], total, paid,
|
entry_id, lines: [{ description, amount, project? }], total, paid,
|
||||||
discounted, written_off, outstanding, status, overdue_days, timeline:
|
discounted, written_off, outstanding, status, overdue_days, timeline:
|
||||||
[{ date, entry_id, kind, amount, reversal, stands }], pdf? }`. `ref` is
|
[{ date, entry_id, kind, amount, reversal, stands }], sends: [{ on, by,
|
||||||
|
via, to, kind, reference?, note? }], pdf? }`. `ref` is
|
||||||
`invoice:N` or `bill:N`; `status` as `books.documents`' rows; the
|
`invoice:N` or `bill:N`; `status` as `books.documents`' rows; the
|
||||||
timeline is every ledger line tagged with the document, dated on or
|
timeline is every ledger line tagged with the document, dated on or
|
||||||
before the day, in posting order, each saying whether its entry still
|
before the day, in posting order, each saying whether its entry still
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ No other statuses.
|
|||||||
| 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet |
|
| 1099 vendor tracking + year-end worksheet | Vendors → Track payments for 1099; 1099 report | partial | hand-verified fixture (bill payments, card exclusion, reversal, year boundary); no golden file yet |
|
||||||
| Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet |
|
| Prepaid expense + deferred revenue amortization | Amortization schedules (Xero/QBO add-ons) | partial | hand-verified fixture, to the cent; no golden file yet |
|
||||||
| Automated cross-implementation diff harness (GnuCash first) | — (`just oracle`) | partial | live: `tests/oracle_gnucash.rs` runs 200 random books through GnuCash 5.10 in a pinned container, 15,800 exact comparisons, weekly in CI (docs/oracle.md Layer 5); open: fixture books diffed as `snapshot()` output, and the machine-readable catalog with CI enforcing this floor |
|
| Automated cross-implementation diff harness (GnuCash first) | — (`just oracle`) | partial | live: `tests/oracle_gnucash.rs` runs 200 random books through GnuCash 5.10 in a pinned container, 15,800 exact comparisons, weekly in CI (docs/oracle.md Layer 5); open: fixture books diffed as `snapshot()` output, and the machine-readable catalog with CI enforcing this floor |
|
||||||
|
| Invoice sent tracking (who, how, to whom, when, original or reminder, the channel's reference) | Business → Invoices (Xero's "Sent"; QBO's email) | done | P4b S5; records, never sends (A-4); a send changes no figure; `invoice.sent` under `documents_v2` (D-52) |
|
||||||
|
|
||||||
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
Workflow/UI parity (QuickBooks/Xero reference) gets its own catalog when the
|
||||||
egui phase starts; semantics come first.
|
egui phase starts; semantics come first.
|
||||||
@@ -135,3 +136,18 @@ door on loopback for Caddy or a tunnel. `crates/ledger-cli/tests/two_user.rs`
|
|||||||
proves the account boundary and the move's head in CI
|
proves the account boundary and the move's head in CI
|
||||||
(`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`.
|
(`.github/workflows/two-user.yml`); the recipe is `deploy/README.md`.
|
||||||
The cutover rule in `docs/capability-parity.md` is unchanged.
|
The cutover rule in `docs/capability-parity.md` is unchanged.
|
||||||
|
|
||||||
|
### P4b S5: sent tracking (2026-10-27)
|
||||||
|
|
||||||
|
The catalog gains one row: an invoice's recorded sends. The books never
|
||||||
|
send (A-4): `books.mark_sent` records who sent an invoice, how, to whom,
|
||||||
|
when, whether it was the original or a reminder, and the channel's own
|
||||||
|
reference, for an owner or a delegate such as an agent. The record is
|
||||||
|
`invoice.sent`, under `documents_v2` (D-52), and it moves no figure, so
|
||||||
|
the A/R and A/P rows' figures are unchanged. The floor never decreases:
|
||||||
|
the row is `done`, tested by `sends_replay_and_project`,
|
||||||
|
`a_send_changes_no_figure` and `mark_sent_records_who_and_how`. Sent
|
||||||
|
tracking also feeds Needs attention: an issued invoice nobody marked sent
|
||||||
|
(past `invoicing.unsent_days`) and an overdue invoice not reminded within
|
||||||
|
`invoicing.reminder_days` are one list in the viewer and in `books.start`,
|
||||||
|
each offering `books.mark_sent` (step 4; U0b's one engine).
|
||||||
@@ -58,8 +58,10 @@ apply is safe — on startup the applier replays every journal line with
|
|||||||
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
- `ts`: UTC, RFC 3339. Recorded, never trusted for ordering (`seq` orders).
|
||||||
- `type`: `account.added`, `entry.posted`, `entry.reversed`, `invoice.created`,
|
- `type`: `account.added`, `entry.posted`, `entry.reversed`, `invoice.created`,
|
||||||
`bill.created`, `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
`bill.created`, `receipt.attached`, `receipt.linked`, `receipt.unlinked`,
|
||||||
`document.filed`, `attachment.withdrawn` (plus the A/R, A/P, asset,
|
`document.filed`, `attachment.withdrawn`, `invoice.sent` (a recorded
|
||||||
period, payroll, and class events listed on `JournalLine`).
|
send: a document's record, never a ledger line or a figure; P4b S5)
|
||||||
|
(plus the A/R, A/P, asset, period, payroll, and class events listed on
|
||||||
|
`JournalLine`).
|
||||||
- `payload`: the full domain facts (account fields, entry with lines, blob
|
- `payload`: the full domain facts (account fields, entry with lines, blob
|
||||||
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
|
sha, ...). Everything the applier needs; nothing it must look up elsewhere.
|
||||||
An `invoice.created` or `bill.created` written after the `documents_v2`
|
An `invoice.created` or `bill.created` written after the `documents_v2`
|
||||||
|
|||||||
+5
-2
@@ -1746,8 +1746,11 @@ person, a billing or shipping address, a billing email (D-55); an
|
|||||||
`invoice.created` or `bill.created`
|
`invoice.created` or `bill.created`
|
||||||
carrying a number, terms, a bill-to or seller snapshot, a customer
|
carrying a number, terms, a bill-to or seller snapshot, a customer
|
||||||
reference or memo, a vendor reference, a receipt date or a duplicate's
|
reference or memo, a vendor reference, a receipt date or a duplicate's
|
||||||
reason, or a line naming its account, quantity or unit price (S4.3); and
|
reason, or a line naming its account, quantity or unit price (S4.3);
|
||||||
S5 to S7's sends, settlements and write-offs. An older version would
|
S5's `invoice.sent` (who sent an invoice, how, to whom and when: a record
|
||||||
|
that moves no figure); S6's payments with `line`, `discount`,
|
||||||
|
`discount_line`, `discount_reason`, `fee` or `fee_line`; and S7's
|
||||||
|
`invoice.written_off`. An older version would
|
||||||
restore the document without them silently: every invoice line would post
|
restore the document without them silently: every invoice line would post
|
||||||
to the revenue role and every bill line to general expenses, and every new
|
to the revenue role and every bill line to general expenses, and every new
|
||||||
event type would be refused. A command asks for
|
event type would be refused. A command asks for
|
||||||
|
|||||||
@@ -3050,3 +3050,67 @@ and `BooksServer::new`'s copy (a dropped field must fail
|
|||||||
`isolation_label_is_enforced_under_the_unit`); the host's `server_flags`
|
`isolation_label_is_enforced_under_the_unit`); the host's `server_flags`
|
||||||
extension and `parse_args`' passthrough of `--isolation` and its optional
|
extension and `parse_args`' passthrough of `--isolation` and its optional
|
||||||
`--http` value.
|
`--http` value.
|
||||||
|
|
||||||
|
## P4b S5: sent tracking (2026-10-27)
|
||||||
|
|
||||||
|
The books record that an invoice went out; they never send it (A-4). A
|
||||||
|
send is `invoice.sent`, under `documents_v2` (D-52), and moves no figure:
|
||||||
|
`Ledger::mark_sent` pushes a `Sent` onto the invoice, the store journals
|
||||||
|
one line and no entry, replay restores it and the projection keeps it in
|
||||||
|
`schema` v17's `invoice_sends` (S4.3 took 16).
|
||||||
|
|
||||||
|
- **`ledger-store` `tests/arap_standing.rs`:** `mark_sent_waits_for_documents_v2`
|
||||||
|
(books with no features: refused, nothing journaled; after `upgrade`,
|
||||||
|
taken, and the reopen equals what was live), `sends_replay_and_project`
|
||||||
|
(two sends: the journal's `invoice.sent` carries the whole record in
|
||||||
|
order, `verify` passes, the reopened invoice holds both),
|
||||||
|
`a_send_changes_no_figure` (on the `p4-arap-before` fixture: the report
|
||||||
|
snapshot, the invoice's `DocState` and every derived cache are equal
|
||||||
|
before and after), `mark_sent_on_void_refused` (`DocumentVoid`,
|
||||||
|
nothing journaled; a send dated before the void is taken).
|
||||||
|
- **`ledger-store` `tests/arap_props.rs`:** `status_is_a_function_of_journal_and_date`
|
||||||
|
gains `Op::MarkSent` (random sends on invoices, skipped on bills): every
|
||||||
|
figure, the control check and both agings still agree across live,
|
||||||
|
replay and snapshot.
|
||||||
|
- **`ledger-store` `tests/format.rs`:** `an_older_version_stops_at_invoice_sent`
|
||||||
|
(`invoice.sent` is this version's, a reader without `documents_v2` waits,
|
||||||
|
and the books declare the feature before the first send line).
|
||||||
|
- **8.0.6:** `books_written_under_older_rules_still_open` hand-writes an
|
||||||
|
`invoice.sent` and still opens; the fixture `old-books/p4_s5_before`
|
||||||
|
was written by eeee9658 (the revision before this unit) and opens to
|
||||||
|
the same figures; `books_p2_served_cut_over_whole` refuses a send before
|
||||||
|
consent and takes it after.
|
||||||
|
- **`ledger-cli` `tests/documents.rs`:** `mark_sent_records_who_and_how`
|
||||||
|
(two sends over the wire: the token's name, email and original by
|
||||||
|
default, then a reminder with a message id; `books.documents` shows
|
||||||
|
`last_sent`, `books.document` both sends, and the invoice still owes its
|
||||||
|
total).
|
||||||
|
- **`ledger-mcp/src/handbook.rs`:** the new `invoices-and-bills` section
|
||||||
|
renders, and its id is unique (the existing handbook tests).
|
||||||
|
- **Step 4, Attention (R-F):** `ledger-gui/tests/invoices.rs`:
|
||||||
|
`an_unsent_invoice_is_listed_until_a_send_clears_it` (an issued invoice
|
||||||
|
past `invoicing.unsent_days` is one to-do opening the invoice; the
|
||||||
|
overview draws it, `attention-unsent-invoice.png`; a send clears it),
|
||||||
|
`a_legacy_invoice_with_no_number_is_never_listed_as_unsent` (exempt),
|
||||||
|
`an_overdue_invoice_with_an_old_send_is_asked_to_remind` (due Mar 1,
|
||||||
|
last sent Feb 20: 14 days overdue, target the invoice, prompt names
|
||||||
|
`books.mark_sent`). `ledger-cli/tests/attention_parity.rs`:
|
||||||
|
`an_unsent_invoice_is_on_both_lists` (the viewer's list and `books.start`
|
||||||
|
both carry the item, and the step is `books.mark_sent` on `invoice:1`).
|
||||||
|
`the_viewer_and_books_start_see_the_same_attention` stays green.
|
||||||
|
|
||||||
|
**For P12 (mutation targets):** `Ledger::mark_sent`'s refusals (unknown
|
||||||
|
invoice, a send dated before it, `DocumentVoid`, empty `to`, and each of
|
||||||
|
the three `check_text` calls; the note's check is stricter than the plan's
|
||||||
|
list, to keep a full TIN out of the journal); `SentVia::parse`'s arms and
|
||||||
|
its `other:` prefix; `SendKind::parse`'s two arms; `sent_via`'s arms and
|
||||||
|
its empty fallback; `send_kind`'s error arm; the handler's `kind` default
|
||||||
|
(`original` when `sends` is empty, `reminder` after), its `to` fallback
|
||||||
|
(bill-to, then party, then refused) and its `by` fallback; `invoice_id`'s
|
||||||
|
`invoice:` strip; `last_sent`'s `max`; the replay reader's
|
||||||
|
`newer_content` arms for an unknown channel or kind; and the two attention
|
||||||
|
predicates in `ledger-package::attention`: `invoice.number.is_some()`
|
||||||
|
(the legacy exemption), `Open | Partial`, `sends.is_empty()`, the
|
||||||
|
`checked_add_days` `unsent_days` comparison (`>`), `overdue_days > 0`, the
|
||||||
|
`sends.max()` last send, and the `reminder_days` comparison (`>`), plus
|
||||||
|
each item's target (`Target::Invoice`) and `books.mark_sent` step args.
|
||||||
@@ -542,6 +542,24 @@ come from the kernel:
|
|||||||
No Lean model: these are two arithmetic laws, not a new `Ledger`
|
No Lean model: these are two arithmetic laws, not a new `Ledger`
|
||||||
command (S4.1 adds fields to the existing document commands).
|
command (S4.1 adds fields to the existing document commands).
|
||||||
|
|
||||||
|
### Sent tracking (P4b S5)
|
||||||
|
|
||||||
|
34. **A send changes no figure.** `Ledger::mark_sent` is a command, so it
|
||||||
|
is a `Step` in the model (AGENTS.md's formal-methods bar; the unit's
|
||||||
|
"no Lean change" phrase was overruled by it, and says so in its As
|
||||||
|
built block). `Documents.lean`'s `Books` gains `sends`, and
|
||||||
|
`Step.markSent` records one on a document the lines carry, touching
|
||||||
|
no line. Every control balance, every document's sum and what is
|
||||||
|
unattached are therefore the same before and after, through any day,
|
||||||
|
and `Reachable.tagged` (the control lines are all documents') covers
|
||||||
|
the new step; the engine's side is the same statement, checked by
|
||||||
|
`a_send_changes_no_figure` on the fixture book.
|
||||||
|
- Lean `Documents.Step.markSent_changes_no_figure` (no axioms) ·
|
||||||
|
`Documents.Reachable.tagged`, `Documents.Reachable.control_is_documents`
|
||||||
|
cover the new step · tests `sends_replay_and_project`,
|
||||||
|
`a_send_changes_no_figure`, `mark_sent_on_void_refused`,
|
||||||
|
`mark_sent_waits_for_documents_v2`
|
||||||
|
|
||||||
## What the proofs found
|
## What the proofs found
|
||||||
|
|
||||||
The engine already had 190+ tests, property tests, mutation testing,
|
The engine already had 190+ tests, property tests, mutation testing,
|
||||||
|
|||||||
@@ -27,7 +27,8 @@ const TYPES: &[&str] = &[
|
|||||||
"asset.registered", "attachment.withdrawn", "bill.created", "bill.paid", "class.added",
|
"asset.registered", "attachment.withdrawn", "bill.created", "bill.paid", "class.added",
|
||||||
"customer.added", "document.filed", "entry.created", "entry.line_added",
|
"customer.added", "document.filed", "entry.created", "entry.line_added",
|
||||||
"entry.line_class_set", "entry.posted", "entry.reversed", "entry.vendor_set",
|
"entry.line_class_set", "entry.posted", "entry.reversed", "entry.vendor_set",
|
||||||
"fx.rate_set", "invoice.created", "invoice.payment_received", "journal.tail_repaired",
|
"fx.rate_set", "invoice.created", "invoice.payment_received", "invoice.sent",
|
||||||
|
"journal.tail_repaired",
|
||||||
"line.cleared", "payroll.run", "period.closed", "period.created", "receipt.attached",
|
"line.cleared", "payroll.run", "period.closed", "period.created", "receipt.attached",
|
||||||
"receipt.linked", "receipt.unlinked", "recurring.generated", "recurring.registered",
|
"receipt.linked", "receipt.unlinked", "recurring.generated", "recurring.registered",
|
||||||
"schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added",
|
"schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added",
|
||||||
@@ -61,6 +62,7 @@ const FIELDS: &[&str] = &[
|
|||||||
"rule", "contributor_adjusted_basis", "fmv_at_contribution", "sec704c_method",
|
"rule", "contributor_adjusted_basis", "fmv_at_contribution", "sec704c_method",
|
||||||
"tax_basis_723", "depreciable_basis", "built_in_gain_704c", "on", "convention",
|
"tax_basis_723", "depreciable_basis", "built_in_gain_704c", "on", "convention",
|
||||||
"status", "party_id", "started_on", "ended_on", "links", "notes", "projects",
|
"status", "party_id", "started_on", "ended_on", "links", "notes", "projects",
|
||||||
|
"via", "by", "to", "reference", "note",
|
||||||
];
|
];
|
||||||
|
|
||||||
/// Journal features a `journal.format_set` may declare.
|
/// Journal features a `journal.format_set` may declare.
|
||||||
|
|||||||
+10
-1
@@ -1355,7 +1355,16 @@ Each unit follows the 8.0.4 template. "A-n" is an owner answer numbered in 8.3.1
|
|||||||
5. **Files:** owns the `Sent` types and `Ledger::mark_sent`, the `invoice.sent` arms, `books.mark_sent`; touches the 8.0.3 files.
|
5. **Files:** owns the `Sent` types and `Ledger::mark_sent`, the `invoice.sent` arms, `books.mark_sent`; touches the 8.0.3 files.
|
||||||
6. **Tests and proofs:** named above; no kernel change, no Lean change (a send moves no money). 8.0.6 items 1-4, 6.
|
6. **Tests and proofs:** named above; no kernel change, no Lean change (a send moves no money). 8.0.6 items 1-4, 6.
|
||||||
7. **Done when:** (1) the tests pass; (2) a send on a fixture book changes no report and no `DocState` (`a_send_changes_no_figure`); (3) an older version stops at the `invoice.sent` line with `NewerJournal` (the `tests/format.rs` arm); (4) `just check` green; (5) attention parity holds.
|
7. **Done when:** (1) the tests pass; (2) a send on a fixture book changes no report and no `DocState` (`a_send_changes_no_figure`); (3) an older version stops at the `invoice.sent` line with `NewerJournal` (the `tests/format.rs` arm); (4) `just check` green; (5) attention parity holds.
|
||||||
8. **As built:** none yet.
|
8. **As built** (2026-10-27, branch `S5`): Build steps 1-5 are done. ~~step 4 (the two attention items) is deferred until U0b lands~~ → built after U0b landed (`cc4f104f`), on main `ed637f0e` (D-55). Departures:
|
||||||
|
- ~~no Lean change (a send moves no money)~~ → AGENTS.md's formal-methods bar (I-13) wins: `Ledger::mark_sent` is a new `Ledger` command, so `verify/lean/Wanderbooks/Documents.lean` gains `sends` on `Books`, `Step.markSent` and `Step.markSent_changes_no_figure` (no axioms), listed in `Audit.lean` and in `docs/verification.md` rule 34.
|
||||||
|
- Step 4: `ledger-package::attention` gains `Target::Invoice` and two items, `invoice_never_sent` (an issued invoice still Open or Partial, no send, past `invoicing.unsent_days`; a legacy invoice with no number is exempt) and `invoice_not_reminded` (overdue, last send older than `invoicing.reminder_days`); both are Todo, open the invoice, and offer `books.mark_sent` with the invoice. The viewer maps the target (one line in its Target→Route table; it owns no item) and draws them; `books.start` turns them into next steps from the one function. Tests: `an_unsent_invoice_is_listed_until_a_send_clears_it` (`attention-unsent-invoice.png`), `a_legacy_invoice_with_no_number_is_never_listed_as_unsent`, `an_overdue_invoice_with_an_old_send_is_asked_to_remind`, `an_unsent_invoice_is_on_both_lists`; `the_viewer_and_books_start_see_the_same_attention` stays green.
|
||||||
|
- The MCP parameter for the recipients is `recipients`, not `to`: `no_tool_takes_a_path` refuses a parameter named `to` (a whole-word path-name rule, I-2). The journal event and the result keep the plan's `to`.
|
||||||
|
- `Ledger::mark_sent` also passes `note` through `parties::check_text`, beside `to` and `reference`: a note must not carry a full TIN (I-7).
|
||||||
|
- `LedgerError::DocumentVoid` (the plan's name) and `LedgerError::InvalidSend` (the text refusals) are new variants.
|
||||||
|
- Schema 17 at merge: S4.3 took 16 with the document fields, so this unit took the next number (8.0.3's numbering rule; the provisional 17 held).
|
||||||
|
- `books.mark_sent` lives in its own group, `crates/ledger-mcp/src/tools/sent.rs`, as the EM directed (S4.4 owns `documents.rs`); the EM approved the `sent` name and `docs/architecture.md` lists it. `sent_out` and `last_sent`, which the read tools share, sit in `tools/helpers.rs`.
|
||||||
|
- `books.document` shows every send in its own `sends` array; its `timeline` stays the ledger lines only (a send moves no line).
|
||||||
|
- `handbook/invoices-and-bills.md` is S4.5's file and had not landed, so this unit creates it (R-E) with the send procedure; S4.5 extends it with the rest of the procedure.
|
||||||
|
|
||||||
#### P4b S6: paying on terms (multi-document payments, discounts, the processor fee, bills due, aging exports)
|
#### P4b S6: paying on terms (multi-document payments, discounts, the processor fee, bills due, aging exports)
|
||||||
|
|
||||||
|
|||||||
@@ -94,5 +94,6 @@ open Wanderbooks
|
|||||||
#print axioms Documents.Step.document_unattached
|
#print axioms Documents.Step.document_unattached
|
||||||
#print axioms Documents.Step.reverse_moves
|
#print axioms Documents.Step.reverse_moves
|
||||||
#print axioms Documents.reverse_twice
|
#print axioms Documents.reverse_twice
|
||||||
|
#print axioms Documents.Step.markSent_changes_no_figure
|
||||||
#print axioms Documents.Reachable.unattached_zero
|
#print axioms Documents.Reachable.unattached_zero
|
||||||
#print axioms Documents.Reachable.control_is_documents
|
#print axioms Documents.Reachable.control_is_documents
|
||||||
@@ -37,6 +37,15 @@ structure TLine where
|
|||||||
doc : Option Nat
|
doc : Option Nat
|
||||||
deriving DecidableEq
|
deriving DecidableEq
|
||||||
|
|
||||||
|
/-- A recorded send of an invoice (plan P4b S5, `Sent`, `invoice.sent`):
|
||||||
|
the invoice, the day, and a code for who sent it, how, to whom and the
|
||||||
|
channel's own reference. No figure reads it: a send moves no money. -/
|
||||||
|
structure Send where
|
||||||
|
doc : Nat
|
||||||
|
day : Nat
|
||||||
|
code : Nat
|
||||||
|
deriving DecidableEq
|
||||||
|
|
||||||
/-- A line's signed amount on account `a` (zero on another account). -/
|
/-- A line's signed amount on account `a` (zero on another account). -/
|
||||||
def onAcct (a : Nat) (l : TLine) : Int := if l.line.account = a then l.line.signed else 0
|
def onAcct (a : Nat) (l : TLine) : Int := if l.line.account = a then l.line.signed else 0
|
||||||
|
|
||||||
@@ -159,11 +168,17 @@ control guard requires of every entry a command posts. -/
|
|||||||
def Tagged (control : List Nat) (ls : List TLine) : Prop :=
|
def Tagged (control : List Nat) (ls : List TLine) : Prop :=
|
||||||
∀ l ∈ ls, l.line.account ∈ control → l.doc ≠ none
|
∀ l ∈ ls, l.line.account ∈ control → l.doc ≠ none
|
||||||
|
|
||||||
/-- The books: the control accounts (the receivables and payables roles)
|
/-- The books: the control accounts (the receivables and payables roles),
|
||||||
and every posted line, in order. -/
|
every posted line in order, and the sends recorded (each on its invoice;
|
||||||
|
plan P4b S5). -/
|
||||||
structure Books where
|
structure Books where
|
||||||
control : List Nat
|
control : List Nat
|
||||||
lines : List TLine
|
lines : List TLine
|
||||||
|
sends : List Send
|
||||||
|
|
||||||
|
/-- The documents some lines carry: the invoices a send can name. -/
|
||||||
|
def Books.docs (B : Books) : List Nat :=
|
||||||
|
B.lines.filterMap (fun l => l.doc)
|
||||||
|
|
||||||
/-- What the engine allows (S1): a document command, or any entry whose
|
/-- What the engine allows (S1): a document command, or any entry whose
|
||||||
control lines are all tagged (`Step.document`); a reversal of posted
|
control lines are all tagged (`Step.document`); a reversal of posted
|
||||||
@@ -184,6 +199,11 @@ inductive Step : Books → Books → Prop
|
|||||||
Step B { B with control := c :: B.control }
|
Step B { B with control := c :: B.control }
|
||||||
| release (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc = none) :
|
| release (B : Books) (c : Nat) (h : ∀ l ∈ B.lines, l.line.account = c → l.doc = none) :
|
||||||
Step B { B with control := B.control.erase c }
|
Step B { B with control := B.control.erase c }
|
||||||
|
/-- `mark_sent` (`Ledger::mark_sent`, plan P4b S5): a send is recorded on
|
||||||
|
an invoice the books hold, and nothing else changes. It moves no line,
|
||||||
|
so every figure stays where it was. -/
|
||||||
|
| markSent (B : Books) (s : Send) (h : s.doc ∈ B.docs) :
|
||||||
|
Step B { B with sends := s :: B.sends }
|
||||||
|
|
||||||
/-- **A document step never moves what is unattached** on a control
|
/-- **A document step never moves what is unattached** on a control
|
||||||
account: no command adds an untagged control line. -/
|
account: no command adds an untagged control line. -/
|
||||||
@@ -216,9 +236,24 @@ theorem reverse_twice (a d D D' : Nat) (e : List TLine) :
|
|||||||
docSum a d ((e.map (mirror D)).map (mirror D')) = docSum a d e := by
|
docSum a d ((e.map (mirror D)).map (mirror D')) = docSum a d e := by
|
||||||
rw [docSum_mirror, docSum_mirror]; omega
|
rw [docSum_mirror, docSum_mirror]; omega
|
||||||
|
|
||||||
|
/-- **A send changes no figure** (plan P4b S5): recording one leaves every
|
||||||
|
control account's balance, every document's sum on it and what is
|
||||||
|
unattached exactly as they were, through any day. A send adds a record and
|
||||||
|
no line, so the engine's own statement (`Ledger::mark_sent`) that a
|
||||||
|
document's DocState is the same before and after is this, line for line. -/
|
||||||
|
theorem Step.markSent_changes_no_figure (B : Books) (s : Send) (_h : s.doc ∈ B.docs)
|
||||||
|
(a d D : Nat) :
|
||||||
|
gl a (through D ({ B with sends := s :: B.sends } : Books).lines)
|
||||||
|
= gl a (through D B.lines)
|
||||||
|
∧ docSum a d (through D ({ B with sends := s :: B.sends } : Books).lines)
|
||||||
|
= docSum a d (through D B.lines)
|
||||||
|
∧ unattached a (through D ({ B with sends := s :: B.sends } : Books).lines)
|
||||||
|
= unattached a (through D B.lines) := by
|
||||||
|
exact ⟨rfl, rfl, rfl⟩
|
||||||
|
|
||||||
/-- States reachable from books with no lines. -/
|
/-- States reachable from books with no lines. -/
|
||||||
inductive Reachable : Books → Prop
|
inductive Reachable : Books → Prop
|
||||||
| empty (control : List Nat) : Reachable ⟨control, []⟩
|
| empty (control : List Nat) : Reachable ⟨control, [], []⟩
|
||||||
| step {B B' : Books} : Reachable B → Step B B' → Reachable B'
|
| step {B B' : Books} : Reachable B → Step B B' → Reachable B'
|
||||||
|
|
||||||
theorem Reachable.tagged {B : Books} (r : Reachable B) : Tagged B.control B.lines := by
|
theorem Reachable.tagged {B : Books} (r : Reachable B) : Tagged B.control B.lines := by
|
||||||
@@ -245,6 +280,9 @@ theorem Reachable.tagged {B : Books} (r : Reachable B) : Tagged B.control B.line
|
|||||||
| release c h =>
|
| release c h =>
|
||||||
intro l hl hc
|
intro l hl hc
|
||||||
exact ih l hl (List.mem_of_mem_erase hc)
|
exact ih l hl (List.mem_of_mem_erase hc)
|
||||||
|
| markSent _ _ =>
|
||||||
|
-- A send touches no line: the control lines are the ones it found.
|
||||||
|
exact ih
|
||||||
|
|
||||||
/-- **Nothing is ever unattached on a control account** in books these
|
/-- **Nothing is ever unattached on a control account** in books these
|
||||||
steps build, through any day, roles assigned and released included:
|
steps build, through any day, roles assigned and released included:
|
||||||
|
|||||||
Reference in new issue
Block a user