diff --git a/crates/ledger-core/src/journal.rs b/crates/ledger-core/src/journal.rs index b6b2b30..197e74a 100644 --- a/crates/ledger-core/src/journal.rs +++ b/crates/ledger-core/src/journal.rs @@ -63,6 +63,12 @@ impl DraftState { } } + /// Whether this is the default, building (a serialized entry omits it, + /// so every entry an earlier version wrote reads and writes as it did). + pub fn is_building(&self) -> bool { + matches!(self, DraftState::Building) + } + /// The note an awaiting state carries; empty for building. pub fn note(&self) -> &str { match self { @@ -110,7 +116,7 @@ pub struct JournalEntry { reversal_of: Option, /// Where the draft stands (`entry.draft_state_set`); meaningful while /// `status` is `Draft`. - #[serde(default)] + #[serde(default, skip_serializing_if = "DraftState::is_building")] draft_state: DraftState, } diff --git a/crates/ledger-core/src/ledger.rs b/crates/ledger-core/src/ledger.rs index c1ad4e5..cea79d3 100644 --- a/crates/ledger-core/src/ledger.rs +++ b/crates/ledger-core/src/ledger.rs @@ -360,6 +360,14 @@ pub struct Ledger { /// 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, + /// The journal line already known to be an entry's posting line, + /// before the engine posts it (a store journals `entry.posted` ahead + /// of the command that posts): consumed when the entry posts. Not + /// state of its own. + expected_seq: std::collections::BTreeMap, + /// Entries posted since the last [`Self::stamp_unstamped`], whose + /// posting line is the one being applied. Not state of its own. + unstamped: Vec, } impl Ledger { @@ -417,6 +425,8 @@ impl Ledger { annotations: std::collections::BTreeMap::new(), entry_settles: std::collections::BTreeMap::new(), posted_seq: std::collections::BTreeMap::new(), + expected_seq: std::collections::BTreeMap::new(), + unstamped: Vec::new(), } } @@ -1976,6 +1986,12 @@ impl Ledger { .get_mut(&entry) .ok_or(LedgerError::UnknownEntry(entry))? .mark_posted(); + match self.expected_seq.remove(&entry) { + Some(seq) => { + self.posted_seq.entry(entry).or_insert(seq); + } + None => self.unstamped.push(entry), + } self.order_pos.insert(entry, self.order.len()); self.order.push(entry); if let Some(o) = snapshot.reversal_of() { @@ -2086,13 +2102,26 @@ impl Ledger { 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). + /// 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 + /// posts. #[doc(hidden)] - pub fn note_posted_seq(&mut self, entry: EntryId, seq: u64) { - self.posted_seq.entry(entry).or_insert(seq); + pub fn expect_posted_seq(&mut self, entry: EntryId, seq: u64) { + self.expected_seq.insert(entry, seq); + } + + /// Give every entry posted since the last call (and not already told + /// its line by [`Self::expect_posted_seq`]) journal line `seq`: the + /// line whose application posted it. A store calls it after each line + /// it replays, and after a live command that journals one line and + /// posts an entry with it (a close, a schedule month). The first + /// posting stands. + #[doc(hidden)] + pub fn stamp_unstamped(&mut self, seq: u64) { + for entry in std::mem::take(&mut self.unstamped) { + self.posted_seq.entry(entry).or_insert(seq); + } } /// Draft entries in id order, with their state. diff --git a/crates/ledger-gui/src/pages/journal.rs b/crates/ledger-gui/src/pages/journal.rs index 51614e4..42566f8 100644 --- a/crates/ledger-gui/src/pages/journal.rs +++ b/crates/ledger-gui/src/pages/journal.rs @@ -426,6 +426,29 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option { }, Some(Route::Document(att)), ), + // A draft's state and a discard (plan P4): notes on a draft, no + // figure. A discarded draft is no longer an entry to open. + "entry.draft_state_set" => ev( + icon::PENCIL_SIMPLE, + match ( + s(p, "state"), + p.get("note") + .and_then(|v| v.as_str()) + .filter(|n| !n.is_empty()), + ) { + ("awaiting_evidence", Some(note)) => { + format!("Draft #{id} waits for its evidence: {note}") + } + ("awaiting_evidence", None) => format!("Draft #{id} waits for its evidence"), + _ => format!("Draft #{id} is being built"), + }, + Some(Route::Entry(id)), + ), + "entry.draft_discarded" => ev( + icon::TRASH, + format!("Draft #{id} discarded: {}", s(p, "reason")), + None, + ), "entry.settles" => { let settles: Vec = p .get("settles") diff --git a/crates/ledger-mcp/handbook/format-upgrade.md b/crates/ledger-mcp/handbook/format-upgrade.md index aff3758..603d65e 100644 --- a/crates/ledger-mcp/handbook/format-upgrade.md +++ b/crates/ledger-mcp/handbook/format-upgrade.md @@ -8,4 +8,4 @@ - After the upgrade an older version of wanderbooks can't open these books; the backup made first keeps them as they were. Going back is the owner's choice, never yours. - This upgrade is `documents_v2`: invoice numbers, terms and line accounts (S4), sent records (S5), payments that settle several documents with early-payment discounts and a processor's fee (S6), and write-offs (S7). Until it is given, those writes are refused and everything else works as before. -- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, and `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books. +- The journal features an upgrade can add: `members_cumulative`, `account_renamed`, `require_tax_category`, `tax_categories_gain_exempt`, `disposal_before_service`, `depreciation_catch_up`, `party_creditors`, `payment_instruments`, `vendor_1099_decisions`, `schedule_convention`, `month_reposting`, `asset_basis`, `projects`, `document_line_projects`, `document_standing`, `documents_v2`, `attachment_text` (P6: a filed file's extracted text, what a human annotated of it, and the entries a payment settles), and `drafts_v2` (P4: a draft's state, awaiting its evidence, and a discarded draft). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books. diff --git a/crates/ledger-store/src/format.rs b/crates/ledger-store/src/format.rs index 372f13d..651de04 100644 --- a/crates/ledger-store/src/format.rs +++ b/crates/ledger-store/src/format.rs @@ -64,6 +64,7 @@ pub const KNOWN_FEATURES: &[&str] = &[ DOCUMENT_STANDING, DOCUMENTS_V2, ATTACHMENT_TEXT, + DRAFTS_V2, ]; /// Feature: `period.closed` closes to members with `"rounding": @@ -208,6 +209,15 @@ pub const DOCUMENTS_V2: &str = "documents_v2"; /// declaration instead (rules.md B-27, B-44). pub const ATTACHMENT_TEXT: &str = "attachment_text"; +/// Feature: `drafts_v2` (plan P4, D-52): `entry.draft_state_set` (a draft +/// building or awaiting its evidence) and `entry.draft_discarded` (a +/// draft thrown away, with why). An older version doesn't know those +/// event types: it would refuse them as unknown, or, replaying around +/// them, keep a discarded draft that blocks a period's close and call a +/// waiting draft one being built; it stops at the declaration instead +/// (rules.md B-27, B-43). +pub const DRAFTS_V2: &str = "drafts_v2"; + /// The line type that declares the journal format. pub const FORMAT_SET: &str = "journal.format_set"; @@ -243,6 +253,8 @@ pub const EVENT_TYPES: &[&str] = &[ "document.filed", "entry.approved", "entry.created", + "entry.draft_discarded", + "entry.draft_state_set", "entry.instrument_set", "entry.line_added", "entry.line_class_set", @@ -942,6 +954,41 @@ mod tests { assert_eq!(up.adds(), vec!["the journal feature documents_v2"]); } + /// The version before P4 (every feature through `attachment_text`) + /// stops at a declaration of `drafts_v2` as newer, never keeping a + /// discarded draft or calling a waiting one a draft being built; and + /// books that declared every earlier feature still wait for it + /// (B-27, D-39). + #[test] + fn drafts_v2_is_its_own_feature() { + assert!(KNOWN_FEATURES.contains(&DRAFTS_V2)); + let before: Vec<&str> = KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != DRAFTS_V2) + .collect(); + let l = line( + FORMAT_SET, + json!({"format": 2, "features": [ATTACHMENT_TEXT, DRAFTS_V2]}), + ); + match declared_knowing(&l, &before) { + Err(StoreError::NewerJournal { need, .. }) => { + assert_eq!(need, "journal features drafts_v2") + } + other => panic!("expected NewerJournal, got {other:?}"), + } + let older = JournalFormat { + format: 2, + features: before.iter().map(|f| f.to_string()).collect(), + }; + assert!(matches!( + older.require_feature(DRAFTS_V2), + Err(StoreError::FormatUpgradePending { .. }) + )); + let up = older.upgrade().expect("waits for it"); + assert_eq!(up.adds(), vec!["the journal feature drafts_v2"]); + } + /// This version's format: the newest it reads, every feature it /// knows, each once, sorted (what new books and an upgrade declare). #[test] diff --git a/crates/ledger-store/src/meta.rs b/crates/ledger-store/src/meta.rs index 42e1b1f..bf17644 100644 --- a/crates/ledger-store/src/meta.rs +++ b/crates/ledger-store/src/meta.rs @@ -20,7 +20,7 @@ use ledger_core::arap::{SendKind, Sent, SentVia}; use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem}; use ledger_core::settings::{Change, Source}; use ledger_core::{ - Annotation, AnnotationFields, ClassId, ProjectFacts, ProjectKind, ProjectStatus, + Annotation, AnnotationFields, ClassId, DraftState, ProjectFacts, ProjectKind, ProjectStatus, }; use serde_json::{Map, Value, json}; @@ -624,6 +624,44 @@ pub fn entry_settles_of(p: &Value) -> Result<(u64, Vec), StoreError> { Ok((u64_of(p, E, "entry_id")?, settles)) } +/// `entry.draft_state_set {id, state, note?}` (plan P4): where a draft +/// stands. A state is a note on the draft; it moves no figure. +pub fn draft_state_set(id: u64, state: &DraftState) -> Value { + let mut o = Map::new(); + o.insert("id".into(), json!(id)); + o.insert("state".into(), json!(state.key())); + if !state.note().is_empty() { + o.insert("note".into(), json!(state.note())); + } + Value::Object(o) +} + +/// Read an `entry.draft_state_set`: `(draft, state)`. A state this +/// version doesn't know is newer, never corrupt (rules.md B-27). +pub fn draft_state_set_of(line: &JournalLine) -> Result<(u64, DraftState), StoreError> { + const E: &str = "entry.draft_state_set"; + let p = &line.payload; + let key = str_of(p, E, "state")?; + let note = opt_str(p, E, "note")?.unwrap_or_default(); + Ok(( + u64_of(p, E, "id")?, + DraftState::parse(&key, ¬e).ok_or_else(|| { + crate::format::newer_content(line, format!("the draft state {key:?}")) + })?, + )) +} + +/// `entry.draft_discarded {id, reason}` (plan P4): a draft thrown away. +pub fn draft_discarded(id: u64, reason: &str) -> Value { + json!({"id": id, "reason": reason}) +} + +/// Read an `entry.draft_discarded`: `(draft, reason)`. +pub fn draft_discarded_of(p: &Value) -> Result<(u64, String), StoreError> { + const E: &str = "entry.draft_discarded"; + Ok((u64_of(p, E, "id")?, str_of(p, E, "reason")?)) +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/ledger-store/src/schema.rs b/crates/ledger-store/src/schema.rs index 2d570cd..f0e1e70 100644 --- a/crates/ledger-store/src/schema.rs +++ b/crates/ledger-store/src/schema.rs @@ -5,7 +5,7 @@ //! is the current version. /// Current schema version. -pub const SCHEMA_VERSION: u32 = 18; +pub const SCHEMA_VERSION: u32 = 19; /// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`. pub const MIGRATIONS: &[&str] = &[ @@ -468,5 +468,22 @@ CREATE TABLE IF NOT EXISTS annotations( seq INTEGER NOT NULL ); ALTER TABLE attachments ADD COLUMN duplicate_of INTEGER NULL; +"#, + r#" +-- v19: draft states and posting order (plan P4, `drafts_v2`). Drafts are +-- not rows of `entries` (they never touch the projection until posted), +-- so a draft's state has a table of its own: one row per draft awaiting +-- its evidence (a draft being built is the default and has no row), gone +-- when the draft posts or is discarded. `posted_seq` is the journal line +-- (`entry.posted` or `entry.reversed`) that posted the entry: what the +-- digest's `since_seq` reads. Old rows are never migrated in place: the +-- database file is named for its schema, so a v19 file is built from the +-- journal. +ALTER TABLE entries ADD COLUMN posted_seq INTEGER NULL; +CREATE TABLE IF NOT EXISTS draft_states( + entry_id INTEGER PRIMARY KEY, + state TEXT NOT NULL, + note TEXT NULL +); "#, ]; diff --git a/crates/ledger-store/src/store.rs b/crates/ledger-store/src/store.rs index eed4540..c65ec47 100644 --- a/crates/ledger-store/src/store.rs +++ b/crates/ledger-store/src/store.rs @@ -1261,7 +1261,15 @@ impl Store { line_type: &str, payload: Value, ) -> Result { - self.journal.append(line_type, payload) + let line = self.journal.append(line_type, payload)?; + // The command that posts the entry runs after its line is + // journaled: tell the ledger which line that is (plan P4). + if matches!(line_type, "entry.posted" | "entry.reversed") + && let Some(id) = line.payload.get("id").and_then(Value::as_u64) + { + self.ledger.expect_posted_seq(id, line.seq); + } + Ok(line) } /// Add an account to the chart, with no tax category (see @@ -1431,6 +1439,67 @@ impl Store { Ok(()) } + /// Set where a draft stands (`entry.draft_state_set`, plan P4): still + /// being built, or awaiting its evidence with a note saying what. + /// Journaled with the feature `drafts_v2` (an older version refuses + /// the type). A state moves no figure and projects nowhere but the + /// `draft_states` table. + /// + /// ```text + /// GOAL: a draft that is finished and waiting on its receipt says so, + /// durably, and replays the same. + /// SOURCE: docs/spec/rules.md B-27, B-43; plan P4. + /// SCENARIOS: + /// 1. IF the engine refuses it (unknown or posted entry, a note the + /// books won't keep): SHOULD refuse, journaling nothing. + /// 2. IF the books haven't declared drafts_v2: SHOULD need the + /// feature, journaling nothing. + /// ``` + pub fn set_draft_state( + &mut self, + entry: EntryId, + state: ledger_core::DraftState, + ) -> Result<(), StoreError> { + self.ensure_feature(crate::format::DRAFTS_V2)?; + self.rehearse(|l| l.set_draft_state(entry, state.clone()))?; + let line = self.append_journal( + "entry.draft_state_set", + crate::meta::draft_state_set(entry, &state), + )?; + self.ledger.set_draft_state(entry, state)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + + /// Throw a draft away (`entry.draft_discarded`, plan P4), with the + /// reason; journaled with the feature `drafts_v2`. The draft's lines + /// stay in the journal; the draft leaves the ledger. A posted entry is + /// never discarded. + /// + /// ```text + /// GOAL: a draft that will never post can go, so it stops holding its + /// period open, and the journal still says why. + /// SOURCE: docs/spec/rules.md B-8, B-43; plan P4. + /// SCENARIOS: + /// 1. IF the entry is unknown or posted, or the reason is empty or + /// breaks the books' text rule: SHOULD refuse, journaling + /// nothing. + /// 2. IF the books haven't declared drafts_v2: SHOULD need the + /// feature, journaling nothing. + /// ``` + pub fn discard_draft(&mut self, entry: EntryId, reason: &str) -> Result<(), StoreError> { + self.ensure_feature(crate::format::DRAFTS_V2)?; + self.rehearse(|l| l.discard_draft(entry, reason))?; + let reason = reason.trim(); + let line = self.append_journal( + "entry.draft_discarded", + crate::meta::draft_discarded(entry, reason), + )?; + self.ledger.discard_draft(entry, reason)?; + apply_line_to_sqlite(&self.conn, &line)?; + Ok(()) + } + /// Validate, journal, and post a draft entry. An entry an enforced /// approval rule covers posts only once its approvers approved it as /// it stands (ledger_core::conventions INVARIANT 3). @@ -2905,6 +2974,8 @@ impl Store { }), )?; self.ledger = next; + // The closing entry was posted by this line (plan P4, posted_seq). + self.ledger.stamp_unstamped(line.seq); apply_line_to_sqlite(&self.conn, &line)?; Ok(closing_entry) } @@ -4859,6 +4930,8 @@ impl Store { }), )?; self.ledger = next; + // The month's entry was posted by this line (plan P4, posted_seq). + self.ledger.stamp_unstamped(line.seq); apply_line_to_sqlite(&self.conn, &line)?; Ok(entry) } @@ -7091,7 +7164,10 @@ pub(crate) fn replay_line_into_ledger( line: &JournalLine, base_currency: Currency, ) -> Result<(), StoreError> { - ledger_core::parties::replaying(|| replay_line(ledger, line, base_currency)) + ledger_core::parties::replaying(|| replay_line(ledger, line, base_currency))?; + // Whatever this line posted was posted by it (plan P4, posted_seq). + ledger.stamp_unstamped(line.seq); + Ok(()) } /// [`replay_line_into_ledger`]'s work. @@ -7206,6 +7282,14 @@ fn replay_line( } ledger.replay_post_entry(id)?; } + "entry.draft_state_set" => { + let (id, state) = crate::meta::draft_state_set_of(line)?; + ledger.replay_set_draft_state(id, state)?; + } + "entry.draft_discarded" => { + let (id, _) = crate::meta::draft_discarded_of(p)?; + ledger.replay_discard_draft(id)?; + } "entry.reversed" => { let want = get_u64(p, "id")?; let orig = get_u64(p, "reverses")?; @@ -7994,11 +8078,16 @@ fn close_to_from_journal(line: &JournalLine, v: Option<&Value>) -> Result Result<(), StoreError> { +fn project_posted_entry(conn: &Connection, e: &Value, seq: u64) -> Result<(), StoreError> { let eid = get_u64(e, "id")? as i64; conn.execute( - "INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',NULL)", - params![eid, get_str(e, "date")?, get_str(e, "description")?], + "INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',NULL,?)", + params![ + eid, + get_str(e, "date")?, + get_str(e, "description")?, + seq as i64 + ], )?; for l in e .get("lines") @@ -8109,16 +8198,53 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr // Draft lines are durable in the journal but never touch the // projection until posted. "entry.line_added" => {} + // A draft awaiting its evidence has a row; one being built (the + // default) has none; a posted or discarded draft none (plan P4). + "entry.draft_state_set" => { + let (id, state) = crate::meta::draft_state_set_of(line)?; + match state { + ledger_core::DraftState::Building => { + conn.execute( + "DELETE FROM draft_states WHERE entry_id = ?", + params![id as i64], + )?; + } + ledger_core::DraftState::AwaitingEvidence { note } => { + conn.execute( + "INSERT INTO draft_states(entry_id, state, note) VALUES (?,?,?) \ + ON CONFLICT(entry_id) DO UPDATE SET state = excluded.state, note = excluded.note", + params![ + id as i64, + ledger_core::DraftState::AwaitingEvidence { note: note.clone() }.key(), + (!note.is_empty()).then_some(note), + ], + )?; + } + } + } + "entry.draft_discarded" => { + let (id, _) = crate::meta::draft_discarded_of(p)?; + conn.execute( + "DELETE FROM draft_states WHERE entry_id = ?", + params![id as i64], + )?; + } "entry.posted" => { conn.execute( - "INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)", + "INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',?,?)", params![ get_u64(p, "id")? as i64, get_str(p, "date")?, get_str(p, "description")?, p.get("reversal_of").and_then(|v| v.as_u64()).map(|v| v as i64), + line.seq as i64, ], )?; + // A posted entry waits on nothing (plan P4). + conn.execute( + "DELETE FROM draft_states WHERE entry_id = ?", + params![get_u64(p, "id")? as i64], + )?; for l in p .get("lines") .and_then(|v| v.as_array()) @@ -8153,12 +8279,13 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr params![get_u64(p, "id")? as i64, get_u64(p, "reverses")? as i64], )?; conn.execute( - "INSERT INTO entries(id, date, description, status, reversal_of) VALUES (?,?,?,'posted',?)", + "INSERT INTO entries(id, date, description, status, reversal_of, posted_seq) VALUES (?,?,?,'posted',?,?)", params![ get_u64(p, "id")? as i64, get_str(p, "date")?, get_str(p, "description")?, get_u64(p, "reverses")? as i64, + line.seq as i64, ], )?; for l in p @@ -8498,7 +8625,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr "schedule.registered" | "recurring.registered" | "recurring.generated" => { // Master data and drafts: the ledger holds them; no report rows. } - "schedule.posted" => project_posted_entry(conn, &p["entry"])?, + "schedule.posted" => project_posted_entry(conn, &p["entry"], line.seq)?, "receipt.unlinked" => { conn.execute( "UPDATE attachments SET entry_id = NULL WHERE id = ?", @@ -8624,7 +8751,7 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr // posted entry so the projection stays complete, then mark the // period row closed. if !p.get("entry").map(|v| v.is_null()).unwrap_or(true) { - project_posted_entry(conn, &p["entry"])?; + project_posted_entry(conn, &p["entry"], line.seq)?; } conn.execute( "UPDATE periods SET status = 'closed', closing_entry = ? WHERE id = ?", diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml index f426613..c3eb1c2 100644 --- a/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml +++ b/crates/ledger-store/tests/fixtures/old-books-writer/Cargo.toml @@ -11,6 +11,9 @@ # the revision before P4b S5) # cargo run --bin p2_1099 -- OUT (p2_vendor_1099: P2's 1099 marks, run # against 9c99a0cf, the revision before P3) +# cargo run --bin p4_before -- OUT (p4_before: run against 25b52580, the +# revision before P4: hand entries posted +# late, a reversal, open drafts, a close) # cargo run --bin expect -- OUT (expected.json beside each journal) # and copy each journal.jsonl, blobs/ and expected.json into old-books/. # x_member_closes was written later, the same way, by main 00226a4c diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/expect.rs b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/expect.rs index a3bf914..65c97af 100644 --- a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/expect.rs +++ b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/expect.rs @@ -6,7 +6,7 @@ fn main() { names.sort(); for dir in names { let n = dir.file_name().unwrap().to_string_lossy().to_string(); - let _ = std::fs::remove_file(dir.join(ledger_store::DB_FILE)); + let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); let s = match ledger_store::Store::open(&dir) { Ok(s) => s, Err(e) => { println!("FAILS {n}: {e}"); continue } }; let l = s.ledger(); let entries: Vec<_> = (1..l.next_entry_id()).filter_map(|id| l.get_entry(id)).collect(); diff --git a/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p4_before.rs b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p4_before.rs new file mode 100644 index 0000000..123e2ad --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books-writer/src/bin/p4_before.rs @@ -0,0 +1,106 @@ +//! Writes `p4_before`: books the version before P4 (main 25b52580) wrote +//! through its Store API: hand entries (one posted late with an early +//! date), a reversal, an invoice and its payment, a schedule month, a +//! recurring entry with a generated draft, two open drafts (one with +//! lines, one empty), and a closed January. No draft state, discard or +//! posting sequence exists in that version; this version opens the same +//! journal and must report the same figures. +use chrono::NaiveDate; +use ledger_core::schedules::{Frequency, NewRecurring, NewSchedule, ScheduleConvention, ScheduleKind}; +use ledger_core::{AccountType, Currency, DocumentLine, Money, Side}; +use ledger_store::Store; +use rust_decimal::Decimal; +use std::path::PathBuf; + +fn d(y: i32, m: u32, day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(y, m, day).unwrap() +} +fn cents(c: i64) -> Decimal { + Decimal::new(c, 2) +} +fn usd(c: i64) -> Money { + Money::new(cents(c), Currency::USD) +} + +fn main() { + let root = PathBuf::from(std::env::args().nth(1).unwrap()); + let dir = root.join("p4_before"); + let _ = std::fs::remove_dir_all(&dir); + std::fs::create_dir_all(&dir).unwrap(); + let mut s = Store::create(&dir, Currency::USD).unwrap(); + let bank = s.add_account("1000", "Bank", AccountType::Asset, None, false).unwrap(); + let prepaid = s.add_account("1300", "Prepaid expenses", AccountType::Asset, None, false).unwrap(); + let capital = s.add_account("3000", "Capital", AccountType::Equity, None, false).unwrap(); + let sales = s.add_account("4000", "Sales", AccountType::Revenue, None, false).unwrap(); + let rent = s.add_account("6000", "Rent", AccountType::Expense, None, false).unwrap(); + let software = s.add_account("6100", "Software", AccountType::Expense, None, false).unwrap(); + let post = |s: &mut Store, date: NaiveDate, what: &str, dr: u64, cr: u64, c: i64| { + let e = s.create_entry(date, what).unwrap(); + s.add_line(e, dr, Side::Debit, usd(c)).unwrap(); + s.add_line(e, cr, Side::Credit, usd(c)).unwrap(); + e + }; + let seed = post(&mut s, d(2026, 1, 1), "Opening capital", bank, capital, 100_000); + s.post_entry(seed).unwrap(); + // Drafted first, posted last, dated first: posting order is not date order. + let late = post(&mut s, d(2026, 1, 3), "Domain renewal", software, bank, 1_800); + let rent_jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000); + s.post_entry(rent_jan).unwrap(); + s.post_entry(late).unwrap(); + let wrong = post(&mut s, d(2026, 1, 12), "Rent paid twice", rent, bank, 50_000); + s.post_entry(wrong).unwrap(); + s.reverse_entry(wrong, d(2026, 1, 14), "Entered twice").unwrap(); + // A document and its payment. + let customer = s.add_customer("Fernhill Labs").unwrap(); + let inv = s + .create_invoice( + customer, + d(2026, 1, 10), + d(2026, 2, 9), + vec![DocumentLine { + description: "Design".into(), + amount: cents(120_000), + class_id: None, + account: None, + quantity: None, + unit_price: None, + }], + ) + .unwrap(); + s.receive_payment(inv, d(2026, 1, 20), cents(40_000), bank).unwrap(); + // A schedule month. + let sched = s + .register_schedule(NewSchedule { + description: "Annual insurance".into(), + kind: ScheduleKind::PrepaidExpense, + total: usd(120_000), + start: d(2026, 1, 1), + months: 12, + balance_account: prepaid, + pl_account: rent, + convention: ScheduleConvention::MonthlyEven, + }) + .unwrap(); + let paid = post(&mut s, d(2026, 1, 2), "Insurance premium", prepaid, bank, 120_000); + s.post_entry(paid).unwrap(); + s.post_schedule_month(sched, d(2026, 1, 31)).unwrap(); + // A recurring entry with one generated draft (February). + s.register_recurring(NewRecurring { + description: "Figma".into(), + lines: vec![(software, Side::Debit, usd(1_500)), (bank, Side::Credit, usd(1_500))], + frequency: Frequency::Monthly, + start: d(2026, 2, 10), + end: None, + }) + .unwrap(); + s.run_recurring(d(2026, 2, 28)).unwrap(); + // Two open drafts dated in February; January closes around them. + let open = post(&mut s, d(2026, 2, 12), "Coworking day pass", rent, bank, 3_500); + let _ = open; + let _empty = s.create_entry(d(2026, 2, 14), "Placeholder").unwrap(); + let jan = s.create_period("2026-01", d(2026, 1, 1), d(2026, 1, 31)).unwrap(); + s.close_period(jan).unwrap(); + let _ = sales; + s.verify().unwrap(); + println!("WROTE {:?}", dir); +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_before/expected.json b/crates/ledger-store/tests/fixtures/old-books/p4_before/expected.json new file mode 100644 index 0000000..987eaa7 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_before/expected.json @@ -0,0 +1,599 @@ +{ + "assets": [], + "bills": [], + "entries": [ + { + "date": "2026-01-01", + "description": "Opening capital", + "id": 1, + "lines": [ + { + "account": 1, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 3, + "amount": { + "amount": "1000.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-03", + "description": "Domain renewal", + "id": 2, + "lines": [ + { + "account": 6, + "amount": { + "amount": "18.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "18.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-05", + "description": "January rent", + "id": 3, + "lines": [ + { + "account": 5, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-12", + "description": "Rent paid twice", + "id": 4, + "lines": [ + { + "account": 5, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-14", + "description": "Entered twice", + "id": 5, + "lines": [ + { + "account": 5, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 1, + "amount": { + "amount": "500.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": 4, + "status": "Posted" + }, + { + "date": "2026-01-10", + "description": "Invoice 1", + "id": 6, + "lines": [ + { + "account": 7, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 4, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-20", + "description": "Payment on invoice 1", + "id": 7, + "lines": [ + { + "account": 7, + "amount": { + "amount": "400.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 1, + "amount": { + "amount": "400.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-02", + "description": "Insurance premium", + "id": 8, + "lines": [ + { + "account": 2, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-01-31", + "description": "Prepaid expense: Annual insurance (2026-01)", + "id": 9, + "lines": [ + { + "account": 5, + "amount": { + "amount": "100.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 2, + "amount": { + "amount": "100.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + }, + { + "date": "2026-02-10", + "description": "Figma", + "id": 10, + "lines": [ + { + "account": 6, + "amount": { + "amount": "15.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "15.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Draft" + }, + { + "date": "2026-02-12", + "description": "Coworking day pass", + "id": 11, + "lines": [ + { + "account": 5, + "amount": { + "amount": "35.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 1, + "amount": { + "amount": "35.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Draft" + }, + { + "date": "2026-02-14", + "description": "Placeholder", + "id": 12, + "lines": [], + "reversal_of": null, + "status": "Draft" + }, + { + "date": "2026-01-31", + "description": "Close period 2026-01", + "id": 13, + "lines": [ + { + "account": 4, + "amount": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Debit" + }, + { + "account": 5, + "amount": { + "amount": "600.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 6, + "amount": { + "amount": "18.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + }, + { + "account": 8, + "amount": { + "amount": "582.00", + "currency": [ + 85, + 83, + 68 + ] + }, + "side": "Credit" + } + ], + "reversal_of": null, + "status": "Posted" + } + ], + "entry_parties": [ + [ + 6, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ], + [ + 7, + [ + { + "party_id": 1, + "share": "derived" + } + ] + ] + ], + "invoices": [ + { + "amount_paid": "400.00", + "customer_id": 1, + "date": "2026-01-10", + "due_date": "2026-02-09", + "entry_id": 6, + "id": 1, + "lines": [ + { + "amount": "1200.00", + "description": "Design" + } + ], + "status": "Partial" + } + ], + "payroll_runs": [], + "periods": [ + { + "close_seq": 1, + "closing_entry": 13, + "end": "2026-01-31", + "id": 1, + "member_close": null, + "name": "2026-01", + "start": "2026-01-01", + "status": "Closed" + } + ], + "recurring": [ + { + "description": "Figma", + "end": null, + "frequency": "monthly", + "generated": [ + "2026-02-10" + ], + "id": 1, + "lines": [ + [ + 6, + "Debit", + { + "amount": "15.00", + "currency": [ + 85, + 83, + 68 + ] + } + ], + [ + 1, + "Credit", + { + "amount": "15.00", + "currency": [ + 85, + 83, + 68 + ] + } + ] + ], + "start": "2026-02-10" + } + ], + "schedules": [ + { + "balance_account": 2, + "description": "Annual insurance", + "id": 1, + "kind": "prepaid_expense", + "months": 12, + "pl_account": 5, + "posted": [ + "2026-01-31" + ], + "start": "2026-01-01", + "total": { + "amount": "1200.00", + "currency": [ + 85, + 83, + 68 + ] + } + } + ], + "snapshot": { + "Ok": { + "balance_sheet": { + "assets": "1582", + "balanced": true, + "current_earnings": "0", + "equity": "1582", + "liabilities": "0" + }, + "profit_and_loss": { + "expenses": "0", + "net_income": "0", + "revenue": "0" + }, + "trial_balance": { + "lines": [ + { + "code": "1000", + "credit": "318", + "debit": "0", + "name": "Bank" + }, + { + "code": "1200", + "credit": "0", + "debit": "800", + "name": "Accounts Receivable" + }, + { + "code": "1300", + "credit": "0", + "debit": "1100", + "name": "Prepaid expenses" + }, + { + "code": "3000", + "credit": "1000", + "debit": "0", + "name": "Capital" + }, + { + "code": "3999", + "credit": "582", + "debit": "0", + "name": "Retained Earnings" + } + ], + "total_credits": "1900", + "total_debits": "1900" + } + } + }, + "verify": { + "Ok": null + } +} diff --git a/crates/ledger-store/tests/fixtures/old-books/p4_before/journal.jsonl b/crates/ledger-store/tests/fixtures/old-books/p4_before/journal.jsonl new file mode 100644 index 0000000..4a51e18 --- /dev/null +++ b/crates/ledger-store/tests/fixtures/old-books/p4_before/journal.jsonl @@ -0,0 +1,52 @@ +{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T18:52:25Z","type":"store.created"} +{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","members_cumulative","month_reposting","party_creditors","payment_instruments","projects","require_tax_category","schedule_convention","tax_categories_gain_exempt","vendor_1099_decisions"],"format":2},"prev":"ca1a2545b353552615320a4e00c3d9a844be226aafef8c150f36286aa9054c3a","seq":2,"ts":"2026-10-29T18:52:25Z","type":"journal.format_set"} +{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"0f90ef6831ad98b59370bc8b6bdcf0518a79bf492955a7201e9a2953c20a5230","seq":3,"ts":"2026-10-29T18:52:25Z","type":"account.added"} +{"payload":{"account_type":"Asset","code":"1300","currency":"USD","id":2,"name":"Prepaid expenses","parent":null,"placeholder":false},"prev":"3d399d1f1a9d8880c935918e93599b4c22a9e826069ca2bc2e61c58e5dec7a33","seq":4,"ts":"2026-10-29T18:52:25Z","type":"account.added"} +{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":3,"name":"Capital","parent":null,"placeholder":false},"prev":"51014db1e5939a6052c38955c1f5f882cfc95a61d5a600af4323756cac6fe90d","seq":5,"ts":"2026-10-29T18:52:25Z","type":"account.added"} +{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":4,"name":"Sales","parent":null,"placeholder":false},"prev":"088bc0a97fff726fd094c1429d1add5f10f9bc2fd34acc90f0b531ff6d2ce4a2","seq":6,"ts":"2026-10-29T18:52:25Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":5,"name":"Rent","parent":null,"placeholder":false},"prev":"804aa97749bca03f0d365aaf3a3f2685103b77b270f5e80716d33a7a2165e8be","seq":7,"ts":"2026-10-29T18:52:25Z","type":"account.added"} +{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":6,"name":"Software","parent":null,"placeholder":false},"prev":"3a685e245cf48a4093e15e796c2af633001dbef7262180b67f1c8d831d48f16a","seq":8,"ts":"2026-10-29T18:52:25Z","type":"account.added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"9449aedbe86967fd9133b8452dc73eb11cb4a4bc518a812727bd504695b00d03","seq":9,"ts":"2026-10-29T18:52:25Z","type":"entry.created"} +{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"fc27bd6075e1cc3b4ae49640c594ce05d05a7e6a579d7636d9ec40dd6ba27ac2","seq":10,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"account":3,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"7364b7b2f0218b81181f5943dc1122bc0e7eb40ce793adf09ed801803f20fd39","seq":11,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-01","description":"Opening capital","id":1,"lines":[{"account":1,"amount":"1000.00","currency":"USD","side":"debit"},{"account":3,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"9a9fdb4c1ad9eb87bb2cff9c006853468ac3e2c400d66d0cb0e4b6ef32f306f0","seq":12,"ts":"2026-10-29T18:52:25Z","type":"entry.posted"} +{"payload":{"date":"2026-01-03","description":"Domain renewal","id":2},"prev":"e31d3eb4d2e500f75126e447198b693ab74faeb8fe95375eb7bd7da3a15c5835","seq":13,"ts":"2026-10-29T18:52:25Z","type":"entry.created"} +{"payload":{"account":6,"amount":"18.00","currency":"USD","id":2,"side":"debit"},"prev":"ff0c990a5c0d76f4feba5daffa4fca11ad50e61b5937cec085295b10e1a318c2","seq":14,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"18.00","currency":"USD","id":2,"side":"credit"},"prev":"4e0e1ae37eed9ff1f5d1ed2cb2048d7bbd36e18125750db7389fa4e251d264c0","seq":15,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-05","description":"January rent","id":3},"prev":"1c7051ba6b15d2c3d4cd042ac4c2f14b0b9b3d6bec69b130cf4c6fcdbba41f6f","seq":16,"ts":"2026-10-29T18:52:25Z","type":"entry.created"} +{"payload":{"account":5,"amount":"500.00","currency":"USD","id":3,"side":"debit"},"prev":"841b1ff16c452c73f44e51f4bdb19feb988c6efc69bb16b63f5451f61d6d3aea","seq":17,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"500.00","currency":"USD","id":3,"side":"credit"},"prev":"1162148134dd5415014e15081850edf1f4eb5563197b9a035e1d5b756e8621fe","seq":18,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-05","description":"January rent","id":3,"lines":[{"account":5,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"53e63c46da026e64bc071fc15bf8d56acdd6e784570d593830e50be20ccc6638","seq":19,"ts":"2026-10-29T18:52:25Z","type":"entry.posted"} +{"payload":{"date":"2026-01-03","description":"Domain renewal","id":2,"lines":[{"account":6,"amount":"18.00","currency":"USD","side":"debit"},{"account":1,"amount":"18.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"fc8da8cc2bda1cbb6849618c16e7deecf0e8b6bde4495a8631157dc56763cf97","seq":20,"ts":"2026-10-29T18:52:25Z","type":"entry.posted"} +{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":4},"prev":"f1f032ad841660ce1e440e35410909057b74e3706f6cdde35c155b1451ca276c","seq":21,"ts":"2026-10-29T18:52:25Z","type":"entry.created"} +{"payload":{"account":5,"amount":"500.00","currency":"USD","id":4,"side":"debit"},"prev":"8a55f6097f5899c708602100788624d2b2848b8ee3460eee4e70c385c7c381ac","seq":22,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"500.00","currency":"USD","id":4,"side":"credit"},"prev":"ad7264185501160ed54e29791a79d034bf3c3bd18d6b03fbdc46077943467509","seq":23,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":4,"lines":[{"account":5,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"06d31261c4a8b67e758c32db49962248ef69bac732b8a2fcd8398c40af4014eb","seq":24,"ts":"2026-10-29T18:52:25Z","type":"entry.posted"} +{"payload":{"date":"2026-01-14","description":"Entered twice","id":5,"lines":[{"account":5,"amount":"500.00","currency":"USD","side":"credit"},{"account":1,"amount":"500.00","currency":"USD","side":"debit"}],"reverses":4},"prev":"134b60dee70f4bb0b495b0473454a4226cbd8dc226822f35edea80b29a56404b","seq":25,"ts":"2026-10-29T18:52:25Z","type":"entry.reversed"} +{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"0ff7c902f8b02337a5657120afb9cc8a014f6ecb9d1ef5e25491008f5b866cf8","seq":26,"ts":"2026-10-29T18:52:25Z","type":"customer.added"} +{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":7,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"0813351327020a32e7868f6ded661cea4054b1667127de7cc2546c377eef9552","seq":27,"ts":"2026-10-29T18:52:25Z","type":"account.added"} +{"payload":{"date":"2026-01-10","description":"Invoice 1","id":6},"prev":"bd6a41e1e84aed64069dba7e2352a8a4c76879049c17b6db3f45d11299f8bcba","seq":28,"ts":"2026-10-29T18:52:25Z","type":"entry.created"} +{"payload":{"account":7,"amount":"1200.00","currency":"USD","id":6,"side":"debit"},"prev":"0036f8a11b1ade230af57558fa60816a68720f43c12c5402b29a7c3fcce2bab8","seq":29,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"account":4,"amount":"1200.00","currency":"USD","id":6,"side":"credit"},"prev":"269758ab57aa48d8b0118899e6fb7ff9a9d9ef8475b6c6b0b5c4361a03ca8f79","seq":30,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-10","description":"Invoice 1","id":6,"lines":[{"account":7,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0b8e1803981bfca51fadc8fb42c63af4b4ba6340e24379c93322f2068381e704","seq":31,"ts":"2026-10-29T18:52:25Z","type":"entry.posted"} +{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":6,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"5de5d98ab8e3d39248dafca54f1dc9aaf5c800e0dff6d489717331249d8868e3","seq":32,"ts":"2026-10-29T18:52:25Z","type":"invoice.created"} +{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":7},"prev":"6accfe7a188b03c2c8dfb528be8ea904c023f9bafc7d42b29b9e4a667a364063","seq":33,"ts":"2026-10-29T18:52:25Z","type":"entry.created"} +{"payload":{"account":7,"amount":"400.00","currency":"USD","id":7,"side":"credit"},"prev":"fcddbb8c0076d41480c5e26c211bc8094afe87db10935b6cec8e080a8a8b1c29","seq":34,"ts":"2026-10-29T18:52:25Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"400.00","currency":"USD","id":7,"side":"debit"},"prev":"9ae9b7df3f932d9ccda1d5365ea6527905236a8a86be04152f6966f0afc88197","seq":35,"ts":"2026-10-29T18:52:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":7,"lines":[{"account":7,"amount":"400.00","currency":"USD","side":"credit"},{"account":1,"amount":"400.00","currency":"USD","side":"debit"}],"reversal_of":null},"prev":"2b726496d9227cd3d465b80ef849b0b86dc5126474db5b53fc867d71d7f20912","seq":36,"ts":"2026-10-29T18:52:26Z","type":"entry.posted"} +{"payload":{"amount":"400.00","cash_account":1,"date":"2026-01-20","entry_id":7,"invoice_id":1},"prev":"3411b5a4f24686054bb04169c49acc3cd84e0751184c652a19a10504f84de963","seq":37,"ts":"2026-10-29T18:52:26Z","type":"invoice.payment_received"} +{"payload":{"balance_account":2,"convention":"monthly_even","description":"Annual insurance","id":1,"kind":"prepaid_expense","months":12,"pl_account":5,"start":"2026-01-01","total":{"amount":"1200.00","currency":"USD"}},"prev":"ab496ad04d3ee39826f86fdbc8dec256d55d3371940dd8d6e55fb0a6da7104d7","seq":38,"ts":"2026-10-29T18:52:26Z","type":"schedule.registered"} +{"payload":{"date":"2026-01-02","description":"Insurance premium","id":8},"prev":"251e429ea0265c712a6c52763f434ff7f3cd3899c67b5121c7a080d92026c8bf","seq":39,"ts":"2026-10-29T18:52:26Z","type":"entry.created"} +{"payload":{"account":2,"amount":"1200.00","currency":"USD","id":8,"side":"debit"},"prev":"1cc2e261fa3f5958d710d969cb303b3f819de4595a56b66c870844a87537b342","seq":40,"ts":"2026-10-29T18:52:26Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"1200.00","currency":"USD","id":8,"side":"credit"},"prev":"a564e19c9d4461cb6ec44a305bf24cd55f8b11e5a31361ae04efbb04d1e4d6af","seq":41,"ts":"2026-10-29T18:52:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-01-02","description":"Insurance premium","id":8,"lines":[{"account":2,"amount":"1200.00","currency":"USD","side":"debit"},{"account":1,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"0ac80761ca87f13347a692e008cb6c6ea162fd497cbd6a2ac3ac9bebc65c9cc7","seq":42,"ts":"2026-10-29T18:52:26Z","type":"entry.posted"} +{"payload":{"entry":{"date":"2026-01-31","description":"Prepaid expense: Annual insurance (2026-01)","id":9,"lines":[{"account":5,"amount":"100.00","currency":"USD","side":"debit"},{"account":2,"amount":"100.00","currency":"USD","side":"credit"}]},"month":"2026-01-31","schedule_id":1},"prev":"a16218a587e7c65ba7cbcff021682970aaa74615aa7d9a63816e9fb8302aeb12","seq":43,"ts":"2026-10-29T18:52:26Z","type":"schedule.posted"} +{"payload":{"description":"Figma","end":null,"frequency":"monthly","id":1,"lines":[{"account":6,"amount":"15.00","currency":"USD","side":"debit"},{"account":1,"amount":"15.00","currency":"USD","side":"credit"}],"start":"2026-02-10"},"prev":"d8e74296193043da842aaf460322c77a53948c282783a890534391d8e5f75353","seq":44,"ts":"2026-10-29T18:52:26Z","type":"recurring.registered"} +{"payload":{"date":"2026-02-10","entry_id":10,"recurring_id":1},"prev":"b093624d33665766fcc7f5b092505defea3480bf5252382ff411c8bd61f6e5a7","seq":45,"ts":"2026-10-29T18:52:26Z","type":"recurring.generated"} +{"payload":{"date":"2026-02-12","description":"Coworking day pass","id":11},"prev":"53f8ad448d05727f9a24938fb9782bfc452d42e55de9987d883dadde86f99f38","seq":46,"ts":"2026-10-29T18:52:26Z","type":"entry.created"} +{"payload":{"account":5,"amount":"35.00","currency":"USD","id":11,"side":"debit"},"prev":"f7bc43ea837a316981b97be04f61b7901cf34105905e0cb5a78132d65b794b70","seq":47,"ts":"2026-10-29T18:52:26Z","type":"entry.line_added"} +{"payload":{"account":1,"amount":"35.00","currency":"USD","id":11,"side":"credit"},"prev":"cde7cfa40a66d4ff68e1b61dc6e1cdc5dee175c96c6ef02e3a6b0cfe86d05a4e","seq":48,"ts":"2026-10-29T18:52:26Z","type":"entry.line_added"} +{"payload":{"date":"2026-02-14","description":"Placeholder","id":12},"prev":"1f4e4348a61307f98b998ba1ff5ceca9b1e2173607acd1196c7769d45f9b0a23","seq":49,"ts":"2026-10-29T18:52:26Z","type":"entry.created"} +{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"3961bcafda6401e136f4f18f2b98f0910a075660789a0a8b452f20efae76e86d","seq":50,"ts":"2026-10-29T18:52:26Z","type":"period.created"} +{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":8,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"e9fbe28b14c3c388d82405c0e5f0f12ef2f01c5a15ab4d8fc5a0650272f9784e","seq":51,"ts":"2026-10-29T18:52:26Z","type":"account.added"} +{"payload":{"close_to":"retained_earnings","closing_entry":13,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":13,"lines":[{"account":4,"amount":"1200.00","currency":"USD","side":"debit"},{"account":5,"amount":"600.00","currency":"USD","side":"credit"},{"account":6,"amount":"18.00","currency":"USD","side":"credit"},{"account":8,"amount":"582.00","currency":"USD","side":"credit"}]},"id":1},"prev":"e6619d0b6a4f98cffae21be9da2326b0ed5bb6fde0691710c9ae28cb6863220d","seq":52,"ts":"2026-10-29T18:52:26Z","type":"period.closed"} diff --git a/crates/ledger-store/tests/format.rs b/crates/ledger-store/tests/format.rs index d0abea8..9ae33fe 100644 --- a/crates/ledger-store/tests/format.rs +++ b/crates/ledger-store/tests/format.rs @@ -18,7 +18,7 @@ use chrono::NaiveDate; use ledger_core::arap::{DocumentLine, SendKind, Sent, SentVia}; use ledger_core::{AccountType, Currency, Money, Side}; -use ledger_store::format::{DOCUMENTS_V2, EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES}; +use ledger_store::format::{DOCUMENTS_V2, DRAFTS_V2, EVENT_TYPES, FORMAT_SET, KNOWN_FEATURES}; use ledger_store::journal::{canonical_json, sha256_hex}; use ledger_store::{ JOURNAL_FILE, JournalFormat, JournalWriter, SUPPORTED_JOURNAL_FORMAT, Snapshot, Store, @@ -1586,3 +1586,68 @@ fn an_unknown_convention_kind_is_newer_for_the_writer_and_the_viewer_alike() { (Err(e), Ok(_)) => panic!("the viewer loads books the writer refuses: {e}"), } } + +/// Done-when (plan P4): the two draft events are this version's, they need +/// `drafts_v2`, and the books declare that feature before the first such +/// line, so a version without it stops at the declaration and reports the +/// books as newer, never corrupt, never keeping a discarded draft that +/// would block a close (rules.md B-27, B-43). +#[test] +fn an_older_version_stops_at_the_draft_events() { + assert!(EVENT_TYPES.contains(&"entry.draft_state_set")); + assert!(EVENT_TYPES.contains(&"entry.draft_discarded")); + let older = JournalFormat { + format: SUPPORTED_JOURNAL_FORMAT, + features: KNOWN_FEATURES + .iter() + .copied() + .filter(|f| *f != DRAFTS_V2) + .map(str::to_string) + .collect(), + }; + assert!( + matches!( + older.require_feature(DRAFTS_V2), + Err(StoreError::FormatUpgradePending { .. }) + ), + "a reader without drafts_v2 waits for it" + ); + let dir = test_dir("drafts-gate"); + let mut store = + Store::create_in_format(&dir, Currency::USD, &JournalFormat::default()).unwrap(); + let date = NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(); + let draft = store.create_entry(date, "Waiting").unwrap(); + store.upgrade_format().unwrap().expect("the go-ahead"); + store + .set_draft_state( + draft, + ledger_core::DraftState::AwaitingEvidence { + note: "receipt".into(), + }, + ) + .unwrap(); + store.discard_draft(draft, "not needed").unwrap(); + let journal = std::fs::read_to_string(dir.join(JOURNAL_FILE)).unwrap(); + let mut declared = false; + let mut seen = 0; + for line in journal.lines() { + let v: serde_json::Value = serde_json::from_str(line).unwrap(); + if v["type"] == FORMAT_SET + && v["payload"]["features"] + .as_array() + .is_some_and(|f| f.iter().any(|x| x == DRAFTS_V2)) + { + declared = true; + } + if v["type"] == "entry.draft_state_set" || v["type"] == "entry.draft_discarded" { + assert!( + declared, + "drafts_v2 must be declared before the draft events" + ); + seen += 1; + } + } + assert_eq!(seen, 2); + drop(store); + Store::open(&dir).unwrap().verify().unwrap(); +} diff --git a/crates/ledger-store/tests/store.rs b/crates/ledger-store/tests/store.rs index 20e2bff..275a4a7 100644 --- a/crates/ledger-store/tests/store.rs +++ b/crates/ledger-store/tests/store.rs @@ -2120,6 +2120,26 @@ fn books_written_under_older_rules_still_open() { .unwrap(); w.append("entry.settles", json!({"entry_id": 7, "settles": [1]})) .unwrap(); + // P4's new shapes, hand-written as this version writes them: a draft + // awaiting its evidence, and a draft thrown away. This reader takes + // them; the version before it stopped at the declaration. + for (id, what) in [(8, "Waiting on a receipt"), (9, "Entered twice")] { + w.append( + "entry.created", + json!({"id": id, "date": "2026-05-01", "description": what}), + ) + .unwrap(); + } + w.append( + "entry.draft_state_set", + json!({"id": 8, "state": "awaiting_evidence", "note": "invoice due"}), + ) + .unwrap(); + w.append( + "entry.draft_discarded", + json!({"id": 9, "reason": "entered twice"}), + ) + .unwrap(); drop(w); // Opening rebuilds from the journal; it must accept all of it. let _ = std::fs::remove_file(dir.join(ledger_store::db_file())); @@ -2247,6 +2267,14 @@ fn books_written_under_older_rules_still_open() { store.ledger().line_doc(6, 1).map(|t| t.movement), Some(ledger_core::Movement::WriteOff) ); + // P4's draft state and discard replay as written, and move no figure. + assert!( + store.ledger().get_entry(9).is_none(), + "the discarded draft is gone" + ); + let waiting = store.ledger().get_entry(8).expect("the waiting draft"); + assert_eq!(waiting.draft_state().key(), "awaiting_evidence"); + assert_eq!(waiting.draft_state().note(), "invoice due"); } /// The books an earlier wanderbooks wrote, one directory each under @@ -4172,3 +4200,285 @@ fn a_new_accounts_name_is_journaled_trimmed() { .unwrap(); assert_eq!(name, "Hosting"); } + +// --------------------------------------------------------------------------- +// Draft states, discards and the posting line (plan P4, `drafts_v2`). +// --------------------------------------------------------------------------- + +fn jan(day: u32) -> NaiveDate { + NaiveDate::from_ymd_opt(2026, 1, day).unwrap() +} + +/// A books dir with two accounts and one posted entry, in the current +/// format (new books declare every feature). +fn books_for_drafts(dir: &std::path::Path) -> (Store, u64, u64, u64) { + let mut s = Store::create(dir, Currency::USD).unwrap(); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let equity = s + .add_account("3000", "Equity", AccountType::Equity, None, false) + .unwrap(); + let rent = s + .add_account("6000", "Rent", AccountType::Expense, None, false) + .unwrap(); + let seed = s.create_entry(jan(2), "Seed").unwrap(); + s.add_line(seed, cash, Side::Debit, usd(500)).unwrap(); + s.add_line(seed, equity, Side::Credit, usd(500)).unwrap(); + s.post_entry(seed).unwrap(); + (s, cash, rent, seed) +} + +fn lines_of(dir: &std::path::Path) -> Vec { + std::fs::read_to_string(dir.join(ledger_store::JOURNAL_FILE)) + .unwrap() + .lines() + .map(|l| serde_json::from_str(l).unwrap()) + .collect() +} + +/// R-B (plan P4): a draft's state journals `entry.draft_state_set`, replays +/// into the draft, and projects into `draft_states` (a row only while it +/// awaits evidence, gone when it posts or is discarded); a discard +/// journals `entry.draft_discarded` with its reason and replays into a +/// ledger without the draft. Live, replay and the projection agree. +#[test] +fn draft_states_replay_and_project() { + let dir = test_dir("draft-states"); + let (mut s, cash, rent, _) = books_for_drafts(&dir); + let waiting = s.create_entry(jan(10), "Figma annual plan").unwrap(); + s.add_line(waiting, rent, Side::Debit, usd(40)).unwrap(); + s.add_line(waiting, cash, Side::Credit, usd(40)).unwrap(); + let doomed = s.create_entry(jan(11), "Duplicate").unwrap(); + s.add_line(doomed, rent, Side::Debit, usd(5)).unwrap(); + s.add_line(doomed, cash, Side::Credit, usd(5)).unwrap(); + let from = lines_of(&dir).len(); + s.set_draft_state( + waiting, + ledger_core::DraftState::AwaitingEvidence { + note: "invoice due from Figma".into(), + }, + ) + .unwrap(); + s.set_draft_state(doomed, ledger_core::DraftState::Building) + .unwrap(); + s.discard_draft(doomed, "entered twice").unwrap(); + let got: Vec<_> = lines_of(&dir).into_iter().skip(from).collect(); + assert_eq!(got[0]["type"], "entry.draft_state_set"); + assert_eq!(got[0]["payload"]["id"], waiting); + assert_eq!(got[0]["payload"]["state"], "awaiting_evidence"); + assert_eq!(got[0]["payload"]["note"], "invoice due from Figma"); + assert_eq!(got[1]["payload"]["state"], "building"); + assert!(got[1]["payload"].get("note").is_none(), "absent, not empty"); + assert_eq!(got[2]["type"], "entry.draft_discarded"); + assert_eq!(got[2]["payload"]["reason"], "entered twice"); + s.verify().unwrap(); + let rows = |dir: &std::path::Path| -> Vec<(i64, String, Option)> { + let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap(); + let mut q = conn + .prepare("SELECT entry_id, state, note FROM draft_states ORDER BY entry_id") + .unwrap(); + q.query_map([], |r| Ok((r.get(0)?, r.get(1)?, r.get(2)?))) + .unwrap() + .map(|r| r.unwrap()) + .collect() + }; + assert_eq!( + rows(&dir), + vec![( + waiting as i64, + "awaiting_evidence".to_string(), + Some("invoice due from Figma".to_string()) + )] + ); + // The live ledger, and one rebuilt from the journal alone, agree. + let live = s.ledger().clone(); + assert!( + live.get_entry(doomed).is_none(), + "the discarded draft is gone" + ); + assert_eq!( + live.get_entry(waiting).unwrap().draft_state().key(), + "awaiting_evidence" + ); + drop(s); + let mut reopened = Store::open(&dir).unwrap(); + reopened.verify().unwrap(); + assert!( + live.differs_from(reopened.ledger()).is_empty(), + "{:?}", + live.differs_from(reopened.ledger()) + ); + // Posting the waiting draft clears its row and its state. + reopened.post_entry(waiting).unwrap(); + assert_eq!( + reopened + .ledger() + .get_entry(waiting) + .unwrap() + .draft_state() + .key(), + "building" + ); + reopened.verify().unwrap(); + drop(reopened); + assert!(rows(&dir).is_empty()); + // The refusals: a posted entry, an unknown one, an empty reason, a TIN. + let mut s = Store::open(&dir).unwrap(); + let head = s.head(); + assert!(s.discard_draft(waiting, "oops").is_err()); + assert!(s.discard_draft(9999, "oops").is_err()); + let again = s.create_entry(jan(12), "Another").unwrap(); + let head = { + let _ = head; + s.head() + }; + assert!(s.discard_draft(again, " ").is_err()); + assert!(s.discard_draft(again, "ssn 123-45-6789").is_err()); + assert!( + s.set_draft_state( + again, + ledger_core::DraftState::AwaitingEvidence { + note: "ein 12-3456789 pending".into() + } + ) + .is_err() + ); + assert!( + s.set_draft_state(waiting, ledger_core::DraftState::Building) + .is_err() + ); + assert_eq!(s.head(), head, "a refusal journals nothing"); +} + +/// R-A step 3 (plan P4): the draft tools are `drafts_v2`'s, so books that +/// haven't declared it refuse them, journaling nothing; after the owner's +/// go-ahead they journal, and the reopened books hold them. +#[test] +fn drafts_v2_waits_for_the_upgrade() { + let dir = test_dir("drafts-wait"); + let mut s = + Store::create_in_format(&dir, Currency::USD, &ledger_store::JournalFormat::default()) + .unwrap(); + let cash = s + .add_account("1000", "Cash", AccountType::Asset, None, false) + .unwrap(); + let rent = s + .add_account("6000", "Rent", AccountType::Expense, None, false) + .unwrap(); + let d = s.create_entry(jan(10), "Figma").unwrap(); + s.add_line(d, rent, Side::Debit, usd(40)).unwrap(); + s.add_line(d, cash, Side::Credit, usd(40)).unwrap(); + let head = s.head(); + let len = lines_of(&dir).len(); + for refused in [ + s.set_draft_state( + d, + ledger_core::DraftState::AwaitingEvidence { note: "x".into() }, + ), + s.discard_draft(d, "no longer needed"), + ] { + match refused { + Err(StoreError::FormatUpgradePending { need }) => { + assert_eq!(need, "the journal feature drafts_v2") + } + other => panic!("expected FormatUpgradePending, got {other:?}"), + } + } + assert_eq!(s.head(), head, "a refusal journals nothing"); + assert_eq!(lines_of(&dir).len(), len); + // Posting a draft needs nothing new: it is the old shape. + assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead"); + s.set_draft_state( + d, + ledger_core::DraftState::AwaitingEvidence { note: "x".into() }, + ) + .unwrap(); + s.discard_draft(d, "no longer needed").unwrap(); + let live = s.ledger().clone(); + drop(s); + let reopened = Store::open(&dir).unwrap(); + reopened.verify().unwrap(); + assert!(live.differs_from(reopened.ledger()).is_empty()); + assert!(reopened.ledger().get_entry(d).is_none()); +} + +/// Plan P4: `posted_seq` is the journal line that posted the entry: +/// `entry.posted` (hand entries and the ones commands make), `entry.reversed`, +/// the closing entry's `period.closed`. A backdated entry has the line it +/// was posted at, not the line its date would suggest; replay and the live +/// books agree; the projection's `entries.posted_seq` is the same number. +#[test] +fn posted_seq_is_the_posting_lines_seq() { + let dir = test_dir("posted-seq"); + let (mut s, cash, rent, seed) = books_for_drafts(&dir); + // Drafted first, posted later, and dated before the seed. + let backdated = s.create_entry(jan(1), "Backdated").unwrap(); + s.add_line(backdated, rent, Side::Debit, usd(7)).unwrap(); + s.add_line(backdated, cash, Side::Credit, usd(7)).unwrap(); + let other = s.create_entry(jan(20), "Later date, earlier post").unwrap(); + s.add_line(other, rent, Side::Debit, usd(3)).unwrap(); + s.add_line(other, cash, Side::Credit, usd(3)).unwrap(); + s.post_entry(other).unwrap(); + s.post_entry(backdated).unwrap(); + let reversal = s.reverse_entry(other, jan(21), "Undo").unwrap(); + let jan_p = s + .create_period( + "2026-01", + jan(1), + NaiveDate::from_ymd_opt(2026, 1, 31).unwrap(), + ) + .unwrap(); + let closing = s.close_period(jan_p).unwrap().expect("income to close"); + // The journal line that carries each posting. + let lines = lines_of(&dir); + let seq_of = |ty: &str, id: u64| -> u64 { + lines + .iter() + .find(|l| l["type"] == ty && l["payload"]["id"] == id) + .unwrap_or_else(|| panic!("no {ty} for {id}"))["seq"] + .as_u64() + .unwrap() + }; + let closing_seq = lines.iter().find(|l| l["type"] == "period.closed").unwrap()["seq"] + .as_u64() + .unwrap(); + let want = [ + (seed, seq_of("entry.posted", seed)), + (other, seq_of("entry.posted", other)), + (backdated, seq_of("entry.posted", backdated)), + (reversal, seq_of("entry.reversed", reversal)), + (closing, closing_seq), + ]; + for (id, seq) in want { + assert_eq!(s.ledger().posted_seq(id), Some(seq), "live: entry {id}"); + } + assert!( + s.ledger().posted_seq(backdated) > s.ledger().posted_seq(other), + "posted later though dated earlier" + ); + s.verify().unwrap(); + let live = s.ledger().clone(); + drop(s); + let reopened = Store::open(&dir).unwrap(); + reopened.verify().unwrap(); + for (id, seq) in want { + assert_eq!( + reopened.ledger().posted_seq(id), + Some(seq), + "replay: entry {id}" + ); + } + assert!(live.differs_from(reopened.ledger()).is_empty()); + let conn = rusqlite::Connection::open(dir.join(ledger_store::db_file())).unwrap(); + for (id, seq) in want { + let got: i64 = conn + .query_row( + "SELECT posted_seq FROM entries WHERE id = ?", + [id as i64], + |r| r.get(0), + ) + .unwrap(); + assert_eq!(got as u64, seq, "projection: entry {id}"); + } +} diff --git a/docs/receipt-store.md b/docs/receipt-store.md index 07e9cfa..965d87b 100644 --- a/docs/receipt-store.md +++ b/docs/receipt-store.md @@ -61,7 +61,11 @@ apply is safe — on startup the applier replays every journal line with never a ledger line or a figure; P4b S5), `invoice.written_off` (P4b S7), `receipt.attached`, `receipt.linked`, `receipt.unlinked`, `document.filed`, `attachment.withdrawn`, `attachment.text_extracted`, - `attachment.annotated`, `entry.settles` (P6) (plus the A/R, A/P, asset, + `attachment.annotated`, `entry.settles` (P6), `entry.draft_state_set` + (a draft building or awaiting its evidence: a note on the draft, no + figure) and `entry.draft_discarded` (a draft thrown away, with why; the + draft's earlier lines stay in the journal) (P4, feature `drafts_v2`) + (plus the A/R, A/P, asset, period, payroll, and class events listed on `JournalLine`). - `payload`: the full domain facts (account fields, entry with lines, blob sha, ...). Everything the applier needs; nothing it must look up elsewhere. diff --git a/docs/spec/rules.md b/docs/spec/rules.md index 4e279d2..395afed 100644 --- a/docs/spec/rules.md +++ b/docs/spec/rules.md @@ -1757,7 +1757,11 @@ event type would be refused. P6's is `attachment_text` (an `attachment.text_extracted`, `attachment.annotated` or `entry.settles`, a new event type, and an `attachment.withdrawn` carrying `duplicate_of`; an older version would refuse the types, or withdraw a duplicate without -saying what it duplicates, B-44). A command asks for +saying what it duplicates, B-44). P4's is `drafts_v2` (an +`entry.draft_state_set` or `entry.draft_discarded`, both new event types: +an older version would refuse them, or, reading around them, keep a +discarded draft that holds a period open and call a waiting draft one +being built, B-43). A command asks for the feature it needs (`Store::ensure_feature`) before its first journaled line, and only when the line it is about to write uses it, so a write refused for waiting on the upgrade journals nothing, never an entry diff --git a/fuzz/fuzz_targets/fuzz_store_replay.rs b/fuzz/fuzz_targets/fuzz_store_replay.rs index 97f933f..0f2c1e6 100644 --- a/fuzz/fuzz_targets/fuzz_store_replay.rs +++ b/fuzz/fuzz_targets/fuzz_store_replay.rs @@ -44,6 +44,7 @@ const TYPES: &[&str] = &[ "party.w4_set", "party.w9_set", "payroll.taxes_remitted", "reconciliation.completed", "reconciliation.voided", "role.assigned", "statement.recorded", "statement.voided", "project.set", "entry.settles", + "entry.draft_state_set", "entry.draft_discarded", // Merged 2026-10-28 (S7, P6): every type EVENT_TYPES names. ]; @@ -65,7 +66,7 @@ const FIELDS: &[&str] = &[ "rule", "contributor_adjusted_basis", "fmv_at_contribution", "sec704c_method", "tax_basis_723", "depreciable_basis", "built_in_gain_704c", "on", "convention", "status", "party_id", "started_on", "ended_on", "links", "notes", "projects", - "via", "by", "to", "reference", "note", + "via", "by", "to", "reference", "note", "state", ]; /// Journal features a `journal.format_set` may declare.