merge: P4b S7, bad debts (branch S7, 4b4fecb9)
The fourth of the paused branches: Ledger::write_off and the bad_debts control role, invoice.written_off with its evidence filed in the same store call, books.write_off_invoice, the write-off on the timeline and in Needs attention, Lean writeOff_within_bounds, rules.md B-41 and the bookspec examples. Twenty-eight files conflicted with the S4.4, S5 and S6 merges; resolved as plan 8.0.2 says, keeping both sides' appends with their seams restored, and: the scope table at 125; S7's law numbered 37; B-41 placed before B-42; the handbook's step 4 and recovery procedures folded into the one invoices-and-bills section; S7's wire tests in their own module with the confirm S4.4's contract needs; S7's copy of the payment steps dropped (S6 replaced them with Ledger::settle) and its write-off journaled through S6's journal_auto_lines; in the old-books fixture S7's hand-written write-off moved to a second invoice, since S6's hand-written payment settles invoice 1 in full. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
335a3eaa7e
47 files changed
+2883
-74
No files matched your search
@@ -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),
|
||||
|
||||
@@ -244,6 +244,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"invoice.created",
|
||||
"invoice.payment_received",
|
||||
"invoice.sent",
|
||||
"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, PreviewPosting,
|
||||
Reconciliation, Statement, Store, Subject, VerifyReport, database_in_use, db_file, db_file_for,
|
||||
is_database_file,
|
||||
Reconciliation, Statement, Store, Subject, VerifyReport, WriteOffEvidence, database_in_use,
|
||||
db_file, db_file_for, is_database_file,
|
||||
};
|
||||
@@ -418,6 +418,54 @@ pub fn invoice_sent_of(line: &JournalLine) -> Result<(u64, Sent), StoreError> {
|
||||
))
|
||||
}
|
||||
|
||||
/// 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::*;
|
||||
|
||||
@@ -440,6 +440,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
|
||||
@@ -3780,6 +3805,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_lines(
|
||||
date,
|
||||
&description,
|
||||
&[
|
||||
(bad, Side::Debit, amount, None),
|
||||
(ar, Side::Credit, amount, None),
|
||||
],
|
||||
)?;
|
||||
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 settlement needs (rules.md B-27, B-42):
|
||||
/// `documents_v2` for more than one application, a discount or a fee
|
||||
/// (an older version would drop them, and the balance would be wrong),
|
||||
@@ -7203,6 +7330,13 @@ fn replay_line(
|
||||
get_opt_usize(p, "discount_line")?,
|
||||
)?;
|
||||
}
|
||||
"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")?)
|
||||
@@ -8188,6 +8322,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) \
|
||||
|
||||
Reference in new issue
Block a user