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
@@ -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) \
|
||||
|
||||
Reference in new issue
Block a user