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

+89 -3
View File
@@ -37,7 +37,12 @@
//! books.set_entry_instrument; a year whose worksheet can't be made
//! for want of named cash accounts while vendors were paid in it
//! (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
//! from the journal or unreadable, and damaged or missing stored
//! files (Problem); depreciation due through the last finished month
@@ -83,8 +88,8 @@ use std::collections::{BTreeSet, HashMap, HashSet};
use chrono::{Datelike, NaiveDate};
use ledger_core::parties::MoneyAccounts;
use ledger_core::{
AccountId, AccountType, AssetId, EntryId, EntryStatus, Ledger, LedgerError, Party, PartyId,
PartyMember, PeriodStatus,
AccountId, AccountType, AssetId, DocRef, DocStatus, EntryId, EntryStatus, Ledger, LedgerError,
Party, PartyId, PartyMember, PeriodStatus,
};
use ledger_store::{Attachment, DiskCheck, JournalLine, Reconciliation, Statement, Subject};
use rust_decimal::Decimal;
@@ -131,6 +136,8 @@ pub enum Target {
Asset(AssetId),
/// The documents and attachments list.
Documents,
/// One invoice's page.
Invoice(ledger_core::InvoiceId),
/// The journal.
Journal,
/// 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| {
a.account_type == AccountType::Liability && {
let n = a.name.to_lowercase();