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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
1 parent
28d46332e4
commit
7299cf90f2
6 files changed
+451
-132
No files matched your search
@@ -5,6 +5,10 @@
|
|||||||
//! an exempt account. Selected accounts can require receipts regardless of
|
//! an exempt account. Selected accounts can require receipts regardless of
|
||||||
//! amount. Reversals are exempt: the original entry carries the evidence.
|
//! 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
|
//! ```text
|
||||||
//! GOAL: no entry posts without the document that supports it.
|
//! GOAL: no entry posts without the document that supports it.
|
||||||
//! SOURCE: docs/spec/sources/irs-records.md (Pub. 583: "you must keep
|
//! SOURCE: docs/spec/sources/irs-records.md (Pub. 583: "you must keep
|
||||||
@@ -23,7 +27,7 @@
|
|||||||
//! startup (validate).
|
//! startup (validate).
|
||||||
//! ```
|
//! ```
|
||||||
|
|
||||||
use ledger_core::{JournalEntry, Ledger, Side};
|
use ledger_core::{JournalEntry, Ledger};
|
||||||
use rust_decimal::Decimal;
|
use rust_decimal::Decimal;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
|
|
||||||
@@ -78,44 +82,31 @@ impl ReceiptPolicy {
|
|||||||
crate::decimal(self.require_receipts_above.trim()).unwrap_or(Decimal::ZERO)
|
crate::decimal(self.require_receipts_above.trim()).unwrap_or(Decimal::ZERO)
|
||||||
}
|
}
|
||||||
|
|
||||||
fn account_codes(&self, ledger: &Ledger, entry: &JournalEntry) -> Vec<String> {
|
/// The policy as the engine evaluates it
|
||||||
entry
|
/// ([`ledger_core::evidence::Policy`], plan P4): one decision, in the
|
||||||
.lines()
|
/// core. A threshold that fails to parse requires a receipt on
|
||||||
.iter()
|
/// everything, as an unvalidated policy always did.
|
||||||
.map(|l| {
|
pub fn engine(&self) -> ledger_core::evidence::Policy {
|
||||||
ledger
|
ledger_core::evidence::Policy {
|
||||||
.chart()
|
threshold: self.threshold(),
|
||||||
.get(l.account)
|
required_accounts: self.receipt_required_accounts.clone(),
|
||||||
.map(|a| a.code.clone())
|
exempt_accounts: self.receipt_exempt_accounts.clone(),
|
||||||
.unwrap_or_default()
|
}
|
||||||
})
|
|
||||||
.collect()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Total debits of the entry (one side of the balanced entry). A
|
/// 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
|
/// draft's lines are as journaled, of any size (rules.md B-28): a total
|
||||||
/// past exact arithmetic is refused (TooLargeToCompute), never a panic.
|
/// past exact arithmetic is refused (TooLargeToCompute), never a panic.
|
||||||
pub fn entry_debits(entry: &JournalEntry) -> Result<Decimal, ledger_core::LedgerError> {
|
pub fn entry_debits(entry: &JournalEntry) -> Result<Decimal, ledger_core::LedgerError> {
|
||||||
entry
|
ledger_core::evidence::Policy::entry_debits(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()
|
|
||||||
))
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when posting this entry requires at least one receipt. Debits
|
/// True when posting this entry requires at least one receipt by the
|
||||||
/// past exact arithmetic are past any threshold.
|
/// 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 {
|
pub fn receipt_required(&self, ledger: &Ledger, entry: &JournalEntry) -> bool {
|
||||||
let codes = self.account_codes(ledger, entry);
|
self.engine().receipt_required(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)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// True when an entry touching these account codes with this debit total
|
/// 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
|
/// [`Self::receipt_required`], for postings (like disposals) computed
|
||||||
/// without a draft entry in hand.
|
/// without a draft entry in hand.
|
||||||
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
|
pub fn receipt_required_for(&self, codes: &[&str], debits: Decimal) -> bool {
|
||||||
// Per-account override: any line in a required account → required.
|
self.engine().receipt_required_for(codes, debits)
|
||||||
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))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Human-readable policy summary for previews and errors.
|
/// Human-readable policy summary for previews and errors.
|
||||||
pub fn describe(&self) -> String {
|
pub fn describe(&self) -> String {
|
||||||
if self.threshold().is_zero() {
|
self.engine().describe()
|
||||||
"receipts required on every entry".to_string()
|
|
||||||
} else {
|
|
||||||
format!(
|
|
||||||
"receipts required when entry debits are strictly greater than {}",
|
|
||||||
self.threshold()
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The receipt rule in force, phrased for the agent's operating
|
/// The receipt rule in force, phrased for the agent's operating
|
||||||
@@ -206,76 +176,14 @@ impl ReceiptPolicy {
|
|||||||
rule
|
rule
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Normalize a description for split detection.
|
/// Detect a potential threshold split (see
|
||||||
fn normalize_description(desc: &str) -> String {
|
/// [`ledger_core::evidence::Policy::split_group`]): posted entries on
|
||||||
desc.split_whitespace()
|
/// the same date with the same normalized description and debit
|
||||||
.collect::<Vec<_>>()
|
/// accounts whose combined debits with this entry exceed the threshold
|
||||||
.join(" ")
|
/// while this entry alone does not. Returns the matching posted entry
|
||||||
.to_lowercase()
|
/// ids.
|
||||||
}
|
|
||||||
|
|
||||||
/// Debit account ids of an entry, sorted.
|
|
||||||
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
|
|
||||||
let mut ids: Vec<u64> = 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: u64, entry: &JournalEntry) -> Vec<u64> {
|
pub fn split_group(&self, ledger: &Ledger, entry_id: u64, entry: &JournalEntry) -> Vec<u64> {
|
||||||
// Debits past exact arithmetic are past the threshold too.
|
self.engine().split_group(ledger, entry_id, entry)
|
||||||
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()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -22,7 +22,8 @@
|
|||||||
use chrono::NaiveDate;
|
use chrono::NaiveDate;
|
||||||
use rust_decimal::Decimal;
|
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
|
/// 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
|
/// 5). Every field is optional; the amount is an exact decimal in the
|
||||||
@@ -118,3 +119,286 @@ impl Annotation {
|
|||||||
Ok(self)
|
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<String>,
|
||||||
|
/// Account codes exempt from the policy; the exemption applies only
|
||||||
|
/// when every line is on one.
|
||||||
|
pub exempt_accounts: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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<Decimal, LedgerError> {
|
||||||
|
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<String> {
|
||||||
|
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<String> {
|
||||||
|
// 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<String> {
|
||||||
|
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::<Vec<_>>()
|
||||||
|
.join(" ")
|
||||||
|
.to_lowercase()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn debit_accounts(entry: &JournalEntry) -> Vec<u64> {
|
||||||
|
let mut ids: Vec<u64> = 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<u64> {
|
||||||
|
// 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 },
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -355,6 +355,10 @@ pub struct Ledger {
|
|||||||
/// What each entry settles (`entry.settles`, plan P6): the one record
|
/// What each entry settles (`entry.settles`, plan P6): the one record
|
||||||
/// a member-paid entry's "reimbursed" is derived from (IZ-18).
|
/// a member-paid entry's "reimbursed" is derived from (IZ-18).
|
||||||
entry_settles: std::collections::BTreeMap<EntryId, Vec<EntryId>>,
|
entry_settles: std::collections::BTreeMap<EntryId, Vec<EntryId>>,
|
||||||
|
/// 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<EntryId, crate::evidence::Origin>,
|
||||||
/// The journal line that posted each posted entry (`entry.posted` or
|
/// The journal line that posted each posted entry (`entry.posted` or
|
||||||
/// `entry.reversed`, plan P4): what "posted since seq N" is read from,
|
/// `entry.reversed`, plan P4): what "posted since seq N" is read from,
|
||||||
/// backdated entries included. An entry the journal's own posting
|
/// backdated entries included. An entry the journal's own posting
|
||||||
@@ -424,6 +428,7 @@ impl Ledger {
|
|||||||
conventions: crate::conventions::Conventions::default(),
|
conventions: crate::conventions::Conventions::default(),
|
||||||
annotations: std::collections::BTreeMap::new(),
|
annotations: std::collections::BTreeMap::new(),
|
||||||
entry_settles: std::collections::BTreeMap::new(),
|
entry_settles: std::collections::BTreeMap::new(),
|
||||||
|
entry_origins: std::collections::BTreeMap::new(),
|
||||||
posted_seq: std::collections::BTreeMap::new(),
|
posted_seq: std::collections::BTreeMap::new(),
|
||||||
expected_seq: std::collections::BTreeMap::new(),
|
expected_seq: std::collections::BTreeMap::new(),
|
||||||
unstamped: Vec::new(),
|
unstamped: Vec::new(),
|
||||||
@@ -843,6 +848,7 @@ impl Ledger {
|
|||||||
("annotations", self.annotations == other.annotations),
|
("annotations", self.annotations == other.annotations),
|
||||||
("entry_settles", self.entry_settles == other.entry_settles),
|
("entry_settles", self.entry_settles == other.entry_settles),
|
||||||
("posted_seq", self.posted_seq == other.posted_seq),
|
("posted_seq", self.posted_seq == other.posted_seq),
|
||||||
|
("entry_origins", self.entry_origins == other.entry_origins),
|
||||||
]
|
]
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|(_, same)| !same)
|
.filter(|(_, same)| !same)
|
||||||
@@ -2102,6 +2108,57 @@ impl Ledger {
|
|||||||
self.posted_seq.get(&entry).copied()
|
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
|
/// Say that journal line `seq` will be `entry`'s posting line, ahead
|
||||||
/// of the command that posts it (a store, live: it journals
|
/// of the command that posts it (a store, live: it journals
|
||||||
/// `entry.posted` or `entry.reversed` first). Consumed when the entry
|
/// `entry.posted` or `entry.reversed` first). Consumed when the entry
|
||||||
|
|||||||
@@ -705,14 +705,13 @@ impl Books {
|
|||||||
.entry_vendor(id)
|
.entry_vendor(id)
|
||||||
.and_then(|v| ledger.get_vendor(v))
|
.and_then(|v| ledger.get_vendor(v))
|
||||||
.map(|v| v.name.clone());
|
.map(|v| v.name.clone());
|
||||||
// The receipt policy covers what is posted by hand (journal
|
// The engine decides (plan P4, I-5): the receipt policy covers
|
||||||
// entries and recurring drafts); invoices, payments, closes,
|
// what is posted by hand (journal entries, recurring drafts);
|
||||||
// depreciation, and schedules carry their own evidence, and a
|
// invoices, payments, closes, depreciation, and schedules carry
|
||||||
// reversal leans on its original's.
|
// their own evidence, and a reversal leans on its original's.
|
||||||
let missing_receipt = matches!(origin, "Journal entry" | "Recurring")
|
// The viewer asks the same function the server's gate asks.
|
||||||
&& e.reversal_of().is_none()
|
let missing_receipt = rec.is_empty()
|
||||||
&& rec.is_empty()
|
&& ledger_core::evidence::requirement(&ledger, e, ledger.settings()).is_required();
|
||||||
&& policy.receipt_required(&ledger, e);
|
|
||||||
let cash_effect = e
|
let cash_effect = e
|
||||||
.lines()
|
.lines()
|
||||||
.iter()
|
.iter()
|
||||||
|
|||||||
@@ -35,6 +35,24 @@ pub enum StoreError {
|
|||||||
#[error("asset {0} already has depreciation posted; correct it with entries instead")]
|
#[error("asset {0} already has depreciation posted; correct it with entries instead")]
|
||||||
AssetDepreciationPosted(u64),
|
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<rust_decimal::Decimal>,
|
||||||
|
/// 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<u64>,
|
||||||
|
},
|
||||||
|
|
||||||
/// Filesystem error.
|
/// Filesystem error.
|
||||||
#[error("io error: {0}")]
|
#[error("io error: {0}")]
|
||||||
Io(#[from] std::io::Error),
|
Io(#[from] std::io::Error),
|
||||||
|
|||||||
@@ -1519,6 +1519,47 @@ impl Store {
|
|||||||
self.post_entry_unchecked(entry)
|
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
|
/// Approval rules (ledger_core::conventions INVARIANT 3): refuse an
|
||||||
/// operation that would post at once an entry an enforced rule covers
|
/// 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
|
/// (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.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)?;
|
apply_line_to_sqlite(&self.conn, &line)?;
|
||||||
Ok(entry_id)
|
Ok(entry_id)
|
||||||
}
|
}
|
||||||
@@ -4398,6 +4441,8 @@ impl Store {
|
|||||||
)?);
|
)?);
|
||||||
let live = self.ledger.remit_payroll_taxes(date, cash_account)?;
|
let live = self.ledger.remit_payroll_taxes(date, cash_account)?;
|
||||||
debug_assert_eq!(live, entry_id, "id allocator diverged from journal");
|
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 {
|
for line in &journaled {
|
||||||
apply_line_to_sqlite(&self.conn, line)?;
|
apply_line_to_sqlite(&self.conn, line)?;
|
||||||
}
|
}
|
||||||
@@ -7492,7 +7537,11 @@ fn replay_line(
|
|||||||
}
|
}
|
||||||
"payroll.taxes_remitted" => {
|
"payroll.taxes_remitted" => {
|
||||||
// Names the remittance entry (rebuilt by the entry events just
|
// 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" => {
|
"asset.registered" => {
|
||||||
let want = get_u64(p, "id")?;
|
let want = get_u64(p, "id")?;
|
||||||
@@ -7663,6 +7712,10 @@ fn replay_line(
|
|||||||
parse_date(get_str(p, "date")?)?,
|
parse_date(get_str(p, "date")?)?,
|
||||||
parse_money(p, "proceeds")?,
|
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" => {
|
"period.created" => {
|
||||||
let want = get_u64(p, "id")?;
|
let want = get_u64(p, "id")?;
|
||||||
|
|||||||
Reference in new issue
Block a user