diff --git a/crates/ledger-cli/tests/format_upgrade.rs b/crates/ledger-cli/tests/format_upgrade.rs index 375e3c7..f87d7af 100644 --- a/crates/ledger-cli/tests/format_upgrade.rs +++ b/crates/ledger-cli/tests/format_upgrade.rs @@ -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(); diff --git a/crates/ledger-core/src/lib.rs b/crates/ledger-core/src/lib.rs index a52639e..50039f9 100644 --- a/crates/ledger-core/src/lib.rs +++ b/crates/ledger-core/src/lib.rs @@ -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}; diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 5affe43..26c27dd 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -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), diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 500845d..d0ba57f 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -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", diff --git a/crates/ledger-store/src/lib.rs b/crates/ledger-store/src/lib.rs index 593156c..625890c 100644 --- a/crates/ledger-store/src/lib.rs +++ b/crates/ledger-store/src/lib.rs @@ -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, }; diff --git a/crates/ledger-store/src/meta.rs b/crates/ledger-store/src/meta.rs index 8205453..1fd184b 100644 --- a/crates/ledger-store/src/meta.rs +++ b/crates/ledger-store/src/meta.rs @@ -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 { + 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::*; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index 055ff0b..87154b6 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -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>, + ) -> 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) \ diff --git a/crates/ledger-store/tests/arap_props.rs b/crates/ledger-store/tests/arap_props.rs index a014f20..eda9f1b 100644 --- a/crates/ledger-store/tests/arap_props.rs +++ b/crates/ledger-store/tests/arap_props.rs @@ -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 { .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::(), 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) -> 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); diff --git a/crates/ledger-store/tests/arap_standing.rs b/crates/ledger-store/tests/arap_standing.rs index eec3711..8bff100 100644 --- a/crates/ledger-store/tests/arap_standing.rs +++ b/crates/ledger-store/tests/arap_standing.rs @@ -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(); +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/expected.json b/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/expected.json new file mode 100644 index 0000000..ca30141 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/expected.json @@ -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 + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/journal.jsonl new file mode 100644 index 0000000..3647e1b --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_s7_before/journal.jsonl @@ -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"} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 412fecc..c0d8a5f 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -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::().unwrap()); + assert_eq!(st.outstanding, "69.99".parse::().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 { let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books"); let mut dirs: Vec = std::fs::read_dir(&root) diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index 3175d3a..c2bc603 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -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",