feat(arap): a write-off on the timeline, and the CPA's note (P4b S7)
The viewer's invoice page already drew a WriteOff movement; now the fixture proves it: a written-off invoice reads Written off, owes nothing, and its timeline's "Written off" row opens the entry (the screenshot invoice-written-off). Needs attention gains the one item the tax rule asks for: a write-off made this calendar year is listed, one per invoice, as "Write-off awaiting the CPA's note", with what was written off, the day, and the prompt that opens the document. Whether a debt is worthless, and in which year, is a tax judgment that stays with the CPA (plan 8.3.16); the books flag it and keep the evidence beside it until the year ends. The item is computed in ledger-package's one attention function, so the viewer and books.start see it alike; view_model.rs compares the two reads on the same books. The handbook's invoices-and-bills section starts here (S4.5 adds the issue, terms and payment steps around it): step 4 says when to void, to write off, or to reverse, and the recovery procedures D-49 records — the same year, reverse the write-off and record the payment; a later year, a plain entry to a bad-debt-recoveries revenue account, the invoice staying written off (IRC §111). Tests: a_write_off_shows_on_the_timeline, a_write_off_is_flagged_for_the_cpa.
This commit is contained in:
1 parent
0f1c7786ab
commit
b731d7ed04
5 files changed
+294
-1
No files matched your search
@@ -208,3 +208,58 @@ fn books_without_documents_show_no_overview_tiles_for_them() {
|
||||
shot(&mut h, "receivables-empty");
|
||||
assert!(h.query_by_label("No invoices").is_some());
|
||||
}
|
||||
|
||||
/// 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,93 @@ 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