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
@@ -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<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.
|
||||
#[error("io error: {0}")]
|
||||
Io(#[from] std::io::Error),
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
Reference in new issue
Block a user