feat(store): journal a write-off with its evidence (P4b S7)

A bad debt is deducted on evidence it is worthless (Treas. Reg.
§1.166-2), so the store will not journal one without it:
Store::write_off_invoice takes the evidence as bytes to file or as an
attachment already filed, and files it in the same call — a
receipt.attached on the write-off's entry and an attachment.linked to
the invoice (rules.md B-41). With no evidence it refuses, journaling
nothing.

The entry (Dr 6990 Bad Debt Expense / Cr 1200) and its evidence are
journaled after the engine's whole check and after the feature gate: the
new event type invoice.written_off needs documents_v2, so an older
version stops at the declaration rather than reading a written-off
invoice as still owed (B-27, D-52). Its payload is the invoice, the
entry, the day, the amount and the reason; replay tags the receivable
line WriteOff, and the projection keeps no written-off figure — it is
the tagged lines' sum (B-38).

`meta::invoice_written_off_of` reads and writes the payload; the replay
fuzzer draws the type.

8.0.6: (1) `books_written_under_older_rules_still_open` hand-writes the
new event and its entry through JournalWriter and still opens, with the
figures tailing; (2) a new fixture old-books/p4_s7_before was written by
main 4cad2cfc (the version before this unit) through the old-books-writer
harness — a numbered invoice with terms and snapshots, a partial payment,
a voided invoice and a paid bill — with its expected.json; this version
opens it and reports the same figures, the viewer included; (3) the
cut-over entry is in format_upgrade.rs's books_p2_served_cut_over_whole;
(4) documents_v2_is_its_own_feature is green and the event gate's arms
pass; (5) no figure moves: no golden changed (the previous version's
expected.json is the comparison); (6) merge does not need the owner's
go-ahead; the live books refuse the new write until he gives it.

Tests: write_off_waits_for_documents_v2, write_off_replays_and_projects,
write_off_without_evidence_is_refused; arap_props.rs gains WriteOff.
This commit is contained in:
swaits committed 2026-10-27 12:44:45 -06:00
1 parent 62f2fb058e
commit 4e71cf6320
13 files changed
+1005 -7

No files matched your search

+30
View File
@@ -703,6 +703,28 @@ fn books_p2_served_cut_over_whole() {
// A project by name alone is a plain class, which P2 reads.
a.call("books.add_project", json!({"name": "Lighthouse"}))
.unwrap();
// A receipt not yet on an entry (P2 reads the unlink; entry 7's bank
// fee is under the receipt policy's threshold, so taking its last
// receipt is allowed), and a legacy-shape invoice (no new field, so no
// feature): the evidence and the document P4b S7's write-off needs
// after the go-ahead.
a.call(
"books.unlink_receipt",
json!({"attachment_id": 7, "reason": "filed against the wrong entry"}),
)
.unwrap();
let customer = a
.call("books.add_customer", json!({"name": "Ridgeway Co"}))
.unwrap()["id"]
.clone();
let unpaid = a
.call(
"books.create_invoice",
json!({"party_id": customer, "date": "2026-01-10", "due_date": "2026-02-09",
"lines": [{"description": "Survey", "amount": "300.00"}]}),
)
.unwrap()["id"]
.clone();
let waiting = journal(&dir);
let refused: Vec<(&str, Value)> = vec![
// An invoice's or bill's line naming its project
@@ -743,6 +765,14 @@ fn books_p2_served_cut_over_whole() {
"books.close_period",
json!({"period_id": q2, "confirmed": true}),
),
// A bad-debt write-off (documents_v2's new event, P4b S7): its
// evidence filed and linked in the same call.
(
"books.write_off_invoice",
json!({"invoice": unpaid, "date": "2026-02-20",
"reason": "collection exhausted",
"existing_attachment_id": 7, "confirmed": true}),
),
];
for (tool, args) in &refused {
let e = a.call(tool, args.clone()).unwrap_err();
+3 -2
View File
@@ -76,8 +76,9 @@ pub use instruments::{Instrument, InstrumentId, InstrumentKind};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use kernel::{EntryFlows, Flow};
pub use ledger::{
ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES, RETAINED_EARNINGS,
REVENUE, ReconciliationReport, UnclearedLine, control_account, control_accounts,
BAD_DEBTS, ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYROLL, RECEIVABLES,
RETAINED_EARNINGS, REVENUE, ReconciliationReport, UnclearedLine, control_account,
control_accounts,
};
pub use money::{Currency, Money};
pub use parties::{Party, PartyField, PartyId, Role, TinFacts, TinType, W4Facts, W9Facts};
+10
View File
@@ -144,6 +144,16 @@ pub enum StoreError {
#[error("store not found at {0}")]
NotFound(String),
/// A write-off with no evidence (rules.md B-41 INVARIANT 1): a bad
/// debt is deducted on evidence that it is worthless (Treas. Reg.
/// §1.166-2), so the books take the write-off only with its file.
#[error(
"a write-off needs its evidence: file the collection correspondence or the bankruptcy \
notice with upload_id, or name an attachment already filed with existing_attachment_id \
(Treas. Reg. §1.166-2: worthlessness on all the pertinent evidence)"
)]
WriteOffNeedsEvidence,
/// The requested base currency differs from the store's.
#[error("base currency mismatch: store is {0}, requested {1}")]
CurrencyMismatch(String, String),
+1
View File
@@ -243,6 +243,7 @@ pub const EVENT_TYPES: &[&str] = &[
"fx.rate_set",
"invoice.created",
"invoice.payment_received",
"invoice.written_off",
"instrument.added",
FORMAT_SET,
"journal.tail_repaired",
+1 -1
View File
@@ -49,6 +49,6 @@ pub use snapshot::{DiskCheck, Snapshot};
pub use store::{
Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, InvoicePreview,
JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN, PayrollLineInput, Reconciliation,
Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for,
Statement, Store, Subject, VerifyReport, WriteOffEvidence, database_in_use, db_file, db_file_for,
is_database_file,
};
+48
View File
@@ -361,6 +361,54 @@ pub fn entry_approved_of(p: &Value, seq: u64) -> Result<(u64, Approval), StoreEr
))
}
/// A bad-debt write-off as journaled (plan P4b S7; rules.md B-41):
/// `invoice.written_off {invoice_id, entry_id, date, amount, reason}`.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct WrittenOff {
/// The invoice written off.
pub invoice_id: u64,
/// The entry that posted it (already rebuilt from its own events).
pub entry_id: u64,
/// The write-off's day.
pub date: NaiveDate,
/// What was written off.
pub amount: rust_decimal::Decimal,
/// Why (it has no resolution: the evidence is the receipt).
pub reason: String,
}
/// `invoice.written_off {invoice_id, entry_id, date, amount, reason}`.
pub fn invoice_written_off(
invoice_id: u64,
entry_id: u64,
date: NaiveDate,
amount: rust_decimal::Decimal,
reason: &str,
) -> Value {
json!({
"invoice_id": invoice_id,
"entry_id": entry_id,
"date": date.to_string(),
"amount": amount.to_string(),
"reason": reason,
})
}
/// Read an `invoice.written_off` payload.
pub fn invoice_written_off_of(p: &Value) -> Result<WrittenOff, StoreError> {
const E: &str = "invoice.written_off";
let amount = str_of(p, E, "amount")?
.parse()
.map_err(|_| corrupt(E, "amount"))?;
Ok(WrittenOff {
invoice_id: u64_of(p, E, "invoice_id")?,
entry_id: u64_of(p, E, "entry_id")?,
date: date_of(&str_of(p, E, "date")?, E, "date")?,
amount,
reason: str_of(p, E, "reason")?,
})
}
#[cfg(test)]
mod tests {
use super::*;
+138
View File
@@ -385,6 +385,31 @@ struct AttachRequest<'a> {
description: Option<&'a str>,
}
/// The evidence a bad-debt write-off files (plan P4b S7; rules.md B-41
/// INVARIANT 1): the file that shows the debt is worthless — collection
/// correspondence, a bankruptcy notice (Treas. Reg. §1.166-2). Given as
/// bytes to file in the same call, or as an attachment already in the
/// books that is linked to the write-off's entry and its invoice.
#[derive(Clone, Copy)]
pub enum WriteOffEvidence<'a> {
/// A file's bytes: filed against the write-off's entry (`receipt.
/// attached`) and linked to the invoice in the same call.
File {
/// Its name, e.g. "collection-letter.pdf".
file_name: &'a str,
/// Its MIME type.
mime_type: &'a str,
/// Its bytes.
bytes: &'a [u8],
/// What it is, in one line.
description: Option<&'a str>,
},
/// An attachment already filed and not linked to an entry (it was
/// filed for this): it is linked to the write-off's entry and the
/// invoice.
Existing(u64),
}
impl Store {
// ------------------------------------------------------------------
// Open / create
@@ -3653,6 +3678,108 @@ impl Store {
Ok(live)
}
/// Write off an invoice as a bad debt (plan P4b S7; rules.md B-41),
/// filing its evidence in the same call. The write-off's entry (Dr
/// 6990 Bad Debt Expense / Cr A/R) is journaled through the standard
/// entry flow, then `invoice.written_off`, then the evidence: a
/// `receipt.attached` on the write-off's entry and an
/// `attachment.linked` to the invoice. Returns the write-off's entry
/// and its evidence's attachment.
///
/// ```text
/// GOAL: a bad debt comes off the receivables with the proof it was
/// worthless, in one call that is all or nothing.
/// SOURCE: IRC §166(a); Treas. Reg. §1.166-1(e), -2(a), -3(a);
/// docs/spec/rules.md B-41; plan P4b S7, 8.3.6.
/// INVARIANTS:
/// 1. A write that needs journal feature documents_v2 (the event
/// type) MUST be refused in books that haven't declared it,
/// journaling nothing (FormatUpgradePending).
/// 2. The engine's whole check runs first (cash basis, the amount,
/// the day): a refusal MUST leave the journal, the ledger and
/// the projection unchanged.
/// 3. No evidence is no write-off (WriteOffNeedsEvidence): the file
/// is a receipt attached to the entry and linked to the invoice,
/// in this call.
/// 4. Applying the lines MUST replay to the same books
/// (`invoice.written_off` tags the invoice's line WriteOff).
/// ```
pub fn write_off_invoice(
&mut self,
invoice: InvoiceId,
date: NaiveDate,
amount: Decimal,
reason: &str,
evidence: Option<WriteOffEvidence<'_>>,
) -> Result<(EntryId, u64), StoreError> {
self.ensure_feature(crate::format::DOCUMENTS_V2)?;
self.rehearse(|l| l.write_off(invoice, date, amount, reason).map(drop))?;
self.approval_gate(
|l| l.write_off(invoice, date, amount, reason).map(drop),
false,
)?;
let Some(evidence) = evidence else {
return Err(StoreError::WriteOffNeedsEvidence);
};
// Every check on the evidence before anything is journaled: a bad
// attachment refuses the whole write-off.
if let WriteOffEvidence::Existing(id) = evidence {
self.check_linkable(id)?;
if self
.attachment_links(id)?
.contains(&Subject::Invoice(invoice))
{
return Err(StoreError::AlreadyLinked(
id,
Subject::Invoice(invoice).describe(),
));
}
}
// The bytes hit the content-addressed store before anything is
// journaled: an orphan blob from a later refusal is harmless
// (unreferenced, deduplicated), a journal line naming a blob that
// failed to store would not be (as the issued invoice's PDF, S4.3).
if let WriteOffEvidence::File { bytes, .. } = evidence {
self.blobs.put(bytes)?;
}
let bad = self.ensure_control_account(&ledger_core::BAD_DEBTS)?;
let ar = self.ensure_control_account(&ledger_core::RECEIVABLES)?;
let description = format!("Write off invoice {invoice}: {}", reason.trim());
let (entry_id, mut journaled) =
self.journal_auto_entry(date, &description, bad, ar, amount)?;
let written_off = crate::meta::invoice_written_off(
invoice,
entry_id,
date,
amount,
reason.trim(),
);
journaled.push(self.append_journal("invoice.written_off", written_off)?);
let live = self.ledger.write_off(invoice, date, amount, reason)?;
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
for line in &journaled {
apply_line_to_sqlite(&self.conn, line)?;
}
let attachment_id = match evidence {
WriteOffEvidence::File {
file_name,
mime_type,
bytes,
description,
} => {
let (id, _, _) =
self.attach_receipt(Some(entry_id), file_name, mime_type, bytes, description)?;
id
}
WriteOffEvidence::Existing(id) => {
self.link_attachment(id, Subject::Entry(entry_id))?;
id
}
};
self.link_attachment(attachment_id, Subject::Invoice(invoice))?;
Ok((entry_id, attachment_id))
}
/// The journal features a payment of `amount` on `doc` needs
/// ([`Self::receive_payment_with`] SCENARIOS 2 and 3): document_standing
/// when more than an older version thinks is still owed (the
@@ -6999,6 +7126,13 @@ fn replay_line(
get_u64(p, "entry_id")?,
)?;
}
"invoice.written_off" => {
// The write-off's entry was already rebuilt; this only tags
// its receivable line with the invoice (WriteOff), which is
// what makes the written-off figure and the status derive.
let w = crate::meta::invoice_written_off_of(p)?;
ledger.replay_invoice_write_off(w.invoice_id, w.amount, w.entry_id)?;
}
"fx.rate_set" => {
ledger.set_fx_rate(
Currency::new(get_str(p, "currency")?)
@@ -7964,6 +8098,10 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
// A payment moves no document row: what a document is paid is read
// from the ledger lines tagged with it (schema v15, rules.md B-38).
"invoice.payment_received" => {}
// A write-off moves no document row either: written_off and the
// status are derived from the tagged lines (the tag is set by
// replay; rules.md B-41).
"invoice.written_off" => {}
"bill.paid" => {
conn.execute(
"INSERT OR REPLACE INTO entry_vendors(entry_id, vendor_id) \
+24
View File
@@ -64,6 +64,9 @@ enum Op {
Reverse(usize, u8),
/// Void document `k` on day `n`.
Void(usize, u8),
/// Write off an invoice `k` (mod the documents) on day `n` for `c`
/// cents, with its evidence filed in the same call.
WriteOff(usize, u8, i64),
/// Close month `m` (January to March), in order.
Close(u8),
/// A hand line on receivables (true) or payables: always refused.
@@ -77,6 +80,8 @@ fn arb_op() -> impl Strategy<Value = Op> {
.prop_map(|(k, n, c, card, r)| Op::Pay(k, n, c, card, r)),
3 => (0usize..40, 0u8..90).prop_map(|(k, n)| Op::Reverse(k, n)),
1 => (0usize..8, 0u8..90).prop_map(|(k, n)| Op::Void(k, n)),
2 => (0usize..8, 0u8..90, 1i64..40_000)
.prop_map(|(k, n, c)| Op::WriteOff(k, n, c)),
1 => (1u8..4).prop_map(Op::Close),
1 => (any::<bool>(), 1i64..10_000).prop_map(|(r, c)| Op::Hand(r, c)),
]
@@ -173,6 +178,25 @@ fn run(s: &mut Store, k: &Setup, ops: &[Op], docs: &mut Vec<DocRef>) -> Result<(
Op::Void(i, n) => s
.void_document(docs[i % docs.len()], day(n), "duplicate")
.map(drop),
Op::WriteOff(..) if docs.is_empty() => Ok(()),
Op::WriteOff(i, n, c) => match docs[i % docs.len()] {
// A bill is never written off: the API takes an invoice.
DocRef::Bill(_) => Ok(()),
DocRef::Invoice(id) => s
.write_off_invoice(
id,
day(n),
cents(c),
"uncollectible",
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"),
}),
)
.map(drop),
},
Op::Close(m) => {
let start = NaiveDate::from_ymd_opt(2026, u32::from(m), 1).unwrap();
let end = start + chrono::Months::new(1) - chrono::Days::new(1);
+225 -1
View File
@@ -14,7 +14,7 @@ use ledger_core::LedgerError;
use ledger_core::arap;
use ledger_core::arap::DocumentLine;
use ledger_core::{AccountType, Currency, Money, Side};
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject};
use ledger_store::{JOURNAL_FILE, Store, StoreError, Subject, WriteOffEvidence};
use rust_decimal::Decimal;
use serde_json::{Value, json};
use std::path::{Path, PathBuf};
@@ -1054,3 +1054,227 @@ fn preview_invoice_and_bill_journal_nothing() {
);
s.verify().unwrap();
}
// ---------------------------------------------------------------------------
// Bad-debt write-offs (plan P4b S7; rules.md B-41)
// ---------------------------------------------------------------------------
/// The evidence a write-off test files: a collection letter.
fn collection_letter() -> &'static [u8] {
b"%PDF-1.4\nFinal demand, unpaid\n%%EOF\n"
}
/// A write-off needs journal feature documents_v2 (its event type is new;
/// an older version refuses the unknown type): refused, journaling
/// nothing, until the owner's upgrade; then it is taken and replays to the
/// same books (R-A, B-27).
#[test]
fn write_off_waits_for_documents_v2() {
let dir = fixture("write-off-v2");
let mut s = Store::open(&dir.0).unwrap();
assert!(
!s.journal_format()
.has_feature(ledger_store::format::DOCUMENTS_V2)
);
let head = s.head();
let len = journal_len(&dir.0);
let evidence = || {
Some(WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: collection_letter(),
description: Some("Collection attempts"),
})
};
match s.write_off_invoice(1, d(2026, 2, 1), cents(10_000), "uncollectible", evidence()) {
Err(StoreError::FormatUpgradePending { need }) => {
assert_eq!(need, "the journal feature documents_v2")
}
other => panic!("expected FormatUpgradePending, got {other:?}"),
}
assert_eq!(s.head(), head, "nothing journaled");
assert_eq!(journal_len(&dir.0), len);
assert!(s.upgrade_format().unwrap().is_some());
let (entry, attachment) = s
.write_off_invoice(1, d(2026, 2, 1), cents(10_000), "uncollectible", evidence())
.unwrap();
s.verify().unwrap();
let live = s.ledger().clone();
let st = live
.invoice_state(1, d(2026, 12, 31))
.unwrap()
.unwrap();
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert_eq!(st.written_off, cents(10_000));
// The evidence is on the write-off's entry and on its invoice.
let on_entry = s.attachments_for(Subject::Entry(entry)).unwrap();
assert_eq!(on_entry.len(), 1);
assert_eq!(on_entry[0].id, attachment);
let on_invoice = s.attachments_for(Subject::Invoice(1)).unwrap();
assert!(on_invoice.iter().any(|a| a.id == attachment));
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
s.verify().unwrap();
}
/// A write-off is journaled as its entry and one `invoice.written_off`,
/// and replay derives the same figures and the projection agrees: the
/// invoice reads Written off, the bad-debt expense is on 6990, and the
/// control account still equals its documents (B-41, B-38 INVARIANT 1).
#[test]
fn write_off_replays_and_projects() {
let dir = test_dir("write-off-replay");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let bank = s
.add_account("1000", "Bank", AccountType::Asset, None, false)
.unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let inv = s
.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 10_000)])
.unwrap();
// Half paid, the rest written off: the invoice reads Written off with
// 50 paid and 50 written off (B-38 SCENARIO 7).
s.receive_payment(inv, d(2026, 4, 20), cents(5_000), bank)
.unwrap();
let from = journal_len(&dir.0);
let (entry, attachment) = s
.write_off_invoice(
inv,
d(2026, 4, 30),
cents(5_000),
"collection exhausted",
Some(WriteOffEvidence::File {
file_name: "collection-letter.pdf",
mime_type: "application/pdf",
bytes: collection_letter(),
description: Some("Collection attempts"),
}),
)
.unwrap();
// The event: one invoice.written_off with the five facts, right after
// the entry's own events.
let got = since(&dir.0, from);
let written: Vec<&(String, Value)> = got
.iter()
.filter(|(t, _)| t == "invoice.written_off")
.collect();
assert_eq!(written.len(), 1, "{got:?}");
let (_, p) = written[0];
assert_eq!(p["invoice_id"], json!(inv));
assert_eq!(p["entry_id"], json!(entry));
assert_eq!(p["date"], json!("2026-04-30"));
assert_eq!(p["amount"], json!("50.00"));
assert_eq!(p["reason"], json!("collection exhausted"));
// The evidence: a receipt.attached on the entry, then attachment.linked
// to the invoice.
assert!(got.iter().any(|(t, v)| t == "receipt.attached"
&& v["attachment_id"] == json!(attachment)
&& v["entry_id"] == json!(entry)));
assert!(got.iter().any(|(t, v)| t == "attachment.linked"
&& v["attachment_id"] == json!(attachment)));
// The figures, live.
let st = s.ledger().invoice_state(inv, d(2026, 5, 1)).unwrap().unwrap();
assert_eq!(st.status, ledger_core::DocStatus::WrittenOff);
assert_eq!(
(st.paid, st.written_off, st.outstanding),
(cents(5_000), cents(5_000), Decimal::ZERO)
);
let bad = s.ledger().chart().find_by_code("6990").unwrap().id;
assert_eq!(s.ledger().account_balance(bad).unwrap(), cents(5_000));
let ar = s.ledger().chart().find_by_code("1200").unwrap().id;
assert_eq!(s.ledger().account_balance(ar).unwrap(), Decimal::ZERO);
assert!(
s.ledger()
.control_check(d(2026, 5, 1))
.unwrap()
.iter()
.all(|c| c.difference.is_zero() && c.attention.is_empty())
);
// `verify` compares the projection with the journal (the write-off
// moves no document row: the figures are the tagged lines').
s.verify().unwrap();
let live = s.ledger().clone();
drop(s);
let s = Store::open(&dir.0).unwrap();
assert!(
live.differs_from(s.ledger()).is_empty(),
"{:?}",
live.differs_from(s.ledger())
);
let replayed = s.ledger().invoice_state(inv, d(2026, 5, 1)).unwrap().unwrap();
assert_eq!(replayed, st);
s.verify().unwrap();
}
/// No evidence, no write-off (B-41 INVARIANT 1; Treas. Reg.
/// §1.166-2(a)): refused, journaling nothing; an attachment already filed
/// is linked instead, to the entry and the invoice.
#[test]
fn write_off_without_evidence_is_refused() {
let dir = test_dir("write-off-evidence");
let mut s = Store::create(&dir.0, Currency::USD).unwrap();
let c = s.add_customer("Fernhill Labs").unwrap();
let inv = s
.create_invoice(c, d(2026, 4, 1), d(2026, 5, 1), vec![line("Design", 10_000)])
.unwrap();
let head = s.head();
let len = journal_len(&dir.0);
assert!(matches!(
s.write_off_invoice(inv, d(2026, 4, 30), cents(10_000), "uncollectible", None),
Err(StoreError::WriteOffNeedsEvidence)
));
assert_eq!(s.head(), head, "nothing journaled");
assert_eq!(journal_len(&dir.0), len);
// An attachment that isn't in the books refuses it too, before
// anything is written.
assert!(matches!(
s.write_off_invoice(
inv,
d(2026, 4, 30),
cents(10_000),
"uncollectible",
Some(WriteOffEvidence::Existing(999)),
),
Err(StoreError::UnknownAttachment(999))
));
assert_eq!(s.head(), head, "nothing journaled");
// One already filed (unlinked) is linked to the write-off's entry and
// its invoice.
let (attachment, _, _) = s
.file_document(
"other",
"collection-letter.pdf",
"application/pdf",
collection_letter(),
Some("Collection attempts"),
)
.unwrap();
let (entry, evidence) = s
.write_off_invoice(
inv,
d(2026, 4, 30),
cents(10_000),
"uncollectible",
Some(WriteOffEvidence::Existing(attachment)),
)
.unwrap();
assert_eq!(evidence, attachment);
assert!(
s.attachments_for(Subject::Entry(entry))
.unwrap()
.iter()
.any(|a| a.id == attachment)
);
assert!(
s.attachments_for(Subject::Invoice(inv))
.unwrap()
.iter()
.any(|a| a.id == attachment)
);
s.verify().unwrap();
}
@@ -0,0 +1,431 @@
{
"assets": [],
"bills": [
{
"amount_paid": "80.00",
"date": "2026-02-07",
"due_date": "2026-03-09",
"entry_id": 6,
"id": 1,
"lines": [
{
"amount": "80.00",
"description": "Paper"
}
],
"memo": "A4 paper",
"received_on": "2026-02-08",
"status": "Paid",
"terms": {
"kind": {
"net": 30
},
"text": "Net 30"
},
"vendor_id": 1,
"vendor_ref": "TOBIN-77"
}
],
"entries": [
{
"date": "2026-01-01",
"description": "Opening capital",
"id": 1,
"lines": [
{
"account": 1,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 2,
"amount": {
"amount": "1000.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-01",
"description": "Invoice 1",
"id": 2,
"lines": [
{
"account": 3,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 4,
"amount": {
"amount": "500.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-10",
"description": "Payment on invoice 1",
"id": 3,
"lines": [
{
"account": 1,
"amount": {
"amount": "200.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 3,
"amount": {
"amount": "200.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-05",
"description": "Invoice 2",
"id": 4,
"lines": [
{
"account": 3,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 4,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-02-06",
"description": "Void invoice 2: wrong customer",
"id": 5,
"lines": [
{
"account": 3,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
},
{
"account": 4,
"amount": {
"amount": "40.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
}
],
"reversal_of": 4,
"status": "Posted"
},
{
"date": "2026-02-07",
"description": "Bill 1",
"id": 6,
"lines": [
{
"account": 5,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 6,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
},
{
"date": "2026-03-01",
"description": "Payment on bill 1",
"id": 7,
"lines": [
{
"account": 6,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Debit"
},
{
"account": 1,
"amount": {
"amount": "80.00",
"currency": [
85,
83,
68
]
},
"side": "Credit"
}
],
"reversal_of": null,
"status": "Posted"
}
],
"entry_parties": [
[
2,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
3,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
4,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
5,
[
{
"party_id": 1,
"share": "derived"
}
]
],
[
6,
[
{
"party_id": 2,
"share": "derived"
}
]
],
[
7,
[
{
"party_id": 2,
"share": "derived"
}
]
]
],
"invoices": [
{
"amount_paid": "200.00",
"bill_to": {
"address": "9 Ridgeway Rd",
"email": "ap@fernhill.example",
"name": "Fernhill Labs"
},
"customer_id": 1,
"customer_ref": "PO-7",
"date": "2026-02-01",
"due_date": "2026-03-03",
"entry_id": 2,
"id": 1,
"lines": [
{
"amount": "500.00",
"description": "Design"
}
],
"memo": "Thanks",
"number": "INV-0001",
"seller": {
"address": "1 Main St",
"name": "Wanderbooks Test Co"
},
"status": "Partial",
"terms": {
"discount": {
"days": 10,
"percent_bp": 200
},
"kind": {
"net": 30
},
"text": "2/10 net 30"
}
},
{
"amount_paid": "0",
"customer_id": 1,
"date": "2026-02-05",
"due_date": "2026-03-07",
"entry_id": 4,
"id": 2,
"lines": [
{
"amount": "40.00",
"description": "Setup"
}
],
"status": "Open"
}
],
"payroll_runs": [],
"periods": [],
"recurring": [],
"schedules": [],
"snapshot": {
"Ok": {
"balance_sheet": {
"assets": "1420",
"balanced": true,
"current_earnings": "420",
"equity": "1420",
"liabilities": "0"
},
"profit_and_loss": {
"expenses": "80",
"net_income": "420",
"revenue": "500"
},
"trial_balance": {
"lines": [
{
"code": "1000",
"credit": "0",
"debit": "1120",
"name": "Checking"
},
{
"code": "1200",
"credit": "0",
"debit": "300",
"name": "Accounts Receivable"
},
{
"code": "3000",
"credit": "1000",
"debit": "0",
"name": "Capital"
},
{
"code": "4000",
"credit": "500",
"debit": "0",
"name": "Revenue"
},
{
"code": "6999",
"credit": "0",
"debit": "80",
"name": "General Expenses"
}
],
"total_credits": "1500",
"total_debits": "1500"
}
}
},
"verify": {
"Ok": null
}
}
@@ -0,0 +1,40 @@
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-27T17:42:15Z","type":"store.created"}
{"payload":{"features":["account_renamed","asset_basis","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"853fa1a23a8e98ed4afffa7f01cb1c0bb458fd7c2218706cdb53a579308a6003","seq":2,"ts":"2026-10-27T17:42:15Z","type":"journal.format_set"}
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Checking","parent":null,"placeholder":false},"prev":"3dea41067629e227334a7e6b050ac103ca121872f2d912fb1d472eb903ae7f59","seq":3,"ts":"2026-10-27T17:42:15Z","type":"account.added"}
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"ee5de65058a81e155528a5258852611d86c0e8ef41e2e97b40644685eb3250df","seq":4,"ts":"2026-10-27T17:42:15Z","type":"account.added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"26cb19b6f713e2269e2e16d040128daa6b69b354c70d51f0202185e84c8f5450","seq":5,"ts":"2026-10-27T17:42:15Z","type":"entry.created"}
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"597afb2305dfcd7a9c9ba11b22996a53a29d71f11895405c29f36d9d8eba26aa","seq":6,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"7ebce83af99c2a0be9b4626d5d4546c3392d11b96bebb9276597c0b1944cada2","seq":7,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"1b4733efe3583fe881e92aabc2b1a8f319911a779e8d986edd427d6fc392b619","seq":8,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"}
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"17ef917991a9c0f46e6a28b108fc3cc181a61a7c0bd5827f123cceea993c19ad","seq":9,"ts":"2026-10-27T17:42:15Z","type":"customer.added"}
{"payload":{"id":1,"name":"Tobin Harlow Supply"},"prev":"6f1d020a91130ceedf8830af4f048b42e1c243636be9a70a7f1abf0568147836","seq":10,"ts":"2026-10-27T17:42:15Z","type":"vendor.added"}
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":3,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"fb7aeacef5825917e93fe9dea60e638c60207656b3e6bcfa79ea269eedd67eb3","seq":11,"ts":"2026-10-27T17:42:15Z","type":"account.added"}
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Revenue","parent":null,"placeholder":false},"prev":"a6c35a0e3fe245e8bf455163a9599eca70df8295daa9f899ca5b1168815d42dc","seq":12,"ts":"2026-10-27T17:42:15Z","type":"account.added"}
{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2},"prev":"0d4ed5e9db70c993de38467a67ed80dde4185eaec240b390d062bd5102b86a6c","seq":13,"ts":"2026-10-27T17:42:15Z","type":"entry.created"}
{"payload":{"account":3,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"b4b54695c5cb7b92c423cb0336eef51cd493516ea1c073986d53dea2bf38dedf","seq":14,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"79a2c8f3a4dce65d6cf6c6c13f0d7df4feeff4fa3543fa4732b64fb96c6c57c9","seq":15,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-01","description":"Invoice 1","id":2,"lines":[{"account":3,"amount":"500.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"a965e96d442a0ee5abb6878ca981583f4fe29da564a71217d331ec5fc642b701","seq":16,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"}
{"payload":{"bill_to":{"address":"9 Ridgeway Rd","email":"ap@fernhill.example","name":"Fernhill Labs"},"customer_id":1,"customer_ref":"PO-7","date":"2026-02-01","due_date":"2026-03-03","entry_id":2,"id":1,"lines":[{"amount":"500.00","description":"Design"}],"memo":"Thanks","number":"INV-0001","seller":{"address":"1 Main St","name":"Wanderbooks Test Co"},"terms":{"discount":{"days":10,"percent_bp":200},"kind":{"net":30},"text":"2/10 net 30"}},"prev":"9d8f3a1a5a0161040cf9caf8b5710c9fd7e61d909cee685d990d9b5a309fa695","seq":17,"ts":"2026-10-27T17:42:15Z","type":"invoice.created"}
{"payload":{"date":"2026-02-10","description":"Payment on invoice 1","id":3},"prev":"5eac387cbd55eafd2637474b8958baf2e7ca7cacec73535b2310ab124e79e6a1","seq":18,"ts":"2026-10-27T17:42:15Z","type":"entry.created"}
{"payload":{"account":1,"amount":"200.00","currency":"USD","id":3,"side":"debit"},"prev":"00b6089df07024fb077049c5206d3dd3812fa662b57af1ae50d42511b34ca0d9","seq":19,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"account":3,"amount":"200.00","currency":"USD","id":3,"side":"credit"},"prev":"26e9ee32e801bedd995f28eee065e4915b654a55c8569fb3ec9327a577c81959","seq":20,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-10","description":"Payment on invoice 1","id":3,"lines":[{"account":1,"amount":"200.00","currency":"USD","side":"debit"},{"account":3,"amount":"200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"f9e36c8f805aeea8656182becc1b47e7947791ff6f4fe36fc469fd7aaa4354c8","seq":21,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"}
{"payload":{"amount":"200.00","cash_account":1,"date":"2026-02-10","entry_id":3,"invoice_id":1},"prev":"a95f752a661405b4fe0904623b0a2b908b184af28a371f9833b9aa3232685dbb","seq":22,"ts":"2026-10-27T17:42:15Z","type":"invoice.payment_received"}
{"payload":{"date":"2026-02-05","description":"Invoice 2","id":4},"prev":"4b96e27f6bb06775b7d3f4b183ed16e5d7c07ab8dd5e202fafbb8aaa23915cc6","seq":23,"ts":"2026-10-27T17:42:15Z","type":"entry.created"}
{"payload":{"account":3,"amount":"40.00","currency":"USD","id":4,"side":"debit"},"prev":"95674fd07333f129d8289f1287190f39f6a8235bdcd9bd1ce01fa7f3f81b2068","seq":24,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"account":4,"amount":"40.00","currency":"USD","id":4,"side":"credit"},"prev":"6c7b09c9a08781eb0f424d05252b88194063b7d954d0a3eed24a7d102fc228e0","seq":25,"ts":"2026-10-27T17:42:15Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-05","description":"Invoice 2","id":4,"lines":[{"account":3,"amount":"40.00","currency":"USD","side":"debit"},{"account":4,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4aef53f197b1ad2b8287d1fbdd2cba6c5ee965421f77a889be5ba30541d4c2d9","seq":26,"ts":"2026-10-27T17:42:15Z","type":"entry.posted"}
{"payload":{"customer_id":1,"date":"2026-02-05","due_date":"2026-03-07","entry_id":4,"id":2,"lines":[{"amount":"40.00","description":"Setup"}]},"prev":"672d61478a6f83d099c7889f75880e5ce0d7a214838720536fcb2eb998ebc2d4","seq":27,"ts":"2026-10-27T17:42:15Z","type":"invoice.created"}
{"payload":{"date":"2026-02-06","description":"Void invoice 2: wrong customer","id":5,"lines":[{"account":3,"amount":"40.00","currency":"USD","side":"credit"},{"account":4,"amount":"40.00","currency":"USD","side":"debit"}],"reverses":4},"prev":"3f8b16f5949e56504750ee6ebfe1edf08a1a8afe74fc75aafe6b2bee883056be","seq":28,"ts":"2026-10-27T17:42:15Z","type":"entry.reversed"}
{"payload":{"account_type":"Expense","code":"6999","currency":"USD","id":5,"name":"General Expenses","parent":null,"placeholder":false},"prev":"57481256716e8ec09f325832cf98df9020505c58b8b2d7136f14d2f8cb4b57ea","seq":29,"ts":"2026-10-27T17:42:16Z","type":"account.added"}
{"payload":{"account_type":"Liability","code":"2000","currency":"USD","id":6,"name":"Accounts Payable","parent":null,"placeholder":false},"prev":"7e7dd770042b61627f9f6a528f38fcae1447eb6b5ad8f1ed378f851667ae51de","seq":30,"ts":"2026-10-27T17:42:16Z","type":"account.added"}
{"payload":{"date":"2026-02-07","description":"Bill 1","id":6},"prev":"f97abbb46d74293510e8b37f54a8c863287df3cb9775e8e159550508a6313160","seq":31,"ts":"2026-10-27T17:42:16Z","type":"entry.created"}
{"payload":{"account":5,"amount":"80.00","currency":"USD","id":6,"side":"debit"},"prev":"d7e394993994fd4e5a1d4017cd30b4e72e3b2ddac8d91ff86a97cbc4a517a667","seq":32,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"}
{"payload":{"account":6,"amount":"80.00","currency":"USD","id":6,"side":"credit"},"prev":"048f99094bf692587a1f3463d663d42efd5681935b9cdb8b6ad187408b23c51d","seq":33,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"}
{"payload":{"date":"2026-02-07","description":"Bill 1","id":6,"lines":[{"account":5,"amount":"80.00","currency":"USD","side":"debit"},{"account":6,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"b38c0078bb78b62f9d565bad95519c243d0374d480b7f17f8f2d0c8a24f7945c","seq":34,"ts":"2026-10-27T17:42:16Z","type":"entry.posted"}
{"payload":{"date":"2026-02-07","due_date":"2026-03-09","entry_id":6,"id":1,"lines":[{"amount":"80.00","description":"Paper"}],"memo":"A4 paper","received_on":"2026-02-08","terms":{"kind":{"net":30},"text":"Net 30"},"vendor_id":1,"vendor_ref":"TOBIN-77"},"prev":"d4aef1e2609466fda52daf6f871bf77c6f3851f58f73e0dc4a59c2b50fb8b472","seq":35,"ts":"2026-10-27T17:42:16Z","type":"bill.created"}
{"payload":{"date":"2026-03-01","description":"Payment on bill 1","id":7},"prev":"cf87cbbda32520851e8a0e341235f24a6c33d095c2b619ff558549c6a200f2ce","seq":36,"ts":"2026-10-27T17:42:16Z","type":"entry.created"}
{"payload":{"account":6,"amount":"80.00","currency":"USD","id":7,"side":"debit"},"prev":"a7df5eacd102668f821c155ebca4caaf3811ed684e89ce76963fad08b02aa60c","seq":37,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"}
{"payload":{"account":1,"amount":"80.00","currency":"USD","id":7,"side":"credit"},"prev":"2969a6d12f0b3c81edd637d436d97e91ef41197c5d353b0d8a78b9b14396fe50","seq":38,"ts":"2026-10-27T17:42:16Z","type":"entry.line_added"}
{"payload":{"date":"2026-03-01","description":"Payment on bill 1","id":7,"lines":[{"account":6,"amount":"80.00","currency":"USD","side":"debit"},{"account":1,"amount":"80.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"fce397fd5ac8118392a5b46682afe929293c91753ac7ac3bffb9bbb67d692ef9","seq":39,"ts":"2026-10-27T17:42:16Z","type":"entry.posted"}
{"payload":{"amount":"80.00","bill_id":1,"cash_account":1,"date":"2026-03-01","entry_id":7},"prev":"a56a40dc3b3d9b1a5ff33ec8814ff8053bdd9c17f2453ece991eb930633d0e4f","seq":40,"ts":"2026-10-27T17:42:16Z","type":"bill.paid"}
+52 -2
View File
@@ -1864,6 +1864,7 @@ fn books_written_under_older_rules_still_open() {
(4, "4000", "Revenue", "Revenue"),
(5, "2000", "Accounts Payable", "Liability"),
(6, "6500", "Supplies", "Expense"),
(7, "6990", "Bad Debt Expense", "Expense"),
] {
w.append(
"account.added",
@@ -1946,13 +1947,44 @@ fn books_written_under_older_rules_still_open() {
"memo": "A4 paper", "duplicate_reason": "the vendor sent it twice"}),
)
.unwrap();
// P4b S7's new shape, hand-written as this version writes it: a
// bad-debt write-off of invoice 1 (its entry, then the event). This
// reader tags the receivable line WriteOff and derives the figures;
// the version before it stopped at the declaration.
w.append(
"entry.created",
json!({"id": 4, "date": "2026-04-10", "description": "Write off invoice 1: uncollectible"}),
)
.unwrap();
for (account, side) in [(7, "debit"), (3, "credit")] {
w.append(
"entry.line_added",
json!({"id": 4, "account": account, "side": side, "amount": "30.00", "currency": "USD"}),
)
.unwrap();
}
w.append(
"entry.posted",
json!({"id": 4, "date": "2026-04-10",
"description": "Write off invoice 1: uncollectible", "reversal_of": null,
"lines": [
{"account": 7, "side": "debit", "amount": "30.00", "currency": "USD"},
{"account": 3, "side": "credit", "amount": "30.00", "currency": "USD"}]}),
)
.unwrap();
w.append(
"invoice.written_off",
json!({"invoice_id": 1, "entry_id": 4, "date": "2026-04-10",
"amount": "30.00", "reason": "uncollectible"}),
)
.unwrap();
drop(w);
// Opening rebuilds from the journal; it must accept all of it.
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
let store = Store::open(&dir).expect("older books still open");
store.verify().unwrap();
assert_eq!(store.journal_format().format, 1);
assert_eq!(store.ledger().chart().len(), 6);
assert_eq!(store.ledger().chart().len(), 7);
assert_eq!(store.ledger().list_periods().len(), 2);
let lines = store.ledger().get_entry(1).unwrap().lines();
assert_eq!(lines.len(), 2);
@@ -2007,6 +2039,20 @@ fn books_written_under_older_rules_still_open() {
);
assert_eq!(bill.memo.as_deref(), Some("A4 paper"));
assert_eq!(bill.lines[0].account, Some(6));
// The write-off replays as written: its line is the invoice's
// (WriteOff), so what it owes fell by 30.00 and what was written off
// rose by it (B-41, B-38).
let st = store
.ledger()
.invoice_state(1, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap())
.unwrap()
.unwrap();
assert_eq!(st.written_off, "30.00".parse::<Decimal>().unwrap());
assert_eq!(st.outstanding, "69.99".parse::<Decimal>().unwrap());
assert_eq!(
store.ledger().line_doc(4, 1).map(|t| t.movement),
Some(ledger_core::Movement::WriteOff)
);
}
/// The books an earlier wanderbooks wrote, one directory each under
@@ -2022,7 +2068,11 @@ fn books_written_under_older_rules_still_open() {
/// 10,000 years, schedules and assets running into chrono's last month,
/// depreciation posted for 10000-01, one recurring run of 1,044 drafts.
/// tests/fixtures/old-books-writer is the harness that wrote them; each
/// later phase adds the books its version writes.
/// later phase adds the books its version writes. `p4_s7_before` is P4b
/// S7's: the books main 4cad2cfc (the version before it) wrote — a
/// numbered invoice with terms and snapshots, a partial payment, a voided
/// invoice and a paid bill, no write-off — with that version's
/// expected.json.
fn old_books() -> Vec<PathBuf> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books");
let mut dirs: Vec<PathBuf> = std::fs::read_dir(&root)
+2 -1
View File
@@ -27,7 +27,8 @@ const TYPES: &[&str] = &[
"asset.registered", "attachment.withdrawn", "bill.created", "bill.paid", "class.added",
"customer.added", "document.filed", "entry.created", "entry.line_added",
"entry.line_class_set", "entry.posted", "entry.reversed", "entry.vendor_set",
"fx.rate_set", "invoice.created", "invoice.payment_received", "journal.tail_repaired",
"fx.rate_set", "invoice.created", "invoice.payment_received", "invoice.written_off",
"journal.tail_repaired",
"line.cleared", "payroll.run", "period.closed", "period.created", "receipt.attached",
"receipt.linked", "receipt.unlinked", "recurring.generated", "recurring.registered",
"schedule.posted", "schedule.registered", "vendor.1099_set", "vendor.added",