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:
1 parent
62f2fb058e
commit
4e71cf6320
13 files changed
+1005
-7
No files matched your search
@@ -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),
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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) \
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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"}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user