From 7299cf90f2ba88efe81c63737a767b2c06c092cc Mon Sep 17 00:00:00 2001 From: Stephen Waits Date: Thu, 29 Oct 2026 13:52:02 -0600 Subject: [PATCH] feat(evidence): one function decides whether an entry needs evidence (P4) Accounting reason: the receipt rule (Pub. 583, Rev. Proc. 98-25: the books must be demonstrably supported by the records) was decided in three places that could disagree: the server's post, the digest, and a string match on a journal-event origin in the viewer. ledger_core:: evidence::requirement is now the one decision: Required (with why) or Exempt (with the source). A reversal needs no receipt of its own (its evidence is its original's, D-47, D-12); neither do a closing entry, a schedule month's or a depreciation run's posting, a disposal, a document's entry, a payroll run or its remittance. The ledger learned the two origins it could not see (a disposal, a remittance) from the events that already name their entry, live and on replay. A split group counts for a draft only: a posted entry's decision was made when it posted. The policy moved with it (evidence::Policy; ReceiptPolicy in ledger-config keeps the words and delegates). Store::post_entry_checked refuses a covered draft with no receipt (StoreError::ReceiptRequired, which the MCP maps to the unchanged receipt_required error); Store::post_entry stays the primitive that commands and fixtures post through, since ~176 call sites post hand entries with no receipt. The viewer's missing_receipt calls requirement; its string match is gone. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp --- crates/ledger-config/src/receipt_policy.rs | 152 +++-------- crates/ledger-core/src/evidence.rs | 286 ++++++++++++++++++++- crates/ledger-core/src/ledger.rs | 57 ++++ crates/ledger-gui/src/books.rs | 15 +- crates/ledger-store/src/error.rs | 18 ++ crates/ledger-store/src/store.rs | 55 +++- 6 files changed, 451 insertions(+), 132 deletions(-) diff --git a/crates/ledger-config/src/receipt_policy.rs b/crates/ledger-config/src/receipt_policy.rs index e6beef9..6df10cc 100644 --- a/crates/ledger-config/src/receipt_policy.rs +++ b/crates/ledger-config/src/receipt_policy.rs @@ -5,6 +5,10 @@ //! an exempt account. Selected accounts can require receipts regardless of //! amount. Reversals are exempt: the original entry carries the evidence. //! +//! The decision itself lives in the engine +//! (`ledger_core::evidence::requirement`, plan P4, I-5): this is the +//! policy as the settings carry it, and the words that describe it. +//! //! ```text //! GOAL: no entry posts without the document that supports it. //! SOURCE: docs/spec/sources/irs-records.md (Pub. 583: "you must keep @@ -23,7 +27,7 @@ //! startup (validate). //! ``` -use ledger_core::{JournalEntry, Ledger, Side}; +use ledger_core::{JournalEntry, Ledger}; use rust_decimal::Decimal; use serde::Deserialize; @@ -78,44 +82,31 @@ impl ReceiptPolicy { crate::decimal(self.require_receipts_above.trim()).unwrap_or(Decimal::ZERO) } - fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec { - entry - .lines() - .iter() - .map(|l| { - ledger - .chart() - .get(l.account) - .map(|a| a.code.clone()) - .unwrap_or_default() - }) - .collect() + /// The policy as the engine evaluates it + /// ([`ledger_core::evidence::Policy`], plan P4): one decision, in the + /// core. A threshold that fails to parse requires a receipt on + /// everything, as an unvalidated policy always did. + pub fn engine(&self) -> ledger_core::evidence::Policy { + ledger_core::evidence::Policy { + threshold: self.threshold(), + required_accounts: self.receipt_required_accounts.clone(), + exempt_accounts: self.receipt_exempt_accounts.clone(), + } } /// Total debits of the entry (one side of the balanced entry). A /// draft's lines are as journaled, of any size (rules.md B-28): a total /// past exact arithmetic is refused (TooLargeToCompute), never a panic. pub fn entry_debits(entry: &JournalEntry) -> Result { - entry - .lines() - .iter() - .filter(|l| l.side == Side::Debit) - .try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount.amount)) - .ok_or_else(|| { - ledger_core::LedgerError::TooLargeToCompute(format!( - "entry {}'s debits", - entry.id() - )) - }) + ledger_core::evidence::Policy::entry_debits(entry) } - /// True when posting this entry requires at least one receipt. Debits - /// past exact arithmetic are past any threshold. + /// True when posting this entry requires at least one receipt by the + /// policy alone (the engine's [`ledger_core::evidence::requirement`] + /// also knows what posted it). Debits past exact arithmetic are past + /// any threshold. pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool { - let codes = self.account_codes(ledger, entry); - let refs: Vec<&str> = codes.iter().map(|s| s.as_str()).collect(); - let debits = Self::entry_debits(entry).unwrap_or(Decimal::MAX); - self.receipt_required_for(&refs, debits) + self.engine().receipt_required(ledger, entry) } /// True when an entry touching these account codes with this debit total @@ -123,33 +114,12 @@ impl ReceiptPolicy { /// [`Self::receipt_required`], for postings (like disposals) computed /// without a draft entry in hand. pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool { - // Per-account override: any line in a required account → required. - if codes - .iter() - .any(|c| self.receipt_required_accounts.iter().any(|r| r == *c)) - { - return true; - } - // Strictly greater than the threshold (owner: ">$25"). - if debits <= self.threshold() { - return false; - } - // Exempt only when every line is in an exempt account. - !codes - .iter() - .all(|c| self.receipt_exempt_accounts.iter().any(|e| e == *c)) + self.engine().receipt_required_for(codes, debits) } /// Human-readable policy summary for previews and errors. pub fn describe(&self) -> String { - if self.threshold().is_zero() { - "receipts required on every entry".to_string() - } else { - format!( - "receipts required when entry debits are strictly greater than {}", - self.threshold() - ) - } + self.engine().describe() } /// The receipt rule in force, phrased for the agent's operating @@ -206,76 +176,14 @@ impl ReceiptPolicy { rule } - /// Normalize a description for split detection. - fn normalize_description(desc: &str) -> String { - desc.split_whitespace() - .collect::>() - .join(" ") - .to_lowercase() - } - - /// Debit account ids of an entry, sorted. - fn debit_accounts(entry: &JournalEntry) -> Vec { - let mut ids: Vec = entry - .lines() - .iter() - .filter(|l| l.side == Side::Debit) - .map(|l| l.account) - .collect(); - ids.sort_unstable(); - ids.dedup(); - ids - } - - /// Detect a potential threshold split: posted entries on the same date - /// with the same normalized description and debit accounts, whose - /// combined debits with this entry exceed the threshold while this - /// entry alone does not. Returns the matching posted entry ids. - /// - /// This is the anti-gaming rule: splitting a $100 purchase into four - /// $25 entries does not dodge the receipt policy. Same-day, - /// same-description, same-accounts is the signature; recurring spending - /// spread across days is not flagged. + /// Detect a potential threshold split (see + /// [`ledger_core::evidence::Policy::split_group`]): posted entries on + /// the same date with the same normalized description and debit + /// accounts whose combined debits with this entry exceed the threshold + /// while this entry alone does not. Returns the matching posted entry + /// ids. pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec { - // Debits past exact arithmetic are past the threshold too. - let debits = Self::entry_debits(entry).unwrap_or(Decimal::MAX); - if debits > self.threshold() { - return Vec::new(); // Normal policy already applies. - } - let desc = Self::normalize_description(entry.description()); - let accounts = Self::debit_accounts(entry); - let date = entry.date(); - let mut group = Vec::new(); - let mut total = debits; - for other in ledger.posted_entries() { - if other.id() == entry_id { - continue; - } - if other.date() != date { - continue; - } - if Self::normalize_description(other.description()) != desc { - continue; - } - if Self::debit_accounts(other) != accounts { - continue; - } - // Reversals are exempt from the policy; don't count them. - if other.reversal_of().is_some() { - continue; - } - group.push(other.id()); - // A sum past exact arithmetic is past the threshold. - total = Self::entry_debits(other) - .ok() - .and_then(|d| total.checked_add(d)) - .unwrap_or(Decimal::MAX); - } - if !group.is_empty() && total > self.threshold() { - group - } else { - Vec::new() - } + self.engine().split_group(ledger, entry_id, entry) } } diff --git a/crates/ledger-core/src/evidence.rs b/crates/ledger-core/src/evidence.rs index 465add3..f03989e 100644 --- a/crates/ledger-core/src/evidence.rs +++ b/crates/ledger-core/src/evidence.rs @@ -22,7 +22,8 @@ use chrono::NaiveDate; use rust_decimal::Decimal; -use crate::LedgerError; +use crate::settings::Settings; +use crate::{EntryId, EntryStatus, JournalEntry, Ledger, LedgerError, Side}; /// The closed set of facts a human may record about a file (plan P6 step /// 5). Every field is optional; the amount is an exact decimal in the @@ -118,3 +119,286 @@ impl Annotation { Ok(self) } } + +// --------------------------------------------------------------------------- +// The evidence gate (plan P4, I-5, D-47; rules.md B-43) +// --------------------------------------------------------------------------- + +/// What posted an entry that the ledger's own state doesn't otherwise say: +/// recorded as the journal records it today (`asset.disposed` and +/// `payroll.taxes_remitted` name their entry), so a reader of the ledger +/// alone can tell it from a hand-made entry. +#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub enum Origin { + /// An asset's disposal entry. + AssetDisposal, + /// A payroll tax remittance entry. + PayrollRemittance, +} + +/// Whether an entry needs evidence before it posts, and why (or why not). +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum Requirement { + /// The receipt policy covers it: at least one receipt is needed. + Required { + /// Why, in words for a refusal or a checklist. + reason: String, + }, + /// It needs none of its own. + Exempt { + /// What exempts it: the origin or the policy line, with its rule. + source: &'static str, + }, +} + +impl Requirement { + /// Whether evidence is required. + pub fn is_required(&self) -> bool { + matches!(self, Requirement::Required { .. }) + } +} + +/// The receipt policy as the books' settings set it +/// (`policy.evidence.*`): required strictly above the threshold, on a +/// required account regardless of amount, never when every line is on an +/// exempt account. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct Policy { + /// Receipts are required when an entry's debits are strictly greater + /// than this. + pub threshold: Decimal, + /// Account codes whose entries always need a receipt. + pub required_accounts: Vec, + /// Account codes exempt from the policy; the exemption applies only + /// when every line is on one. + pub exempt_accounts: Vec, +} + +impl Policy { + /// The policy the books' own settings set (the threshold defaults to + /// 25.00; one that is not a decimal at least zero requires a receipt + /// on everything, as an unvalidated policy always did). + pub fn from_settings(s: &Settings) -> Policy { + Policy { + threshold: crate::settings::decimal( + s.receipt_threshold() + .unwrap_or(crate::settings::DEFAULT_THRESHOLD) + .trim(), + ) + .unwrap_or(Decimal::ZERO), + required_accounts: s.receipt_required_accounts(), + exempt_accounts: s.receipt_exempt_accounts(), + } + } + + /// Total debits of the entry (one side of the balanced entry). A + /// draft's lines are as journaled, of any size (rules.md B-28): a + /// total past exact arithmetic is refused (TooLargeToCompute), never + /// a panic. + pub fn entry_debits(entry: &JournalEntry) -> Result { + entry + .lines() + .iter() + .filter(|l| l.side == Side::Debit) + .try_fold(Decimal::ZERO, |sum, l| sum.checked_add(l.amount.amount)) + .ok_or_else(|| LedgerError::TooLargeToCompute(format!("entry {}'s debits", entry.id()))) + } + + fn account_codes(ledger: &Ledger, entry: &JournalEntry) -> Vec { + entry + .lines() + .iter() + .map(|l| { + ledger + .chart() + .get(l.account) + .map(|a| a.code.clone()) + .unwrap_or_default() + }) + .collect() + } + + /// True when an entry touching these account codes with this debit + /// total requires at least one receipt (the code-level check behind + /// [`requirement`], for postings computed without a draft in hand, + /// like a disposal's). + pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool { + self.reason_for(codes, debits).is_some() + } + + /// [`Self::receipt_required_for`], with why. + fn reason_for(&self, codes: &[&str], debits: Decimal) -> Option { + // Per-account override: any line in a required account. + if let Some(c) = codes + .iter() + .find(|c| self.required_accounts.iter().any(|r| r == **c)) + { + return Some(format!( + "it touches account {c}, which always needs a receipt" + )); + } + // Strictly greater than the threshold (owner: ">$25"). + if debits <= self.threshold { + return None; + } + // Exempt only when every line is in an exempt account. + if codes + .iter() + .all(|c| self.exempt_accounts.iter().any(|e| e == *c)) + { + return None; + } + Some(format!( + "its debits {debits} are strictly greater than the {} threshold", + self.threshold + )) + } + + /// True when the policy, entry by entry, requires a receipt for + /// `entry`: debits past exact arithmetic are past any threshold. + pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool { + self.reason(ledger, entry).is_some() + } + + fn reason(&self, ledger: &Ledger, entry: &JournalEntry) -> Option { + let codes = Self::account_codes(ledger, entry); + let refs: Vec<&str> = codes.iter().map(String::as_str).collect(); + let debits = Self::entry_debits(entry).unwrap_or(Decimal::MAX); + self.reason_for(&refs, debits) + } + + /// Human-readable policy summary for previews and errors. + pub fn describe(&self) -> String { + if self.threshold.is_zero() { + "receipts required on every entry".to_string() + } else { + format!( + "receipts required when entry debits are strictly greater than {}", + self.threshold + ) + } + } + + fn normalize_description(desc: &str) -> String { + desc.split_whitespace() + .collect::>() + .join(" ") + .to_lowercase() + } + + fn debit_accounts(entry: &JournalEntry) -> Vec { + let mut ids: Vec = entry + .lines() + .iter() + .filter(|l| l.side == Side::Debit) + .map(|l| l.account) + .collect(); + ids.sort_unstable(); + ids.dedup(); + ids + } + + /// Detect a potential threshold split: posted entries on the same + /// date with the same normalized description and debit accounts, + /// whose combined debits with this entry exceed the threshold while + /// this entry alone does not. Returns the matching posted entry ids. + /// + /// This is the anti-gaming rule: splitting a $100 purchase into four + /// $25 entries does not dodge the receipt policy. Same-day, + /// same-description, same-accounts is the signature; recurring + /// spending spread across days is not flagged. + pub fn split_group( + &self, + ledger: &Ledger, + entry_id: EntryId, + entry: &JournalEntry, + ) -> Vec { + // Debits past exact arithmetic are past the threshold too. + let debits = Self::entry_debits(entry).unwrap_or(Decimal::MAX); + if debits > self.threshold { + return Vec::new(); // Normal policy already applies. + } + let desc = Self::normalize_description(entry.description()); + let accounts = Self::debit_accounts(entry); + let date = entry.date(); + let mut group = Vec::new(); + let mut total = debits; + for other in ledger.posted_entries() { + if other.id() == entry_id + || other.date() != date + || Self::normalize_description(other.description()) != desc + || Self::debit_accounts(other) != accounts + { + continue; + } + // Reversals are exempt from the policy; don't count them. + if other.reversal_of().is_some() { + continue; + } + group.push(other.id()); + // A sum past exact arithmetic is past the threshold. + total = Self::entry_debits(other) + .ok() + .and_then(|d| total.checked_add(d)) + .unwrap_or(Decimal::MAX); + } + if !group.is_empty() && total > self.threshold { + group + } else { + Vec::new() + } + } +} + +/// Whether `entry` needs evidence before it posts: the one function that +/// decides (plan P4 step 2, I-5, D-47; rules.md B-43). The store's gate, +/// the server's previews and digest, the viewer and Needs attention all +/// call it. +/// +/// ```text +/// GOAL: one answer, from what the ledger knows, to "does this entry need +/// a receipt". +/// SOURCE: docs/spec/rules.md B-43; IRS Pub. 583 (supporting documents); +/// DECISION D-47 (a reversal's evidence is its original's, D-12); +/// DECISION (owner: the threshold, strictly greater than, 2026-10-12). +/// INVARIANTS: +/// 1. Exempt by origin, each with its source: a closing entry, a +/// schedule month's posting, a depreciation run, an asset's +/// disposal, an entry a document command posted (any line tagged +/// with a document), a payroll run or its tax remittance, and a +/// reversal. Every other entry, hand entries and recurring drafts +/// included, is the policy's. +/// 2. The policy's: Required when it touches a required account, or its +/// debits are strictly greater than the threshold and some line is +/// off the exempt accounts; a DRAFT is also Required when it joins a +/// split group (same day, description and debit accounts as posted +/// entries whose total passes the threshold). A posted entry's +/// decision was made when it posted and is not made again from its +/// neighbours. +/// 3. Pure: nothing changes. +/// SCENARIOS: +/// 1. A reversal of a 5,000.00 hand entry: Exempt (D-47). +/// 2. A 40.00 hand entry at the default 25.00 threshold: Required. +/// 3. The same under threshold 0.00 on an exempt account: Exempt. +/// ``` +pub fn requirement(ledger: &Ledger, entry: &JournalEntry, settings: &Settings) -> Requirement { + let policy = Policy::from_settings(settings); + let mut reason = policy.reason(ledger, entry); + if reason.is_none() && entry.status() == EntryStatus::Draft { + let group = policy.split_group(ledger, entry.id(), entry); + if !group.is_empty() { + reason = Some(format!( + "it looks like part of a split group with entries {group:?}; the policy applies to the group total" + )); + } + } + let Some(reason) = reason else { + return Requirement::Exempt { + source: "the receipt policy (policy.evidence.*): under the threshold, or on exempt accounts", + }; + }; + match ledger.origin_exemption(entry) { + Some(source) => Requirement::Exempt { source }, + None => Requirement::Required { reason }, + } +} diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index cea79d3..9053143 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -355,6 +355,10 @@ pub struct Ledger { /// What each entry settles (`entry.settles`, plan P6): the one record /// a member-paid entry's "reimbursed" is derived from (IZ-18). entry_settles: std::collections::BTreeMap>, + /// What posted the entries the ledger's other state doesn't say + /// ([`crate::evidence::Origin`]): an asset's disposal, a payroll tax + /// remittance. Read by [`crate::evidence::requirement`]. + entry_origins: std::collections::BTreeMap, /// The journal line that posted each posted entry (`entry.posted` or /// `entry.reversed`, plan P4): what "posted since seq N" is read from, /// backdated entries included. An entry the journal's own posting @@ -424,6 +428,7 @@ impl Ledger { conventions: crate::conventions::Conventions::default(), annotations: std::collections::BTreeMap::new(), entry_settles: std::collections::BTreeMap::new(), + entry_origins: std::collections::BTreeMap::new(), posted_seq: std::collections::BTreeMap::new(), expected_seq: std::collections::BTreeMap::new(), unstamped: Vec::new(), @@ -843,6 +848,7 @@ impl Ledger { ("annotations", self.annotations == other.annotations), ("entry_settles", self.entry_settles == other.entry_settles), ("posted_seq", self.posted_seq == other.posted_seq), + ("entry_origins", self.entry_origins == other.entry_origins), ] .into_iter() .filter(|(_, same)| !same) @@ -2102,6 +2108,57 @@ impl Ledger { self.posted_seq.get(&entry).copied() } + /// Record what posted `entry` where the ledger's other state doesn't + /// say: an asset's disposal entry, a payroll tax remittance (the store, + /// live, after the command; on replay, from the event that names the + /// entry). Read by [`crate::evidence::requirement`]. + #[doc(hidden)] + pub fn note_entry_origin(&mut self, entry: EntryId, origin: crate::evidence::Origin) { + self.entry_origins.insert(entry, origin); + } + + /// Why `entry` needs no evidence of its own, by what posted it, if + /// that is so ([`crate::evidence::requirement`] INVARIANT 1); `None` + /// for an entry the receipt policy covers (a hand entry, a recurring + /// draft, a payout). + pub(crate) fn origin_exemption(&self, entry: &JournalEntry) -> Option<&'static str> { + let id = entry.id(); + if entry.reversal_of().is_some() { + return Some("a reversal: its evidence is its original's (B-43, D-47, D-12)"); + } + if self.is_closing_entry(id) { + return Some("a period's closing entry: derived from the books (B-43)"); + } + if self.schedule_month_of(id).is_some() { + return Some("a schedule month's posting: the schedule is the evidence (B-43)"); + } + if self + .asset_runs + .values() + .any(|runs| runs.iter().any(|r| r.entry == id)) + { + return Some("a depreciation run: the asset's schedule is the evidence (B-43)"); + } + match self.entry_origins.get(&id) { + Some(crate::evidence::Origin::AssetDisposal) => { + return Some("an asset's disposal: the register is the evidence (B-43)"); + } + Some(crate::evidence::Origin::PayrollRemittance) => { + return Some("a payroll tax remittance: the payroll runs are the evidence (B-43)"); + } + None => {} + } + if (0..entry.lines().len()).any(|i| self.line_docs.contains_key(&(id, i))) { + return Some( + "a document's entry (invoice, bill, payment, write-off): the document is the evidence (B-43)", + ); + } + if self.payroll_runs.values().any(|r| r.entry_id == id) { + return Some("a payroll run: the run is the evidence (B-43)"); + } + None + } + /// Say that journal line `seq` will be `entry`'s posting line, ahead /// of the command that posts it (a store, live: it journals /// `entry.posted` or `entry.reversed` first). Consumed when the entry diff --git a/crates/ledger-gui/src/books.rs b/crates/ledger-gui/src/books.rs index 9f8a950..ec01a9b 100644 --- a/crates/ledger-gui/src/books.rs +++ b/crates/ledger-gui/src/books.rs @@ -705,14 +705,13 @@ impl Books { .entry_vendor(id) .and_then(|v| ledger.get_vendor(v)) .map(|v| v.name.clone()); - // The receipt policy covers what is posted by hand (journal - // entries and recurring drafts); invoices, payments, closes, - // depreciation, and schedules carry their own evidence, and a - // reversal leans on its original's. - let missing_receipt = matches!(origin, "Journal entry" | "Recurring") - && e.reversal_of().is_none() - && rec.is_empty() - && policy.receipt_required(&ledger, e); + // The engine decides (plan P4, I-5): the receipt policy covers + // what is posted by hand (journal entries, recurring drafts); + // invoices, payments, closes, depreciation, and schedules carry + // their own evidence, and a reversal leans on its original's. + // The viewer asks the same function the server's gate asks. + let missing_receipt = rec.is_empty() + && ledger_core::evidence::requirement(&ledger, e, ledger.settings()).is_required(); let cash_effect = e .lines() .iter() diff --git a/crates/ledger-store/src/error.rs b/crates/ledger-store/src/error.rs index 26c27dd..a1d19d9 100644 --- a/crates/ledger-store/src/error.rs +++ b/crates/ledger-store/src/error.rs @@ -35,6 +35,24 @@ pub enum StoreError { #[error("asset {0} already has depreciation posted; correct it with entries instead")] AssetDepreciationPosted(u64), + /// The receipt policy covers the entry and it has no receipt (plan P4, + /// I-5; rules.md B-43): refused before anything is journaled. + #[error( + "entry {entry_id} needs a receipt before it posts: {reason} ({policy}); attach one with books.attach_receipt" + )] + ReceiptRequired { + /// The entry that needs the receipt. + entry_id: u64, + /// Its total debits, `None` when they pass exact arithmetic. + debits: Option, + /// The policy that triggered, in words. + policy: String, + /// Why this entry is covered. + reason: String, + /// The posted entries it appears to split (anti-splitting). + split_group: Vec, + }, + /// Filesystem error. #[error("io error: {0}")] Io(#[from] std::io::Error), diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index c65ec47..8990bad 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1519,6 +1519,47 @@ impl Store { self.post_entry_unchecked(entry) } + /// [`Self::post_entry`] behind the evidence gate (plan P4, I-5, D-47; + /// rules.md B-43): refuses, journaling nothing, a draft that + /// [`ledger_core::evidence::requirement`] says needs evidence while it + /// has no receipt filed on it or linked to it. This is the path the + /// server posts a hand-made draft by; [`Self::post_entry`] stays the + /// primitive that commands and fixtures post through, which carry + /// their evidence another way. + /// + /// ```text + /// GOAL: no draft the receipt policy covers posts without the + /// document that supports it, decided in one place. + /// SOURCE: docs/spec/rules.md B-43; IRS Pub. 583. + /// SCENARIOS: + /// 1. IF the entry is covered and has no receipt: SHOULD refuse + /// (ReceiptRequired), NEVER journal. + /// 2. IF it is exempt (a reversal, a document's entry, a close) or + /// has a receipt: SHOULD post as post_entry does. + /// ``` + pub fn post_entry_checked(&mut self, entry: EntryId) -> Result<(), StoreError> { + let e = self + .ledger + .get_entry(entry) + .ok_or(LedgerError::UnknownEntry(entry))? + .clone(); + if e.status() == EntryStatus::Draft + && let ledger_core::evidence::Requirement::Required { reason } = + ledger_core::evidence::requirement(&self.ledger, &e, self.ledger.settings()) + && self.list_attachments(Some(entry))?.is_empty() + { + let policy = ledger_core::evidence::Policy::from_settings(self.ledger.settings()); + return Err(StoreError::ReceiptRequired { + entry_id: entry, + debits: ledger_core::evidence::Policy::entry_debits(&e).ok(), + policy: policy.describe(), + split_group: policy.split_group(&self.ledger, entry, &e), + reason, + }); + } + self.post_entry(entry) + } + /// Approval rules (ledger_core::conventions INVARIANT 3): refuse an /// operation that would post at once an entry an enforced rule covers /// (such an entry has no draft to approve: it must be drafted and @@ -2833,6 +2874,8 @@ impl Store { }), )?; self.ledger.dispose_asset(asset_id, date, proceeds)?; + self.ledger + .note_entry_origin(entry_id, ledger_core::evidence::Origin::AssetDisposal); apply_line_to_sqlite(&self.conn, &line)?; Ok(entry_id) } @@ -4398,6 +4441,8 @@ impl Store { )?); let live = self.ledger.remit_payroll_taxes(date, cash_account)?; debug_assert_eq!(live, entry_id, "id allocator diverged from journal"); + self.ledger + .note_entry_origin(live, ledger_core::evidence::Origin::PayrollRemittance); for line in &journaled { apply_line_to_sqlite(&self.conn, line)?; } @@ -7492,7 +7537,11 @@ fn replay_line( } "payroll.taxes_remitted" => { // Names the remittance entry (rebuilt by the entry events just - // before it) for readers; no ledger state of its own. + // before it): the ledger keeps what posted it (plan P4), so + // the evidence gate can tell it from a hand entry. + if let Some(eid) = p.get("entry_id").and_then(Value::as_u64) { + ledger.note_entry_origin(eid, ledger_core::evidence::Origin::PayrollRemittance); + } } "asset.registered" => { let want = get_u64(p, "id")?; @@ -7663,6 +7712,10 @@ fn replay_line( parse_date(get_str(p, "date")?)?, parse_money(p, "proceeds")?, )?; + // The disposal's own entry, as the journal names it (plan P4). + if let Some(eid) = p.get("entry_id").and_then(Value::as_u64) { + ledger.note_entry_origin(eid, ledger_core::evidence::Origin::AssetDisposal); + } } "period.created" => { let want = get_u64(p, "id")?;