feat(attention): unsent and unreminded invoices (P4b S5)

The books record that an invoice went out but do not chase it. U0b's
one attention engine now lists two items the sent record makes possible,
in the viewer's Needs attention and in books.start's next steps alike:

- "Invoice INV-0007 was never sent": an issued invoice (a number) still
  Open or Partial, with no send recorded, once today is past its issue
  date plus invoicing.unsent_days (default 2). A legacy invoice with no
  number is exempt, and so is a void or settled one.
- "Invoice INV-0007 is overdue: remind the customer": the invoice is
  overdue and its last send is older than invoicing.reminder_days
  (default 14). An invoice never sent gets the first item, not this one.

Both are Todo, open the invoice, and offer books.mark_sent with its
invoice and the tool's defaults. The viewer maps the new
Target::Invoice to its invoice page (one line; items still come only
from ledger-package), and the two lists cannot drift.

Tests: an_unsent_invoice_is_listed_until_a_send_clears_it (the overview
draws it; attention-unsent-invoice.png; a send clears it),
a_legacy_invoice_with_no_number_is_never_listed_as_unsent,
an_overdue_invoice_with_an_old_send_is_asked_to_remind, and
an_unsent_invoice_is_on_both_lists (viewer and books.start, step
books.mark_sent on invoice:1). The existing attention parity test stays
green.
This commit is contained in:
swaits committed 2026-10-27 13:46:43 -06:00
1 parent adc8804f61
commit 485bd4ae5f
4 files changed
+263 -3

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}");
}
+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,
+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);
}
+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();