feat(store): draft states and discards are journaled, posted_seq is kept (P4)
Accounting reason: a draft that waits on its receipt, and one thrown
away, are facts about the books' working papers that must survive a
restart and replay identically, or a discarded draft would come back and
hold its period open. entry.draft_state_set and entry.draft_discarded are
drafts_v2's (an older version stops at the declaration instead of
misreading them); no figure moves. Schema 19 (taken at merge as the next
after the current last): entries.posted_seq, and a draft_states table
(drafts are not rows of entries, so the plan's entries.draft_state column
has nowhere to live). posted_seq is the journal line that posted each
entry (entry.posted, entry.reversed, the line that closed a period or
posted a schedule month), identical live and on replay.
8.0.6: a hand-written line of each new shape in
books_written_under_older_rules_still_open; fixture old-books/p4_before
written by 25b52580 (hand entries posted late, a reversal, a document, a
schedule month, a recurring draft, open drafts, a close) opens to the
same figures (serialized entries omit a building state, so no golden
moved); drafts_v2_is_its_own_feature; the fuzzer and GUI journal know the
types.
Tests: draft_states_replay_and_project, drafts_v2_waits_for_the_upgrade,
posted_seq_is_the_posting_lines_seq, an_older_version_stops_at_the_draft_events.
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
be8bcf340e
commit
28d46332e4
18 files changed
+1455
-24
No files matched your search
@@ -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<EntryId>,
|
||||
/// 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,
|
||||
}
|
||||
|
||||
|
||||
@@ -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<EntryId, u64>,
|
||||
/// 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<EntryId, u64>,
|
||||
/// Entries posted since the last [`Self::stamp_unstamped`], whose
|
||||
/// posting line is the one being applied. Not state of its own.
|
||||
unstamped: Vec<EntryId>,
|
||||
}
|
||||
|
||||
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.
|
||||
|
||||
@@ -426,6 +426,29 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
},
|
||||
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<String> = p
|
||||
.get("settles")
|
||||
|
||||
@@ -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.
|
||||
@@ -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]
|
||||
|
||||
@@ -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<u64>), 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::*;
|
||||
|
||||
@@ -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
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -1261,7 +1261,15 @@ impl Store {
|
||||
line_type: &str,
|
||||
payload: Value,
|
||||
) -> Result<JournalLine, StoreError> {
|
||||
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<CloseT
|
||||
/// Record a posted entry that rides inside another event's payload (a
|
||||
/// closing entry, a schedule posting) in the projection, like any posted
|
||||
/// entry.
|
||||
fn project_posted_entry(conn: &Connection, e: &Value) -> 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 = ?",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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"}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<serde_json::Value> {
|
||||
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<String>)> {
|
||||
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}");
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user