feat(drafts): a draft has a state and can be discarded (P4)

Accounting reason: an entry that is finished but waits on its receipt is
a different thing from one still being built, and a draft that will
never post must have a way out, or the period that holds it open can
never close (the Lean hypothesis hdrafts). JournalEntry gains
DraftState { Building, AwaitingEvidence { note } }; Ledger gains
set_draft_state (a note: no line, balance or report moves),
discard_draft (a draft only; a posted entry is never removed, B-8; ids
are never reused; what was recorded about the draft goes with it) and
posted_seq (the journal line that posted an entry, for the digest's
since_seq). Lean Step.discard with its preservation proof and three
theorems (a discard moves no figure, removes only its draft, and
unblocks a close), listed in Audit.lean.

Tests: discard_unblocks_close (ledger-core invariants).

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
swaitsandClaude Fable 5.1 committed 2026-10-29 13:55:17 -06:00
1 parent 75882c5e07
commit be8bcf340e
8 files changed
+347 -1

No files matched your search

+6
View File
@@ -854,6 +854,12 @@ pub enum LedgerError {
#[error("{0}")]
InvalidVoid(String),
/// A draft's state or discard was refused: the message says why (a
/// note or reason that breaks the books' text rule, no reason given).
/// Plan P4, rules.md B-43.
#[error("{0}")]
InvalidDraft(String),
/// A void invoice can't be recorded as sent: it was never a document
/// the customer should have received (plan P4b S5, B-38).
#[error("invoice {0} is void: a void invoice was never sent")]
+68
View File
@@ -23,6 +23,55 @@ pub enum EntryStatus {
Posted,
}
/// Where a draft entry stands (plan P4, `drafts_v2`): still being built,
/// or finished and waiting on the document that supports it (rules.md
/// B-43). Meaningful while the entry is a draft; a posted entry keeps
/// the state it had and nothing reads it.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum DraftState {
/// Lines are still being added. What every draft starts as.
#[default]
Building,
/// The lines are final and the entry waits for its receipt or other
/// evidence; `note` says what is awaited (empty: nothing said).
AwaitingEvidence {
/// What the draft waits on, in the words of whoever set the state.
note: String,
},
}
impl DraftState {
/// Its key as the journal, the tools and the viewer name it:
/// `building` or `awaiting_evidence`.
pub fn key(&self) -> &'static str {
match self {
DraftState::Building => "building",
DraftState::AwaitingEvidence { .. } => "awaiting_evidence",
}
}
/// [`Self::key`] the other way, with the note an awaiting state
/// carries; `None` for a state this version doesn't know (a newer
/// version's).
pub fn parse(key: &str, note: &str) -> Option<DraftState> {
match key {
"building" => Some(DraftState::Building),
"awaiting_evidence" => Some(DraftState::AwaitingEvidence {
note: note.to_string(),
}),
_ => None,
}
}
/// The note an awaiting state carries; empty for building.
pub fn note(&self) -> &str {
match self {
DraftState::Building => "",
DraftState::AwaitingEvidence { note } => note,
}
}
}
/// One debit or credit line of a journal entry.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct JournalLine {
@@ -59,6 +108,10 @@ pub struct JournalEntry {
lines: Vec<JournalLine>,
status: EntryStatus,
reversal_of: Option<EntryId>,
/// Where the draft stands (`entry.draft_state_set`); meaningful while
/// `status` is `Draft`.
#[serde(default)]
draft_state: DraftState,
}
impl JournalEntry {
@@ -72,6 +125,7 @@ impl JournalEntry {
lines: Vec::new(),
status: EntryStatus::Draft,
reversal_of: None,
draft_state: DraftState::Building,
}
}
@@ -100,6 +154,12 @@ impl JournalEntry {
self.status
}
/// Where a draft stands (plan P4): building, or awaiting evidence.
/// Meaningful while the entry is a draft.
pub fn draft_state(&self) -> &DraftState {
&self.draft_state
}
/// If this entry reverses another, the reversed entry's id.
pub fn reversal_of(&self) -> Option<EntryId> {
self.reversal_of
@@ -116,9 +176,17 @@ impl JournalEntry {
self.lines[index].class_id = class_id;
}
/// Set the draft's state. Only while drafted; only via
/// [`crate::Ledger::set_draft_state`].
pub(crate) fn set_draft_state(&mut self, state: DraftState) {
self.draft_state = state;
}
/// Mark posted. Only via [`crate::Ledger`], after [`Self::validate`].
pub(crate) fn mark_posted(&mut self) {
self.status = EntryStatus::Posted;
// A posted entry waits on nothing: its state is the canonical one.
self.draft_state = DraftState::Building;
}
/// Link this entry as the reversal of another. Only via [`crate::Ledger`].
+132
View File
@@ -355,6 +355,11 @@ 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<EntryId, Vec<EntryId>>,
/// 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
/// line doesn't name (none today) has no place here.
posted_seq: std::collections::BTreeMap<EntryId, u64>,
}
impl Ledger {
@@ -411,6 +416,7 @@ impl Ledger {
conventions: crate::conventions::Conventions::default(),
annotations: std::collections::BTreeMap::new(),
entry_settles: std::collections::BTreeMap::new(),
posted_seq: std::collections::BTreeMap::new(),
}
}
@@ -826,6 +832,7 @@ impl Ledger {
("conventions", self.conventions == other.conventions),
("annotations", self.annotations == other.annotations),
("entry_settles", self.entry_settles == other.entry_settles),
("posted_seq", self.posted_seq == other.posted_seq),
]
.into_iter()
.filter(|(_, same)| !same)
@@ -1977,6 +1984,131 @@ impl Ledger {
Ok(())
}
/// Set where a draft stands: still being built, or awaiting its
/// evidence (plan P4, rules.md B-43). A state is a note on the draft
/// and moves no figure; the draft's lines are what they were.
///
/// ```text
/// GOAL: let a draft say it is finished and waiting on a document,
/// so it is neither lost nor mistaken for work in progress.
/// SOURCE: docs/spec/rules.md B-43 (DECISION D-47: a draft waits on
/// evidence; the evidence is checked when it posts); IRS
/// Pub. 583 "supporting documents".
/// INVARIANTS:
/// 1. Only a draft has a state: a posted entry is refused
/// (PostedEntryImmutable), an unknown one too (UnknownEntry).
/// 2. The note passes parties::check_text (no full TIN, no control
/// characters, at most 500 characters).
/// 3. No line, balance or report moves; a refusal changes nothing.
/// ```
pub fn set_draft_state(
&mut self,
entry: EntryId,
state: crate::DraftState,
) -> Result<(), LedgerError> {
if let crate::DraftState::AwaitingEvidence { note } = &state {
crate::parties::check_text("the note", note).map_err(LedgerError::InvalidDraft)?;
}
self.draft_mut(entry)?.set_draft_state(state);
Ok(())
}
/// [`Self::set_draft_state`] as journaled, for replay (rules.md
/// B-28): the state is restored on a draft that exists; a state
/// naming an entry since posted or discarded is refused like the
/// command, which journal order makes impossible.
#[doc(hidden)]
pub fn replay_set_draft_state(
&mut self,
entry: EntryId,
state: crate::DraftState,
) -> Result<(), LedgerError> {
self.draft_mut(entry)?.set_draft_state(state);
Ok(())
}
/// Throw a draft away, with the reason (plan P4, rules.md B-43): the
/// only way out of an open draft that will never post, and so the way
/// a period it holds open can close. The draft was never in the books,
/// so no balance moves; the id is spent (ids are never reused); the
/// journal still holds its lines and why it went.
///
/// ```text
/// GOAL: remove a draft that will not post, leaving the books and
/// every id as they were.
/// SOURCE: docs/spec/rules.md B-43; B-8 (a posted entry is never
/// removed: only a draft is); Lean Step.discard.
/// INVARIANTS:
/// 1. Only a draft is discarded: a posted entry is refused
/// (EntryAlreadyPosted), an unknown one too (UnknownEntry).
/// 2. The reason is not empty and passes parties::check_text.
/// 3. Posted entries, their order, every balance and every id
/// allocator are as they were; what was recorded about the draft
/// itself (its vendor, instrument, parties, settlements) goes
/// with it.
/// SCENARIOS:
/// 1. IF a period holds the draft open: after it, the period can
/// close (the Lean hypothesis hdrafts).
/// ```
pub fn discard_draft(&mut self, entry: EntryId, reason: &str) -> Result<(), LedgerError> {
if reason.trim().is_empty() {
return Err(LedgerError::InvalidDraft(
"a discarded draft needs a reason".to_string(),
));
}
crate::parties::check_text("the reason", reason).map_err(LedgerError::InvalidDraft)?;
self.replay_discard_draft(entry)
}
/// [`Self::discard_draft`] as journaled, for replay: the draft goes.
#[doc(hidden)]
pub fn replay_discard_draft(&mut self, entry: EntryId) -> Result<(), LedgerError> {
let e = self
.entries
.get(&entry)
.ok_or(LedgerError::UnknownEntry(entry))?;
if e.status() != EntryStatus::Draft {
return Err(LedgerError::EntryAlreadyPosted(entry));
}
self.entries.remove(&entry);
self.entry_vendors.remove(&entry);
self.entry_instruments.remove(&entry);
self.entry_parties.remove(&entry);
self.entry_no_party.remove(&entry);
self.entry_settles.remove(&entry);
Ok(())
}
/// The journal line (`seq`) that posted `entry`: `entry.posted` or
/// `entry.reversed`. `None` for a draft, an unknown entry, or books
/// read by a path that doesn't carry the sequence (plan P4).
pub fn posted_seq(&self, entry: EntryId) -> Option<u64> {
self.posted_seq.get(&entry).copied()
}
/// Record the journal line that posted `entry` (the store, live and
/// on replay, after the posting line's own command). Idempotent for
/// the same line; a second, different line is ignored (the first
/// posting stands).
#[doc(hidden)]
pub fn note_posted_seq(&mut self, entry: EntryId, seq: u64) {
self.posted_seq.entry(entry).or_insert(seq);
}
/// Draft entries in id order, with their state.
pub fn drafts(&self) -> Vec<&JournalEntry> {
let mut ids: Vec<EntryId> = self
.entries
.values()
.filter(|e| e.status() == EntryStatus::Draft)
.map(|e| e.id())
.collect();
ids.sort_unstable();
ids.into_iter()
.filter_map(|i| self.entries.get(&i))
.collect()
}
/// Look up an entry by id (draft or posted).
pub fn get_entry(&self, entry: EntryId) -> Option<&JournalEntry> {
self.entries.get(&entry)
+1 -1
View File
@@ -76,7 +76,7 @@ pub use form1099::{
Vendor1099Line, Vendor1099Report, vendor_1099_report,
};
pub use instruments::{Instrument, InstrumentId, InstrumentKind};
pub use journal::{EntryId, EntryStatus, JournalEntry, JournalLine};
pub use journal::{DraftState, EntryId, EntryStatus, JournalEntry, JournalLine};
pub use kernel::{EntryFlows, Flow};
pub use ledger::{
BAD_DEBTS, ControlAccount, GENERAL_EXPENSES, Ledger, PAYABLES, PAYMENT_PROCESSING_FEES,
+72
View File
@@ -1609,3 +1609,75 @@ fn replayed_documents_and_payments_rebuild_the_records() {
assert_eq!(b.status, DocumentStatus::Paid);
assert_eq!(l.entry_vendor(last), Some(v));
}
// ---------------------------------------------------------------------------
// Drafts: a state, and a way out (plan P4, rules.md B-43).
// ---------------------------------------------------------------------------
/// Spec (rules.md B-43; Lean `Step.discard` and `hdrafts`): a period with
/// an open draft can't close; once the draft is discarded it can, and
/// nothing posted moved: balances, order and ids are as they were.
#[test]
fn discard_unblocks_close() {
let c = fixture_company();
let mut ledger = c.ledger.clone();
let jan = ledger.create_period("2026-01", date(1), date(31)).unwrap();
let draft = ledger.create_entry(date(20), "Figma annual plan");
ledger
.add_line(draft, c.rent, Side::Debit, usd(40))
.unwrap();
ledger
.add_line(draft, c.cash, Side::Credit, usd(40))
.unwrap();
// A state is a note on the draft: it moves nothing and doesn't unblock.
ledger
.set_draft_state(
draft,
ledger_core::DraftState::AwaitingEvidence {
note: "invoice due from Figma".into(),
},
)
.unwrap();
assert_eq!(
ledger.get_entry(draft).unwrap().draft_state().key(),
"awaiting_evidence"
);
assert_eq!(
ledger.close_period(jan).unwrap_err(),
LedgerError::DraftsInPeriod(jan)
);
let tb_before = snapshot(&ledger).unwrap();
let posted_before: Vec<u64> = ledger.posted_entries().map(|e| e.id()).collect();
let next_before = ledger.next_entry_id();
// A reason is required; a posted entry is never discarded.
assert!(matches!(
ledger.discard_draft(draft, " "),
Err(LedgerError::InvalidDraft(_))
));
assert_eq!(
ledger.discard_draft(posted_before[0], "oops").unwrap_err(),
LedgerError::EntryAlreadyPosted(posted_before[0])
);
ledger.discard_draft(draft, "never billed").unwrap();
assert!(ledger.get_entry(draft).is_none());
assert_eq!(snapshot(&ledger).unwrap(), tb_before);
assert_eq!(
ledger.posted_entries().map(|e| e.id()).collect::<Vec<_>>(),
posted_before
);
assert_eq!(ledger.next_entry_id(), next_before, "ids are never reused");
assert!(ledger.close_period(jan).unwrap().is_some());
// A state on a posted entry, or on none, is refused.
assert_eq!(
ledger
.set_draft_state(posted_before[0], ledger_core::DraftState::Building)
.unwrap_err(),
LedgerError::PostedEntryImmutable
);
assert_eq!(
ledger
.set_draft_state(9999, ledger_core::DraftState::Building)
.unwrap_err(),
LedgerError::UnknownEntry(9999)
);
}