merge: P4b S7, bad debts (branch S7, 4b4fecb9)

The fourth of the paused branches: Ledger::write_off and the bad_debts
control role, invoice.written_off with its evidence filed in the same
store call, books.write_off_invoice, the write-off on the timeline and
in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the
bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and
S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with
their seams restored, and: the scope table at 125; S7's law numbered 37;
B-41 placed before B-42; the handbook's step 4 and recovery procedures
folded into the one invoices-and-bills section; S7's wire tests in their
own module with the confirm S4.4's contract needs; S7's copy of the
payment steps dropped (S6 replaced them with Ledger::settle) and its
write-off journaled through S6's journal_auto_lines; in the old-books
fixture S7's hand-written write-off moved to a second invoice, since
S6's hand-written payment settles invoice 1 in full.

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:47:33 -06:00
commit 335a3eaa7e
47 files changed
+2883 -74

No files matched your search

+54
View File
@@ -336,3 +336,57 @@ fn an_overdue_invoice_with_an_old_send_is_asked_to_remind() {
assert!(item.detail.contains("14 days overdue"), "{}", item.detail);
assert!(item.prompt.contains("books.mark_sent"), "{}", item.prompt);
}
/// A written-off invoice (plan P4b S7): $500 issued, written off whole for
/// a bad debt with its evidence, so it reads Written off owing nothing.
fn write_off_books(dir: &TempDir) -> (u64, u64) {
let mut s = Store::create(&dir.0.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
s.add_account("3000", "Capital", AccountType::Equity, None, false)
.unwrap();
let client = s.add_customer("Harbor Client").unwrap();
let inv = s
.create_invoice(
client,
d(2026, 2, 1),
d(2026, 3, 1),
vec![line("Consulting", 50_000)],
)
.unwrap();
let (entry, _) = s
.write_off_invoice(
inv,
d(2026, 3, 10),
Decimal::new(50_000, 2),
"collection exhausted",
Some(ledger_store::WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: b"%PDF-1.4\nFinal demand\n%%EOF\n",
description: Some("Collection attempts"),
}),
)
.unwrap();
(inv, entry)
}
/// The write-off is on its invoice's timeline, reads "Written off", and
/// opens its entry.
#[test]
fn a_write_off_shows_on_the_timeline() {
let dir = TempDir::new("write-off");
let (inv, entry) = write_off_books(&dir);
let mut h = open(&dir, Route::Invoice(inv));
shot(&mut h, "invoice-written-off");
assert!(
h.query_by_label_contains("Written off, entry #").is_some(),
"the timeline shows the write-off"
);
// The status chip reads its label ("Written off"), not "late".
assert!(h.query_by_label("Written off").is_some());
h.get_by_label_contains(&format!("Written off, entry #{entry},"))
.click();
h.run_steps(2);
assert_eq!(h.state_mut().route(), &Route::Entry(entry));
}
+93
View File
@@ -831,3 +831,96 @@ fn all_dates_holds_an_entry_dated_after_today() {
assert_eq!(RegisterRange::All.dates(&b), (first, later));
assert_eq!(Range::AllTime.dates(&b), (first, later));
}
/// A write-off this calendar year is one Needs-attention item, "Write-off
/// awaiting the CPA's note", through the viewer and through the server's
/// own read of the books alike (plan P4b S7; the one function in
/// ledger-package::attention).
#[test]
fn a_write_off_is_flagged_for_the_cpa() {
let d = dir("write-off");
let today = NaiveDate::from_ymd_opt(2026, 6, 30).unwrap();
let mut s = Store::create(&d.join("books-data"), Currency::USD).unwrap();
s.add_account("1000", "Checking", AccountType::Asset, None, false)
.unwrap();
let cust = s.add_customer("Ridgeway Co").unwrap();
let inv = s
.create_invoice(
cust,
NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(),
NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(),
vec![DocumentLine {
description: "Survey".into(),
amount: Decimal::new(10_000, 2),
class_id: None,
account: None,
quantity: None,
unit_price: None,
}],
)
.unwrap();
let (entry, _) = s
.write_off_invoice(
inv,
NaiveDate::from_ymd_opt(2026, 3, 15).unwrap(),
Decimal::new(10_000, 2),
"collection exhausted",
Some(ledger_store::WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: b"%PDF-1.4\nFinal demand\n%%EOF\n",
description: Some("Collection attempts"),
}),
)
.unwrap();
// The server's list: the same function, over the Store.
let settings = s.ledger().settings();
let company = settings.company("USD");
let (cash, financing, investing) = (
settings.cash_accounts(),
settings.financing_accounts(),
settings.investing_accounts(),
);
let server = ledger_package::find(
&s,
today,
&ledger_package::Settings {
company: &company,
cash_accounts: &cash,
financing_accounts: &financing,
investing_accounts: &investing,
},
);
let want = format!("Write-off awaiting the CPA's note: Invoice {inv}");
let said = server.iter().find(|a| a.title == want).unwrap_or_else(|| {
panic!(
"the server's list has no {want:?}: {:?}",
server.iter().map(|a| &a.title).collect::<Vec<_>>()
)
});
assert_eq!(said.severity, ledger_package::Severity::Todo);
assert!(said.prompt.contains("wanderbooks"), "{}", said.prompt);
assert!(said.detail.contains("100.00"), "{}", said.detail);
drop(s);
// The viewer's list, over its snapshot, has the same item.
let books = data::load_once(&Source {
data_dir: d.join("books-data"),
today: Some(today),
})
.unwrap();
let shown = books
.attention
.iter()
.find(|a| a.title == want)
.unwrap_or_else(|| {
panic!(
"the viewer shows no {want:?}: {:?}",
books.attention.iter().map(|a| &a.title).collect::<Vec<_>>()
)
});
assert_eq!(shown.target, Some(ledger_gui::nav::Route::Documents));
// The viewer reads the same figures from the journal it replayed.
let st = books.ledger.invoice_state(inv, today).unwrap().unwrap();
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert!(books.ledger.get_entry(entry).is_some());
}