merge: P4b S5, sent tracking (branch S5, ad649be3)
check / check (push) Canceled after 0s
two-user / two-user (push) Canceled after 0s

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:
swaitsandClaude Fable 5.1 committed 2026-10-28 21:21:21 -06:00
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}");
}
+86
View File
@@ -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"
);
}
+17
View File
@@ -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",
+1 -1
View File
@@ -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"));
+7
View 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(
+50
View File
@@ -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)]
+11
View File
@@ -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),
+102
View File
@@ -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)
+1
View File
@@ -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,
+18
View File
@@ -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)),
+126
View File
@@ -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).
+4
View File
@@ -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.
+3 -1
View File
@@ -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),
+3
View File
@@ -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"]
+2 -1
View File
@@ -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),
+41 -7
View File
@@ -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,
})) }))
} }
+18
View File
@@ -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())
}
+118
View File
@@ -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(),
}))
}
}
+78 -1
View File
@@ -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>,
}
+89 -3
View File
@@ -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();
+1
View File
@@ -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",
+57
View File
@@ -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::*;
+18 -1
View File
@@ -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
);
"#, "#,
]; ];
+55
View File
@@ -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" => {}
+33
View File
@@ -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);
+189 -2
View File
@@ -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"}
+82 -1
View File
@@ -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();
}
+17
View File
@@ -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
+1
View File
@@ -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
View File
@@ -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
+16
View File
@@ -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).
+4 -2
View File
@@ -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
View File
@@ -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
+64
View File
@@ -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.
+18
View File
@@ -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,
+3 -1
View File
@@ -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
View File
@@ -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)
+1
View File
@@ -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
+41 -3
View File
@@ -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: