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:
commit
335a3eaa7e
47 files changed
+2883
-74
No files matched your search
@@ -830,6 +830,14 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
format!("Bill paid, {}", amount(p)),
|
||||
entry_id.map(Route::Entry),
|
||||
),
|
||||
"invoice.written_off" => {
|
||||
let invoice = p.get("invoice_id").and_then(|v| v.as_u64()).unwrap_or(id);
|
||||
ev(
|
||||
icon::HAND_COINS,
|
||||
format!("Invoice {invoice} written off, {}", amount(p)),
|
||||
entry_id.map(Route::Entry),
|
||||
)
|
||||
}
|
||||
"period.created" => ev(
|
||||
icon::CALENDAR_PLUS,
|
||||
format!("Period {} created", s(p, "name")),
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
Reference in new issue
Block a user