diff --git a/crates/ledger-cli/tests/attention_parity.rs b/crates/ledger-cli/tests/attention_parity.rs index 0a14ec4..1b9fd2c 100644 --- a/crates/ledger-cli/tests/attention_parity.rs +++ b/crates/ledger-cli/tests/attention_parity.rs @@ -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"); } } + +/// 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}"); +} diff --git a/crates/ledger-gui/src/attention.rs b/crates/ledger-gui/src/attention.rs index e7a7980..8331750 100644 --- a/crates/ledger-gui/src/attention.rs +++ b/crates/ledger-gui/src/attention.rs @@ -67,6 +67,7 @@ fn route(t: ledger_package::Target) -> Option { ledger_package::Target::Assets => Route::Assets, ledger_package::Target::Asset(a) => Route::Asset(a), ledger_package::Target::Documents => Route::Documents, + ledger_package::Target::Invoice(i) => Route::Invoice(i), ledger_package::Target::Journal => Route::Journal, ledger_package::Target::Report(r) => Route::Reports(match r { ledger_package::Report::Vendor1099 => ReportKind::Vendor1099, diff --git a/crates/ledger-gui/tests/invoices.rs b/crates/ledger-gui/tests/invoices.rs index f51ad3c..0709afc 100644 --- a/crates/ledger-gui/tests/invoices.rs +++ b/crates/ledger-gui/tests/invoices.rs @@ -8,6 +8,7 @@ use chrono::NaiveDate; use egui_kittest::Harness; use egui_kittest::kittest::Queryable; +use ledger_core::arap::{InvoiceFields, SendKind, Sent, SentVia}; use ledger_core::{AccountType, Currency, DocumentLine}; use ledger_gui::nav::Route; 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"); 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 { + 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); +} diff --git a/crates/ledger-package/src/attention.rs b/crates/ledger-package/src/attention.rs index ab7f33f..a5968ba 100644 --- a/crates/ledger-package/src/attention.rs +++ b/crates/ledger-package/src/attention.rs @@ -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();