feat(store): a record's source can be corrected without touching its words (P5)
Accounting reason: a convention, a decision or an open item carries who
decided it, when and on what authority. When that is recorded wrongly the
honest fix is to say so on the record, not to restate the policy: a policy
revision bumps the convention's revision and reads as a change of the rule,
and a new decision supersedes the old one. A source correction changes only
the authority and says why, so the audit trail shows the words were never
altered.
New event types convention.source_revised, decision.source_revised and
open_item.source_revised {id, source, reason} under the journal feature
source_revisions: an older version would refuse the types, or show the old
source as the record's authority, so it stops at the declaration instead
(rules.md B-27) and books that have not had the owner's upgrade refuse the
write with format_upgrade_pending, journaling nothing. Replay sets the
source and nothing else (the text, the revision and the journal line stay);
the projection updates the rows' existing source columns, so no schema
changed. A correction must be one: other words, the same source, no reason
or an unknown record is refused.
books.set_convention takes reason (the text exactly as it stands),
books.record_decision and books.raise_item take revises with reason. Without
reason the old refusal stands and now says how to correct only the source.
8.0.6: the hand-written lines of the three shapes in
books_written_under_older_rules_still_open; fixture old-books/p5_before,
written by b97234ac (a revised convention, two decisions, two open items, a
draft) through old-books-writer/src/bin/p5_before.rs, opens to that
version's figures, so no figure moved; books_p2_served_cut_over_whole
refuses the correction before consent and takes it after; the fuzzer and the
viewer's journal know the types. The live books refuse the new write until
the owner gives the go-ahead; merging does not need it.
Tests: a_source_only_revision_is_journaled,
a_source_revision_must_change_only_the_source,
source_revisions_wait_for_the_upgrade, source_revisions_is_its_own_feature.
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
cf73d0bda5
commit
4236f03744
19 files changed
+1188
-13
No files matched your search
@@ -812,8 +812,24 @@ fn books_p2_served_cut_over_whole() {
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
// An open item P2 reads: the correction of its source is
|
||||
// source_revisions' (P5), and waits.
|
||||
let item = a
|
||||
.call(
|
||||
"books.raise_item",
|
||||
json!({"title": "Ask the CPA about the vehicle", "decided_by": "Avery Quill"}),
|
||||
)
|
||||
.unwrap()["id"]
|
||||
.clone();
|
||||
let waiting = journal(&dir);
|
||||
let refused: Vec<(&str, Value)> = vec![
|
||||
// A source correction (source_revisions, P5): an older version
|
||||
// refuses the type.
|
||||
(
|
||||
"books.raise_item",
|
||||
json!({"revises": item, "reason": "it was the CPA who raised it",
|
||||
"decided_by": "Pat Smith"}),
|
||||
),
|
||||
// A draft's state and its discard (drafts_v2, P4): an older
|
||||
// version refuses the types.
|
||||
(
|
||||
|
||||
@@ -277,6 +277,30 @@ pub struct NewDecision {
|
||||
pub projects: Vec<crate::ClassId>,
|
||||
}
|
||||
|
||||
/// Where a decision comes from: who decided, when, and what it rests on.
|
||||
/// A correction of these three is journaled on its own
|
||||
/// (`decision.source_revised`), the decision itself unchanged.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct DecisionSource {
|
||||
/// Who decided (names), at least one.
|
||||
pub decided_by: Vec<String>,
|
||||
/// When.
|
||||
pub decided_on: NaiveDate,
|
||||
/// The rule or document it rests on, if any.
|
||||
pub citation: Option<String>,
|
||||
}
|
||||
|
||||
impl NewDecision {
|
||||
/// The decision's source.
|
||||
pub fn source(&self) -> DecisionSource {
|
||||
DecisionSource {
|
||||
decided_by: self.decided_by.clone(),
|
||||
decided_on: self.decided_on,
|
||||
citation: self.citation.clone(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A decision as the journal holds it.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Decision {
|
||||
@@ -645,6 +669,39 @@ impl Conventions {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct a convention's source as journaled (`convention.source_revised`):
|
||||
/// who decided it, when and on what; its text, revision and line stay.
|
||||
pub fn apply_convention_source(&mut self, id: &str, source: Source) -> Result<(), String> {
|
||||
let c = self
|
||||
.conventions
|
||||
.get_mut(id)
|
||||
.ok_or_else(|| format!("unknown convention {id:?}"))?;
|
||||
c.source = source;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct a decision's source as journaled (`decision.source_revised`).
|
||||
pub fn apply_decision_source(&mut self, id: u64, source: DecisionSource) -> Result<(), String> {
|
||||
let d = self
|
||||
.decisions
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| format!("unknown decision {id}"))?;
|
||||
d.new.decided_by = source.decided_by;
|
||||
d.new.decided_on = source.decided_on;
|
||||
d.new.citation = source.citation;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct an open item's source as journaled (`open_item.source_revised`).
|
||||
pub fn apply_open_item_source(&mut self, id: u64, source: Source) -> Result<(), String> {
|
||||
let i = self
|
||||
.open_items
|
||||
.get_mut(&id)
|
||||
.ok_or_else(|| format!("unknown open item {id}"))?;
|
||||
i.new.source = source;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record an approval as journaled.
|
||||
pub fn apply_approval(&mut self, entry: EntryId, a: Approval) {
|
||||
self.approvals.entry(entry).or_default().push(a);
|
||||
@@ -800,6 +857,28 @@ pub fn check_decision(d: &NewDecision) -> Result<NewDecision, String> {
|
||||
})
|
||||
}
|
||||
|
||||
/// Check a decision's corrected source (the same rules as a new
|
||||
/// decision's own).
|
||||
pub fn check_decision_source(s: &DecisionSource) -> Result<DecisionSource, String> {
|
||||
if s.decided_by.is_empty() {
|
||||
return Err("decided_by names at least one person".into());
|
||||
}
|
||||
crate::calendar::check(s.decided_on).map_err(|e| e.to_string())?;
|
||||
Ok(DecisionSource {
|
||||
decided_by: s
|
||||
.decided_by
|
||||
.iter()
|
||||
.map(|n| line_text("decided_by", n, MAX_TEXT))
|
||||
.collect::<Result<_, _>>()?,
|
||||
decided_on: s.decided_on,
|
||||
citation: s
|
||||
.citation
|
||||
.as_deref()
|
||||
.map(|c| long_text("citation", c, MAX_LONG_TEXT))
|
||||
.transpose()?,
|
||||
})
|
||||
}
|
||||
|
||||
/// Check a new open item's text (INVARIANT 6).
|
||||
pub fn check_open_item(i: &NewOpenItem) -> Result<NewOpenItem, String> {
|
||||
i.source.check()?;
|
||||
|
||||
@@ -8753,6 +8753,135 @@ impl Ledger {
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check a correction of a convention's source before it is journaled
|
||||
/// (P5; `convention.source_revised`): the convention is live, `c` says
|
||||
/// exactly what it says now (title, text, structure; a change of any is
|
||||
/// a revision, not a correction) and a source that differs from the
|
||||
/// one it has. Returns the source as it will be journaled, with the
|
||||
/// reason; changes nothing.
|
||||
pub fn check_convention_source(
|
||||
&self,
|
||||
c: &crate::conventions::NewConvention,
|
||||
reason: &str,
|
||||
) -> Result<(crate::settings::Source, String), LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
let c = crate::conventions::check_new(c).map_err(bad)?;
|
||||
let old = self.conventions.convention(&c.id).ok_or_else(|| {
|
||||
bad(format!(
|
||||
"unknown convention {:?}: books.conventions lists them",
|
||||
c.id
|
||||
))
|
||||
})?;
|
||||
if old.retired.is_some() {
|
||||
return Err(bad(format!("convention {} is retired", c.id)));
|
||||
}
|
||||
if old.kind != c.kind {
|
||||
return Err(bad(format!(
|
||||
"convention {} is a {}; its kind can't change",
|
||||
c.id,
|
||||
old.kind.key()
|
||||
)));
|
||||
}
|
||||
if old.title != c.title || old.body_md != c.body_md || old.structured != c.structured {
|
||||
return Err(bad(format!(
|
||||
"convention {} changes more than its source: revise it without a reason, or give the text it has now",
|
||||
c.id
|
||||
)));
|
||||
}
|
||||
if old.source == c.source {
|
||||
return Err(bad(format!(
|
||||
"convention {} already has that source",
|
||||
c.id
|
||||
)));
|
||||
}
|
||||
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
|
||||
.map_err(bad)?;
|
||||
if reason.is_empty() {
|
||||
return Err(bad("a source correction needs a reason".into()));
|
||||
}
|
||||
Ok((c.source, reason))
|
||||
}
|
||||
|
||||
/// Apply a journaled `convention.source_revised`.
|
||||
pub fn apply_convention_source(
|
||||
&mut self,
|
||||
id: &str,
|
||||
source: crate::settings::Source,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.conventions
|
||||
.apply_convention_source(id, source)
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check a correction of a decision's source (`decision.source_revised`).
|
||||
pub fn check_decision_source(
|
||||
&self,
|
||||
id: u64,
|
||||
source: &crate::conventions::DecisionSource,
|
||||
reason: &str,
|
||||
) -> Result<(crate::conventions::DecisionSource, String), LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
let old = self
|
||||
.conventions
|
||||
.decision(id)
|
||||
.ok_or_else(|| bad(format!("decision {id} doesn't exist: books.decisions lists them")))?;
|
||||
let source = crate::conventions::check_decision_source(source).map_err(bad)?;
|
||||
if old.new.source() == source {
|
||||
return Err(bad(format!("decision {id} already has that source")));
|
||||
}
|
||||
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
|
||||
.map_err(bad)?;
|
||||
if reason.is_empty() {
|
||||
return Err(bad("a source correction needs a reason".into()));
|
||||
}
|
||||
Ok((source, reason))
|
||||
}
|
||||
|
||||
/// Apply a journaled `decision.source_revised`.
|
||||
pub fn apply_decision_source(
|
||||
&mut self,
|
||||
id: u64,
|
||||
source: crate::conventions::DecisionSource,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.conventions
|
||||
.apply_decision_source(id, source)
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check a correction of an open item's source (`open_item.source_revised`).
|
||||
pub fn check_open_item_source(
|
||||
&self,
|
||||
id: u64,
|
||||
source: &crate::settings::Source,
|
||||
reason: &str,
|
||||
) -> Result<(crate::settings::Source, String), LedgerError> {
|
||||
let bad = LedgerError::InvalidConvention;
|
||||
source.check().map_err(bad)?;
|
||||
let old = self.conventions.open_item(id).ok_or_else(|| {
|
||||
bad(format!("unknown open item {id}: books.open_items lists them"))
|
||||
})?;
|
||||
if old.new.source == *source {
|
||||
return Err(bad(format!("open item {id} already has that source")));
|
||||
}
|
||||
let reason = crate::settings::long_text("reason", reason, crate::settings::MAX_LONG_TEXT)
|
||||
.map_err(bad)?;
|
||||
if reason.is_empty() {
|
||||
return Err(bad("a source correction needs a reason".into()));
|
||||
}
|
||||
Ok((source.clone(), reason))
|
||||
}
|
||||
|
||||
/// Apply a journaled `open_item.source_revised`.
|
||||
pub fn apply_open_item_source(
|
||||
&mut self,
|
||||
id: u64,
|
||||
source: crate::settings::Source,
|
||||
) -> Result<(), LedgerError> {
|
||||
self.conventions
|
||||
.apply_open_item_source(id, source)
|
||||
.map_err(LedgerError::InvalidConvention)
|
||||
}
|
||||
|
||||
/// Check a retirement before it is journaled: the convention is live,
|
||||
/// the reason and source are sound.
|
||||
pub fn check_retire_convention(
|
||||
|
||||
@@ -1172,6 +1172,26 @@ pub fn describe(b: &Books, line: &JournalLine) -> Option<Event> {
|
||||
format!("Open item #{id} resolved: {}", s(p, "resolution")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"convention.source_revised" => ev(
|
||||
icon::SCROLL,
|
||||
format!(
|
||||
"Source of convention {} corrected to {}: {}",
|
||||
s(p, "id"),
|
||||
s(&p["source"], "decided_by"),
|
||||
s(p, "reason")
|
||||
),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"decision.source_revised" => ev(
|
||||
icon::GAVEL,
|
||||
format!("Source of decision #{id} corrected: {}", s(p, "reason")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"open_item.source_revised" => ev(
|
||||
icon::CHECK_CIRCLE,
|
||||
format!("Source of open item #{id} corrected: {}", s(p, "reason")),
|
||||
Some(Route::Company),
|
||||
),
|
||||
"entry.approved" => ev(
|
||||
icon::SEAL_CHECK,
|
||||
format!(
|
||||
|
||||
@@ -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`, `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.
|
||||
- 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), `drafts_v2` (P4: a draft's state, awaiting its evidence, and a discarded draft), and `source_revisions` (P5: correcting who decided a convention, a decision or an open item, and when). All are declared at once; a version that doesn't know one stops at the declaration instead of misreading the books.
|
||||
@@ -392,6 +392,11 @@ pub struct SetConventionParams {
|
||||
/// amount_over?}. Other kinds take none.
|
||||
#[serde(default)]
|
||||
pub structured: Option<Value>,
|
||||
/// Only to correct who decided it, when or on what (the text exactly
|
||||
/// as it stands): why the source is wrong. The correction is journaled
|
||||
/// as such, the convention's revision unchanged.
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// Who decided, and when.
|
||||
#[serde(flatten)]
|
||||
pub source: SourceIn,
|
||||
@@ -412,9 +417,12 @@ pub struct RetireConventionParams {
|
||||
/// Parameters for `books.record_decision`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RecordDecisionParams {
|
||||
/// What it is about ("Materiality policy").
|
||||
/// What it is about ("Materiality policy"). Left out when `revises` is
|
||||
/// given.
|
||||
#[serde(default)]
|
||||
pub subject: String,
|
||||
/// The decision in words (Markdown).
|
||||
/// The decision in words (Markdown). Left out when `revises` is given.
|
||||
#[serde(default)]
|
||||
pub decision_md: String,
|
||||
/// Who decided (names), at least one.
|
||||
pub decided_by: Vec<String>,
|
||||
@@ -432,6 +440,14 @@ pub struct RecordDecisionParams {
|
||||
/// The projects it is about (ids or names), if any.
|
||||
#[serde(default)]
|
||||
pub projects: Vec<String>,
|
||||
/// To correct only who decided an earlier decision, when and on what
|
||||
/// (decided_by, decided_on, citation: the new source), give its id here
|
||||
/// with `reason`; its words stay. Journaled as a source correction.
|
||||
#[serde(default)]
|
||||
pub revises: Option<u64>,
|
||||
/// With `revises`: why the source was wrong.
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
}
|
||||
|
||||
/// A decision.
|
||||
@@ -487,7 +503,8 @@ pub struct DecisionsOut {
|
||||
/// Parameters for `books.raise_item`.
|
||||
#[derive(Debug, Deserialize, JsonSchema)]
|
||||
pub struct RaiseItemParams {
|
||||
/// What is open, in a line.
|
||||
/// What is open, in a line. Left out when `revises` is given.
|
||||
#[serde(default)]
|
||||
pub title: String,
|
||||
/// More, if any (Markdown).
|
||||
#[serde(default)]
|
||||
@@ -495,6 +512,14 @@ pub struct RaiseItemParams {
|
||||
/// When it is due, YYYY-MM-DD, if it is.
|
||||
#[serde(default)]
|
||||
pub due: Option<String>,
|
||||
/// To correct only who raised an item, when and on what (the source
|
||||
/// given here), give the item's id with `reason`; the item stays as it
|
||||
/// is. Journaled as a source correction.
|
||||
#[serde(default)]
|
||||
pub revises: Option<u64>,
|
||||
/// With `revises`: why the source was wrong.
|
||||
#[serde(default)]
|
||||
pub reason: Option<String>,
|
||||
/// Who raised it, and when.
|
||||
#[serde(flatten)]
|
||||
pub source: SourceIn,
|
||||
@@ -1104,7 +1129,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.set_convention",
|
||||
title = "Set a convention",
|
||||
description = "Record how this company keeps its books, as the human decided: a posting template (structured {description?, lines: [{account, side, share}]}, each side's shares summing to 1), an approval rule (structured {approver, accounts?, amount_over?}), or a policy, close step, calendar item, mapping or note (words only). An id already set is revised (same kind); retire one with books.retire_convention.",
|
||||
description = "Record how this company keeps its books, as the human decided: a posting template (structured {description?, lines: [{account, side, share}]}, each side's shares summing to 1), an approval rule (structured {approver, accounts?, amount_over?}), or a policy, close step, calendar item, mapping or note (words only). An id already set is revised (same kind); retire one with books.retire_convention. To correct only its source (who decided it, when, on what), pass reason.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1131,7 +1156,20 @@ impl BooksServer {
|
||||
source: p.source.source()?,
|
||||
};
|
||||
let mut store = self.lock()?;
|
||||
let c = store.set_convention(&c).map_err(bad)?;
|
||||
let c = match p.reason.as_deref() {
|
||||
Some(reason) => {
|
||||
store.revise_convention_source(&c, reason).map_err(bad)?;
|
||||
c
|
||||
}
|
||||
None => store.set_convention(&c).map_err(|e| {
|
||||
let text = bad(e);
|
||||
if text.contains("already says exactly that") {
|
||||
format!("{text}; to correct only who decided it, when or on what, add reason")
|
||||
} else {
|
||||
text
|
||||
}
|
||||
})?,
|
||||
};
|
||||
let set = store
|
||||
.ledger()
|
||||
.conventions()
|
||||
@@ -1167,7 +1205,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.record_decision",
|
||||
title = "Record a decision",
|
||||
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about.",
|
||||
description = "Record a decision the owners made about the books (a policy, a treatment, a cutover), with who decided, when, and the document behind it if one is filed. A decision that replaces an earlier one names it in supersedes. No signatures or memos needed: the record is the decision with its source. projects names the projects it is about. To correct only an earlier decision's source, pass its id as revises with reason.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1180,6 +1218,34 @@ impl BooksServer {
|
||||
) -> Result<Json<IdOut>, String> {
|
||||
let p = params.0;
|
||||
let mut store = self.lock()?;
|
||||
if let Some(id) = p.revises {
|
||||
// A source correction: the decision's words stay (the ones
|
||||
// given, if any, must be the ones it has).
|
||||
let old = store
|
||||
.ledger()
|
||||
.conventions()
|
||||
.decision(id)
|
||||
.ok_or_else(|| format!("decision {id} doesn't exist: books.decisions lists them"))?;
|
||||
if (!p.subject.is_empty() && p.subject != old.new.subject)
|
||||
|| (!p.decision_md.is_empty() && p.decision_md != old.new.decision_md)
|
||||
{
|
||||
return Err(format!(
|
||||
"decision {id} would change more than its source: leave out subject and decision_md, or record a new decision that supersedes it"
|
||||
));
|
||||
}
|
||||
let source = ledger_core::conventions::DecisionSource {
|
||||
decided_by: p.decided_by,
|
||||
decided_on: parse_date(&p.decided_on)?,
|
||||
citation: p.citation,
|
||||
};
|
||||
let reason = p
|
||||
.reason
|
||||
.ok_or("revises needs a reason: why the source was wrong")?;
|
||||
store
|
||||
.revise_decision_source(id, &source, &reason)
|
||||
.map_err(bad)?;
|
||||
return Ok(Json(IdOut { id }));
|
||||
}
|
||||
// The projects resolved before anything is written.
|
||||
let projects = p
|
||||
.projects
|
||||
@@ -1232,7 +1298,7 @@ impl BooksServer {
|
||||
#[tool(
|
||||
name = "books.raise_item",
|
||||
title = "Raise an open item",
|
||||
description = "Record something open: waiting on someone, due later, or to look into (a letter to mail, a reply expected, a question for the CPA), with a due date if it has one. books.start lists open items; resolve each with books.resolve_item.",
|
||||
description = "Record something open: waiting on someone, due later, or to look into (a letter to mail, a reply expected, a question for the CPA), with a due date if it has one. books.start lists open items; resolve each with books.resolve_item. To correct only an item's source, pass its id as revises with reason.",
|
||||
annotations(
|
||||
read_only_hint = false,
|
||||
destructive_hint = false,
|
||||
@@ -1241,6 +1307,25 @@ impl BooksServer {
|
||||
)]
|
||||
async fn raise_item(&self, params: Parameters<RaiseItemParams>) -> Result<Json<IdOut>, String> {
|
||||
let p = params.0;
|
||||
if let Some(id) = p.revises {
|
||||
let reason = p
|
||||
.reason
|
||||
.ok_or("revises needs a reason: why the source was wrong")?;
|
||||
let source = p.source.source()?;
|
||||
let mut store = self.lock()?;
|
||||
if let Some(old) = store.ledger().conventions().open_item(id)
|
||||
&& !p.title.is_empty()
|
||||
&& p.title != old.new.title
|
||||
{
|
||||
return Err(format!(
|
||||
"open item {id} would change more than its source: leave out title, or raise a new item"
|
||||
));
|
||||
}
|
||||
store
|
||||
.revise_open_item_source(id, &source, &reason)
|
||||
.map_err(bad)?;
|
||||
return Ok(Json(IdOut { id }));
|
||||
}
|
||||
let item = NewOpenItem {
|
||||
title: p.title,
|
||||
detail_md: p.detail_md,
|
||||
|
||||
@@ -65,6 +65,7 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
DOCUMENTS_V2,
|
||||
ATTACHMENT_TEXT,
|
||||
DRAFTS_V2,
|
||||
SOURCE_REVISIONS,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -218,6 +219,15 @@ pub const ATTACHMENT_TEXT: &str = "attachment_text";
|
||||
/// (rules.md B-27, B-43).
|
||||
pub const DRAFTS_V2: &str = "drafts_v2";
|
||||
|
||||
/// Feature: `source_revisions` (plan P5): `convention.source_revised`,
|
||||
/// `decision.source_revised` and `open_item.source_revised` correct who
|
||||
/// decided a convention, a decision or an open item, when and on what,
|
||||
/// leaving its text as it was. An older version doesn't know those event
|
||||
/// types: it would refuse them as unknown, or, replaying around them,
|
||||
/// show the old source as the decision's authority; it stops at the
|
||||
/// declaration instead (rules.md B-27).
|
||||
pub const SOURCE_REVISIONS: &str = "source_revisions";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -248,8 +258,10 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"class.added",
|
||||
"convention.retired",
|
||||
"convention.set",
|
||||
"convention.source_revised",
|
||||
"customer.added",
|
||||
"decision.recorded",
|
||||
"decision.source_revised",
|
||||
"document.filed",
|
||||
"entry.approved",
|
||||
"entry.created",
|
||||
@@ -276,6 +288,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"line.cleared",
|
||||
"open_item.raised",
|
||||
"open_item.resolved",
|
||||
"open_item.source_revised",
|
||||
"party.added",
|
||||
"party.creditor_set",
|
||||
"party.role_set",
|
||||
@@ -989,6 +1002,39 @@ mod tests {
|
||||
assert_eq!(up.adds(), vec!["the journal feature drafts_v2"]);
|
||||
}
|
||||
|
||||
/// The version before P5 (every feature through `drafts_v2`) stops at a
|
||||
/// declaration of `source_revisions` as newer, and books that declared
|
||||
/// every earlier feature still wait for it (B-27, D-39).
|
||||
#[test]
|
||||
fn source_revisions_is_its_own_feature() {
|
||||
assert!(KNOWN_FEATURES.contains(&SOURCE_REVISIONS));
|
||||
let before: Vec<&str> = KNOWN_FEATURES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| *f != SOURCE_REVISIONS)
|
||||
.collect();
|
||||
let l = line(
|
||||
FORMAT_SET,
|
||||
json!({"format": 2, "features": [DRAFTS_V2, SOURCE_REVISIONS]}),
|
||||
);
|
||||
match declared_knowing(&l, &before) {
|
||||
Err(StoreError::NewerJournal { need, .. }) => {
|
||||
assert_eq!(need, "journal features source_revisions")
|
||||
}
|
||||
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(SOURCE_REVISIONS),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
let up = older.upgrade().expect("waits for it");
|
||||
assert_eq!(up.adds(), vec!["the journal feature source_revisions"]);
|
||||
}
|
||||
|
||||
/// This version's format: the newest it reads, every feature it
|
||||
/// knows, each once, sorted (what new books and an upgrade declare).
|
||||
#[test]
|
||||
|
||||
@@ -17,7 +17,9 @@ use crate::error::StoreError;
|
||||
use crate::journal::JournalLine;
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::arap::{SendKind, Sent, SentVia};
|
||||
use ledger_core::conventions::{Approval, ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::conventions::{
|
||||
Approval, ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem,
|
||||
};
|
||||
use ledger_core::settings::{Change, Source};
|
||||
use ledger_core::{
|
||||
Annotation, AnnotationFields, ClassId, DraftState, ProjectFacts, ProjectKind, ProjectStatus,
|
||||
@@ -174,6 +176,71 @@ pub fn convention_retired_of(p: &Value) -> Result<(String, String, Source), Stor
|
||||
))
|
||||
}
|
||||
|
||||
/// `convention.source_revised {id, source, reason}` (journal feature
|
||||
/// `source_revisions`): the convention's text is unchanged.
|
||||
pub fn convention_source_revised(id: &str, source: &Source, reason: &str) -> Value {
|
||||
json!({ "id": id, "source": source_json(source), "reason": reason })
|
||||
}
|
||||
|
||||
/// Read a `convention.source_revised`: `(id, source, reason)`.
|
||||
pub fn convention_source_revised_of(p: &Value) -> Result<(String, Source, String), StoreError> {
|
||||
const E: &str = "convention.source_revised";
|
||||
Ok((
|
||||
str_of(p, E, "id")?,
|
||||
source_of(p, E, "source")?,
|
||||
str_of(p, E, "reason")?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `decision.source_revised {id, source: {decided_by, decided_on,
|
||||
/// citation?}, reason}`.
|
||||
pub fn decision_source_revised(id: u64, s: &DecisionSource, reason: &str) -> Value {
|
||||
let mut src = Map::new();
|
||||
src.insert("decided_by".into(), json!(s.decided_by));
|
||||
src.insert("decided_on".into(), json!(s.decided_on.to_string()));
|
||||
put(&mut src, "citation", s.citation.clone());
|
||||
json!({ "id": id, "source": Value::Object(src), "reason": reason })
|
||||
}
|
||||
|
||||
/// Read a `decision.source_revised`: `(id, source, reason)`.
|
||||
pub fn decision_source_revised_of(p: &Value) -> Result<(u64, DecisionSource, String), StoreError> {
|
||||
const E: &str = "decision.source_revised";
|
||||
let src = p.get("source").ok_or_else(|| corrupt(E, "source"))?;
|
||||
let decided_by = src
|
||||
.get("decided_by")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|a| {
|
||||
a.iter()
|
||||
.map(|v| v.as_str().map(str::to_string))
|
||||
.collect::<Option<Vec<_>>>()
|
||||
})
|
||||
.ok_or_else(|| corrupt(E, "source.decided_by"))?;
|
||||
Ok((
|
||||
u64_of(p, E, "id")?,
|
||||
DecisionSource {
|
||||
decided_by,
|
||||
decided_on: date_of(&str_of(src, E, "decided_on")?, E, "source.decided_on")?,
|
||||
citation: opt_str(src, E, "citation")?,
|
||||
},
|
||||
str_of(p, E, "reason")?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `open_item.source_revised {id, source, reason}`.
|
||||
pub fn open_item_source_revised(id: u64, source: &Source, reason: &str) -> Value {
|
||||
json!({ "id": id, "source": source_json(source), "reason": reason })
|
||||
}
|
||||
|
||||
/// Read an `open_item.source_revised`: `(id, source, reason)`.
|
||||
pub fn open_item_source_revised_of(p: &Value) -> Result<(u64, Source, String), StoreError> {
|
||||
const E: &str = "open_item.source_revised";
|
||||
Ok((
|
||||
u64_of(p, E, "id")?,
|
||||
source_of(p, E, "source")?,
|
||||
str_of(p, E, "reason")?,
|
||||
))
|
||||
}
|
||||
|
||||
/// `decision.recorded {id, subject, decision_md, decided_by, decided_on,
|
||||
/// evidence_attachment?, supersedes?, citation?, projects?}`: `projects`
|
||||
/// only when it names any (journal feature `projects`), so a decision
|
||||
|
||||
@@ -1117,6 +1117,67 @@ impl Store {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct who decided a convention, when and on what
|
||||
/// (`convention.source_revised`, journal feature `source_revisions`),
|
||||
/// its text unchanged: `c` is the convention as it stands, with the
|
||||
/// corrected source. The reason is journaled with it.
|
||||
pub fn revise_convention_source(
|
||||
&mut self,
|
||||
c: &ledger_core::conventions::NewConvention,
|
||||
reason: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ensure_feature(crate::format::SOURCE_REVISIONS)?;
|
||||
let (source, reason) = self.ledger.check_convention_source(c, reason)?;
|
||||
self.ensure_format()?;
|
||||
let line = self.append_journal(
|
||||
"convention.source_revised",
|
||||
crate::meta::convention_source_revised(&c.id, &source, &reason),
|
||||
)?;
|
||||
self.ledger.apply_convention_source(&c.id, source)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct who decided a decision, when and on what
|
||||
/// (`decision.source_revised`, journal feature `source_revisions`).
|
||||
pub fn revise_decision_source(
|
||||
&mut self,
|
||||
id: u64,
|
||||
source: &ledger_core::conventions::DecisionSource,
|
||||
reason: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ensure_feature(crate::format::SOURCE_REVISIONS)?;
|
||||
let (source, reason) = self.ledger.check_decision_source(id, source, reason)?;
|
||||
self.ensure_format()?;
|
||||
let line = self.append_journal(
|
||||
"decision.source_revised",
|
||||
crate::meta::decision_source_revised(id, &source, &reason),
|
||||
)?;
|
||||
self.ledger.apply_decision_source(id, source)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Correct who raised an open item, when and on what
|
||||
/// (`open_item.source_revised`, journal feature `source_revisions`).
|
||||
pub fn revise_open_item_source(
|
||||
&mut self,
|
||||
id: u64,
|
||||
source: &ledger_core::settings::Source,
|
||||
reason: &str,
|
||||
) -> Result<(), StoreError> {
|
||||
self.ensure_feature(crate::format::SOURCE_REVISIONS)?;
|
||||
let (source, reason) = self.ledger.check_open_item_source(id, source, reason)?;
|
||||
self.ensure_format()?;
|
||||
let line = self.append_journal(
|
||||
"open_item.source_revised",
|
||||
crate::meta::open_item_source_revised(id, &source, &reason),
|
||||
)?;
|
||||
self.ledger.apply_open_item_source(id, source)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Record a decision (`decision.recorded`); its evidence, if any, is
|
||||
/// a filed attachment not withdrawn. Returns its id.
|
||||
pub fn record_decision(
|
||||
@@ -7945,6 +8006,18 @@ fn replay_line(
|
||||
let (id, reason, source) = crate::meta::convention_retired_of(p)?;
|
||||
ledger.apply_retire_convention(&id, reason, source, line.seq)?;
|
||||
}
|
||||
"convention.source_revised" => {
|
||||
let (id, source, _reason) = crate::meta::convention_source_revised_of(p)?;
|
||||
ledger.apply_convention_source(&id, source)?;
|
||||
}
|
||||
"decision.source_revised" => {
|
||||
let (id, source, _reason) = crate::meta::decision_source_revised_of(p)?;
|
||||
ledger.apply_decision_source(id, source)?;
|
||||
}
|
||||
"open_item.source_revised" => {
|
||||
let (id, source, _reason) = crate::meta::open_item_source_revised_of(p)?;
|
||||
ledger.apply_open_item_source(id, source)?;
|
||||
}
|
||||
"decision.recorded" => {
|
||||
let (id, d) = crate::meta::decision_recorded_of(p)?;
|
||||
ledger.apply_decision(id, d, line.seq)?;
|
||||
@@ -9042,6 +9115,32 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"convention.source_revised" => {
|
||||
let (id, source, _reason) = crate::meta::convention_source_revised_of(p)?;
|
||||
conn.execute(
|
||||
"UPDATE conventions SET source_json = ? WHERE id = ?",
|
||||
params![crate::meta::source_json(&source).to_string(), id],
|
||||
)?;
|
||||
}
|
||||
"decision.source_revised" => {
|
||||
let (id, source, _reason) = crate::meta::decision_source_revised_of(p)?;
|
||||
conn.execute(
|
||||
"UPDATE decisions SET decided_by_json = ?, decided_on = ?, citation = ? WHERE id = ?",
|
||||
params![
|
||||
serde_json::to_string(&source.decided_by)?,
|
||||
source.decided_on.to_string(),
|
||||
source.citation,
|
||||
id as i64
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"open_item.source_revised" => {
|
||||
let (id, source, _reason) = crate::meta::open_item_source_revised_of(p)?;
|
||||
conn.execute(
|
||||
"UPDATE open_items SET source_json = ? WHERE id = ?",
|
||||
params![crate::meta::source_json(&source).to_string(), id as i64],
|
||||
)?;
|
||||
}
|
||||
"decision.recorded" => {
|
||||
let (id, d) = crate::meta::decision_recorded_of(p)?;
|
||||
conn.execute(
|
||||
|
||||
@@ -14,6 +14,9 @@
|
||||
# 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 p5_before -- OUT (p5_before: run against b97234ac, the
|
||||
# revision before P5: a revised
|
||||
# convention, two decisions, two open items)
|
||||
# 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
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
//! Writes `p5_before`: books the version before P5 (main b97234ac) wrote
|
||||
//! through its Store API, with the records P5's source corrections act on:
|
||||
//! a convention revised once, two decisions (one superseding the other)
|
||||
//! and two open items (one resolved), each with its source, beside a few
|
||||
//! posted entries and a draft awaiting its evidence. No source correction
|
||||
//! exists in that version; this version opens the same journal and must
|
||||
//! report the same figures.
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{ConventionKind, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::Source;
|
||||
use ledger_core::{AccountType, Currency, 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 usd(c: i64) -> Money {
|
||||
Money::new(Decimal::new(c, 2), Currency::USD)
|
||||
}
|
||||
fn source(by: &str, day: u32) -> Source {
|
||||
Source {
|
||||
decided_by: by.into(),
|
||||
decided_on: d(2026, 10, day),
|
||||
citation: None,
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
let root = PathBuf::from(std::env::args().nth(1).unwrap());
|
||||
let dir = root.join("p5_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 capital = s.add_account("3000", "Capital", AccountType::Equity, None, false).unwrap();
|
||||
let rent = s.add_account("6000", "Rent", 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();
|
||||
let jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000);
|
||||
s.post_entry(jan).unwrap();
|
||||
let _waiting = post(&mut s, d(2026, 2, 3), "Awaiting a receipt", rent, bank, 4_000);
|
||||
// How the company keeps its books: a close step, revised once.
|
||||
let step = |body: &str, by: &str, day: u32| NewConvention {
|
||||
id: "close-checklist".into(),
|
||||
kind: ConventionKind::CloseStep,
|
||||
title: "Close checklist".into(),
|
||||
body_md: body.into(),
|
||||
structured: None,
|
||||
source: source(by, day),
|
||||
};
|
||||
s.set_convention(&step("Reconcile, then close.", "Owner", 20)).unwrap();
|
||||
s.set_convention(&step("Reconcile, review drafts, then close.", "Owner", 22)).unwrap();
|
||||
// Two decisions, the second replacing the first.
|
||||
let first = s
|
||||
.record_decision(&NewDecision {
|
||||
subject: "Materiality".into(),
|
||||
decision_md: "Five hundred dollars.".into(),
|
||||
decided_by: vec!["Owner".into()],
|
||||
decided_on: d(2026, 10, 20),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
s.record_decision(&NewDecision {
|
||||
subject: "Materiality".into(),
|
||||
decision_md: "One thousand dollars.".into(),
|
||||
decided_by: vec!["Owner".into(), "Pat Smith".into()],
|
||||
decided_on: d(2026, 10, 25),
|
||||
evidence_attachment: None,
|
||||
supersedes: Some(first),
|
||||
citation: Some("IRS Pub. 538".into()),
|
||||
projects: vec![],
|
||||
})
|
||||
.unwrap();
|
||||
// Two open items, one resolved.
|
||||
let ask = s
|
||||
.raise_open_item(&NewOpenItem {
|
||||
title: "Ask the CPA about the vehicle".into(),
|
||||
detail_md: None,
|
||||
due: Some(d(2026, 11, 15)),
|
||||
source: source("Owner", 20),
|
||||
})
|
||||
.unwrap();
|
||||
s.raise_open_item(&NewOpenItem {
|
||||
title: "Mail the annual report".into(),
|
||||
detail_md: Some("Due with the state.".into()),
|
||||
due: None,
|
||||
source: source("Owner", 21),
|
||||
})
|
||||
.unwrap();
|
||||
s.resolve_open_item(ask, "Answered: lease it.", &source("Pat Smith", 26)).unwrap();
|
||||
s.verify().unwrap();
|
||||
println!("WROTE {:?}", dir);
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
{
|
||||
"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": 2,
|
||||
"amount": {
|
||||
"amount": "1000.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-05",
|
||||
"description": "January rent",
|
||||
"id": 2,
|
||||
"lines": [
|
||||
{
|
||||
"account": 3,
|
||||
"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-02-03",
|
||||
"description": "Awaiting a receipt",
|
||||
"id": 3,
|
||||
"lines": [
|
||||
{
|
||||
"account": 3,
|
||||
"amount": {
|
||||
"amount": "40.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "40.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Draft"
|
||||
}
|
||||
],
|
||||
"entry_parties": [],
|
||||
"invoices": [],
|
||||
"payroll_runs": [],
|
||||
"periods": [],
|
||||
"recurring": [],
|
||||
"schedules": [],
|
||||
"snapshot": {
|
||||
"Ok": {
|
||||
"balance_sheet": {
|
||||
"assets": "500",
|
||||
"balanced": true,
|
||||
"current_earnings": "-500",
|
||||
"equity": "500",
|
||||
"liabilities": "0"
|
||||
},
|
||||
"profit_and_loss": {
|
||||
"expenses": "500",
|
||||
"net_income": "-500",
|
||||
"revenue": "0"
|
||||
},
|
||||
"trial_balance": {
|
||||
"lines": [
|
||||
{
|
||||
"code": "1000",
|
||||
"credit": "0",
|
||||
"debit": "500",
|
||||
"name": "Bank"
|
||||
},
|
||||
{
|
||||
"code": "3000",
|
||||
"credit": "1000",
|
||||
"debit": "0",
|
||||
"name": "Capital"
|
||||
},
|
||||
{
|
||||
"code": "6000",
|
||||
"credit": "0",
|
||||
"debit": "500",
|
||||
"name": "Rent"
|
||||
}
|
||||
],
|
||||
"total_credits": "1000",
|
||||
"total_debits": "1000"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verify": {
|
||||
"Ok": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:39:45Z","type":"store.created"}
|
||||
{"payload":{"features":["account_renamed","asset_basis","attachment_text","depreciation_catch_up","disposal_before_service","document_line_projects","document_standing","documents_v2","drafts_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":"1ec5c6e78cbef1e34d22de1c3376c7ec5051d3cf16d99d5d21fc0f62ab272574","seq":2,"ts":"2026-10-29T20:39:45Z","type":"journal.format_set"}
|
||||
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"49c0bd7bc22f2804ad1af0a6ed1488dcaa74ffcfbfc3f1d34580574c3b5f577a","seq":3,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"e62de432ee6bbf20db6231ceb2caa523a423cb6104747d0fee9bb7caa3306a13","seq":4,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":3,"name":"Rent","parent":null,"placeholder":false},"prev":"e718e3ee547698e7a8dba46b0fa2006579b265c8941a2a64a8780e0d6799c05e","seq":5,"ts":"2026-10-29T20:39:45Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"1aac79f391f066f3caa4286252c17094d5dcd78c5f1a3a5c3d26831976876581","seq":6,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
|
||||
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"278ea897aa5a37a5198f4165d03464945379a6b3cb25070cbf7a5b6a9c65faea","seq":7,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
|
||||
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"59535fd0d40e6820e354c3f2c2b9e0e03107c03392413e81d738c4229f6c8d40","seq":8,"ts":"2026-10-29T20:39:45Z","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":2,"amount":"1000.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"4fea68defea0343aa4cbc3b70c6d7c8dec9481d5df0bd6db78ddd7bf4c9a6b67","seq":9,"ts":"2026-10-29T20:39:45Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"40488694d734440f615cfaa57164f31f92da6c5c5133891671eba86ccbf99628","seq":10,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
|
||||
{"payload":{"account":3,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"ea486bc7b9029b186f156d4d8d7ad14b149fe6d24585bcaeb8707e13546a4109","seq":11,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"0836c22c91fb4ed3a0a230b49d6a8123592cd80f9630819c1f7ba957a51abf8f","seq":12,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":3,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"15cf6b35fd07465fdf86d765fbcacbbcd9fb8e378a9aca516c232a91d406cfbb","seq":13,"ts":"2026-10-29T20:39:45Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2026-02-03","description":"Awaiting a receipt","id":3},"prev":"331e0f5b3610e7b6a1e20d3682c5f2be6ce1b827e182643bd0be0ae097fbf4c7","seq":14,"ts":"2026-10-29T20:39:45Z","type":"entry.created"}
|
||||
{"payload":{"account":3,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"e2faf752e39dac02ea40ff2133c8d33a1fe85d84f5d3e81473b77a0284bd9b63","seq":15,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"4b4fac7a91be608d99f90c1aad38650b6e2442699cb103dd4c9f3dc2846e63d4","seq":16,"ts":"2026-10-29T20:39:45Z","type":"entry.line_added"}
|
||||
{"payload":{"body_md":"Reconcile, then close.","id":"close-checklist","kind":"close_step","source":{"decided_by":"Owner","decided_on":"2026-10-20"},"title":"Close checklist"},"prev":"0dd1ac379bd2987f018675f0c0ccc1558ddb6c5c32cd383bb5984c163f37b2c3","seq":17,"ts":"2026-10-29T20:39:45Z","type":"convention.set"}
|
||||
{"payload":{"body_md":"Reconcile, review drafts, then close.","id":"close-checklist","kind":"close_step","source":{"decided_by":"Owner","decided_on":"2026-10-22"},"title":"Close checklist"},"prev":"ac97d62664a7b399d6fe38cfd62843068470fdc46f41f19a74c0388b42d42105","seq":18,"ts":"2026-10-29T20:39:45Z","type":"convention.set"}
|
||||
{"payload":{"decided_by":["Owner"],"decided_on":"2026-10-20","decision_md":"Five hundred dollars.","id":1,"subject":"Materiality"},"prev":"4c7412cba11ebe3ab93e5a4c85a5f99f29913c952149f40a91a34e818d955a8e","seq":19,"ts":"2026-10-29T20:39:45Z","type":"decision.recorded"}
|
||||
{"payload":{"citation":"IRS Pub. 538","decided_by":["Owner","Pat Smith"],"decided_on":"2026-10-25","decision_md":"One thousand dollars.","id":2,"subject":"Materiality","supersedes":1},"prev":"b89addb314620a56f585d6a3f997f7f658e1596bda44c6b56ffe51b4fe737fff","seq":20,"ts":"2026-10-29T20:39:45Z","type":"decision.recorded"}
|
||||
{"payload":{"due":"2026-11-15","id":1,"source":{"decided_by":"Owner","decided_on":"2026-10-20"},"title":"Ask the CPA about the vehicle"},"prev":"4f1ce3e87d5bd5a4d689a0ef0a61422417c76d3af4d3793c03b12fecc5a73af1","seq":21,"ts":"2026-10-29T20:39:45Z","type":"open_item.raised"}
|
||||
{"payload":{"detail_md":"Due with the state.","id":2,"source":{"decided_by":"Owner","decided_on":"2026-10-21"},"title":"Mail the annual report"},"prev":"7054527c8e4184297b46b801e6ce2c8237b467684c40ebce295957d0bab372cf","seq":22,"ts":"2026-10-29T20:39:45Z","type":"open_item.raised"}
|
||||
{"payload":{"id":1,"resolution":"Answered: lease it.","source":{"decided_by":"Pat Smith","decided_on":"2026-10-26"}},"prev":"3ad740fbca7d8a6e8ce669a944631bc7d406f3f6820fb4b5c31c05fcbe26fca5","seq":23,"ts":"2026-10-29T20:39:46Z","type":"open_item.resolved"}
|
||||
@@ -0,0 +1,266 @@
|
||||
//! Source revisions (plan P5, journal feature `source_revisions`): who
|
||||
//! decided a convention, a decision or an open item can be corrected, with
|
||||
//! why, without touching its words. The correction is journaled as its own
|
||||
//! event, replays identically, and waits for the owner's format upgrade.
|
||||
//!
|
||||
//! Expected behavior comes from `docs/receipt-store.md` (the three events)
|
||||
//! and rules.md B-27 (a feature is declared before its first line).
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::conventions::{ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem};
|
||||
use ledger_core::settings::Source;
|
||||
use ledger_core::Currency;
|
||||
use ledger_store::format::{KNOWN_FEATURES, SOURCE_REVISIONS};
|
||||
use ledger_store::{JournalFormat, SUPPORTED_JOURNAL_FORMAT, Store, StoreError};
|
||||
use std::path::PathBuf;
|
||||
|
||||
struct TestDir(PathBuf);
|
||||
|
||||
impl std::ops::Deref for TestDir {
|
||||
type Target = std::path::Path;
|
||||
fn deref(&self) -> &std::path::Path {
|
||||
&self.0
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for TestDir {
|
||||
fn drop(&mut self) {
|
||||
let _ = std::fs::remove_dir_all(&self.0);
|
||||
}
|
||||
}
|
||||
|
||||
fn test_dir(name: &str) -> TestDir {
|
||||
let dir = std::env::temp_dir().join(format!("wanderbooks-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn day(d: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, 10, d).unwrap()
|
||||
}
|
||||
|
||||
fn source(by: &str, d: u32, citation: Option<&str>) -> Source {
|
||||
Source {
|
||||
decided_by: by.into(),
|
||||
decided_on: day(d),
|
||||
citation: citation.map(str::to_string),
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn convention(src: Source) -> NewConvention {
|
||||
NewConvention {
|
||||
id: "close-checklist".into(),
|
||||
kind: ConventionKind::CloseStep,
|
||||
title: "Close checklist".into(),
|
||||
body_md: "Reconcile, then close.".into(),
|
||||
structured: None,
|
||||
source: src,
|
||||
}
|
||||
}
|
||||
|
||||
fn decision() -> NewDecision {
|
||||
NewDecision {
|
||||
subject: "Materiality".into(),
|
||||
decision_md: "Five hundred dollars.".into(),
|
||||
decided_by: vec!["Owner".into()],
|
||||
decided_on: day(20),
|
||||
evidence_attachment: None,
|
||||
supersedes: None,
|
||||
citation: None,
|
||||
projects: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
fn item(src: Source) -> NewOpenItem {
|
||||
NewOpenItem {
|
||||
title: "Ask the CPA".into(),
|
||||
detail_md: None,
|
||||
due: None,
|
||||
source: src,
|
||||
}
|
||||
}
|
||||
|
||||
/// Done-when (plan P5 step 5): a call that changes only the source is
|
||||
/// journaled as the `*.source_revised` event, the words and the revision
|
||||
/// stay, the live books and a replay agree, and the reopened books verify.
|
||||
#[test]
|
||||
fn a_source_only_revision_is_journaled() {
|
||||
let dir = test_dir("source-revised");
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
s.set_convention(&convention(source("Owner", 20, None))).unwrap();
|
||||
let decision_id = s.record_decision(&decision()).unwrap();
|
||||
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
|
||||
let before = lines_of(&dir).len();
|
||||
|
||||
// A convention: the same words, a corrected source.
|
||||
let cpa = source("Pat Smith", 21, Some("IRS Pub. 538"));
|
||||
s.revise_convention_source(&convention(cpa.clone()), "the CPA decided it")
|
||||
.unwrap();
|
||||
s.revise_decision_source(
|
||||
decision_id,
|
||||
&DecisionSource {
|
||||
decided_by: vec!["Owner".into(), "Pat Smith".into()],
|
||||
decided_on: day(21),
|
||||
citation: Some("IRS Pub. 538".into()),
|
||||
},
|
||||
"both decided it",
|
||||
)
|
||||
.unwrap();
|
||||
s.revise_open_item_source(item_id, &cpa, "raised by the CPA")
|
||||
.unwrap();
|
||||
|
||||
let lines = lines_of(&dir);
|
||||
let types: Vec<&str> = lines[before..]
|
||||
.iter()
|
||||
.map(|l| l["type"].as_str().unwrap())
|
||||
.collect();
|
||||
assert_eq!(
|
||||
types,
|
||||
[
|
||||
"convention.source_revised",
|
||||
"decision.source_revised",
|
||||
"open_item.source_revised"
|
||||
]
|
||||
);
|
||||
assert_eq!(lines[before]["payload"]["reason"], "the CPA decided it");
|
||||
assert_eq!(lines[before]["payload"]["source"]["decided_by"], "Pat Smith");
|
||||
|
||||
// The words and the revision are as they were; the source is new.
|
||||
let c = s.ledger().conventions().convention("close-checklist").unwrap();
|
||||
assert_eq!((c.body_md.as_str(), c.revision), ("Reconcile, then close.", 1));
|
||||
assert_eq!(c.source.decided_by, "Pat Smith");
|
||||
let d = s.ledger().conventions().decision(decision_id).unwrap();
|
||||
assert_eq!(d.new.decision_md, "Five hundred dollars.");
|
||||
assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]);
|
||||
assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538"));
|
||||
assert_eq!(
|
||||
s.ledger().conventions().open_item(item_id).unwrap().new.source,
|
||||
cpa
|
||||
);
|
||||
|
||||
// Replay equals the live books, and the database file agrees.
|
||||
let live = s.ledger().clone();
|
||||
drop(s);
|
||||
let reopened = Store::open(&dir).unwrap();
|
||||
reopened.verify().unwrap();
|
||||
assert!(live.differs_from(reopened.ledger()).is_empty());
|
||||
drop(reopened);
|
||||
let _ = std::fs::remove_file(dir.join(ledger_store::db_file()));
|
||||
let rebuilt = Store::open(&dir).unwrap();
|
||||
rebuilt.verify().unwrap();
|
||||
assert!(live.differs_from(rebuilt.ledger()).is_empty());
|
||||
}
|
||||
|
||||
/// A correction must be one: other words, the same source, a retired
|
||||
/// convention or a missing reason is refused and journals nothing.
|
||||
#[test]
|
||||
fn a_source_revision_must_change_only_the_source() {
|
||||
let dir = test_dir("source-refused");
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
s.set_convention(&convention(source("Owner", 20, None))).unwrap();
|
||||
let id = s.record_decision(&decision()).unwrap();
|
||||
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
|
||||
let len = lines_of(&dir).len();
|
||||
let mut other_words = convention(source("Pat Smith", 21, None));
|
||||
other_words.body_md = "Close first.".into();
|
||||
for refused in [
|
||||
// other words
|
||||
s.revise_convention_source(&other_words, "why"),
|
||||
// the same source
|
||||
s.revise_convention_source(&convention(source("Owner", 20, None)), "why"),
|
||||
// no reason
|
||||
s.revise_convention_source(&convention(source("Pat Smith", 21, None)), " "),
|
||||
// no such convention, decision, item
|
||||
s.revise_convention_source(
|
||||
&NewConvention {
|
||||
id: "nonesuch".into(),
|
||||
..convention(source("Pat Smith", 21, None))
|
||||
},
|
||||
"why",
|
||||
),
|
||||
s.revise_decision_source(99, &decision().source(), "why"),
|
||||
s.revise_decision_source(id, &decision().source(), "same source"),
|
||||
s.revise_decision_source(
|
||||
id,
|
||||
&DecisionSource {
|
||||
decided_by: vec![],
|
||||
decided_on: day(21),
|
||||
citation: None,
|
||||
},
|
||||
"why",
|
||||
),
|
||||
s.revise_open_item_source(99, &source("Pat Smith", 21, None), "why"),
|
||||
s.revise_open_item_source(item_id, &source("Owner", 20, None), "same source"),
|
||||
] {
|
||||
assert!(
|
||||
matches!(refused, Err(StoreError::Ledger(_))),
|
||||
"{refused:?}"
|
||||
);
|
||||
}
|
||||
assert_eq!(lines_of(&dir).len(), len, "a refusal journals nothing");
|
||||
}
|
||||
|
||||
/// R-A step 3 (plan P5): books that have not declared `source_revisions`
|
||||
/// refuse the three corrections, journaling nothing, with the format
|
||||
/// upgrade pending; after the owner's go-ahead they journal.
|
||||
#[test]
|
||||
fn source_revisions_wait_for_the_upgrade() {
|
||||
let dir = test_dir("source-wait");
|
||||
// The version before P5: format 2, every feature but this one.
|
||||
let before = JournalFormat {
|
||||
format: SUPPORTED_JOURNAL_FORMAT,
|
||||
features: KNOWN_FEATURES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| *f != SOURCE_REVISIONS)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
};
|
||||
let mut s = Store::create_in_format(&dir, Currency::USD, &before).unwrap();
|
||||
s.set_convention(&convention(source("Owner", 20, None))).unwrap();
|
||||
let id = s.record_decision(&decision()).unwrap();
|
||||
let item_id = s.raise_open_item(&item(source("Owner", 20, None))).unwrap();
|
||||
let head = s.head();
|
||||
let len = lines_of(&dir).len();
|
||||
let cpa = source("Pat Smith", 21, None);
|
||||
for refused in [
|
||||
s.revise_convention_source(&convention(cpa.clone()), "why"),
|
||||
s.revise_decision_source(
|
||||
id,
|
||||
&DecisionSource {
|
||||
decided_by: vec!["Pat Smith".into()],
|
||||
decided_on: day(21),
|
||||
citation: None,
|
||||
},
|
||||
"why",
|
||||
),
|
||||
s.revise_open_item_source(item_id, &cpa, "why"),
|
||||
] {
|
||||
match refused {
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert_eq!(need, "the journal feature source_revisions")
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(s.head(), head, "a refusal journals nothing");
|
||||
assert_eq!(lines_of(&dir).len(), len);
|
||||
assert!(s.upgrade_format().unwrap().is_some(), "the go-ahead");
|
||||
s.revise_convention_source(&convention(cpa.clone()), "why").unwrap();
|
||||
s.revise_open_item_source(item_id, &cpa, "why").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());
|
||||
}
|
||||
@@ -2140,6 +2140,47 @@ fn books_written_under_older_rules_still_open() {
|
||||
json!({"id": 9, "reason": "entered twice"}),
|
||||
)
|
||||
.unwrap();
|
||||
// P5's new shapes, hand-written as this version writes them: a
|
||||
// convention, a decision and an open item, each with its source
|
||||
// corrected (`source_revisions`). This reader takes them; the version
|
||||
// before it stopped at the declaration.
|
||||
let owner = json!({"decided_by": "Owner", "decided_on": "2026-10-26"});
|
||||
w.append(
|
||||
"convention.set",
|
||||
json!({"id": "close-checklist", "kind": "close_step", "title": "Close checklist",
|
||||
"body_md": "Reconcile, then close.", "source": owner}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"decision.recorded",
|
||||
json!({"id": 1, "subject": "Materiality", "decision_md": "Five hundred dollars.",
|
||||
"decided_by": ["Owner"], "decided_on": "2026-10-26"}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"open_item.raised",
|
||||
json!({"id": 1, "title": "Ask the CPA", "source": owner}),
|
||||
)
|
||||
.unwrap();
|
||||
let cpa = json!({"decided_by": "Pat Smith", "decided_on": "2026-10-27",
|
||||
"citation": "IRS Pub. 538"});
|
||||
w.append(
|
||||
"convention.source_revised",
|
||||
json!({"id": "close-checklist", "source": cpa, "reason": "the CPA decided it"}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"decision.source_revised",
|
||||
json!({"id": 1, "source": {"decided_by": ["Owner", "Pat Smith"],
|
||||
"decided_on": "2026-10-27", "citation": "IRS Pub. 538"},
|
||||
"reason": "both decided it"}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"open_item.source_revised",
|
||||
json!({"id": 1, "source": cpa, "reason": "raised by the CPA"}),
|
||||
)
|
||||
.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()));
|
||||
@@ -2221,6 +2262,21 @@ fn books_written_under_older_rules_still_open() {
|
||||
);
|
||||
assert_eq!(bill.memo.as_deref(), Some("A4 paper"));
|
||||
assert_eq!(bill.lines[0].account, Some(6));
|
||||
// P5's source corrections replay as written; the text stays as it was.
|
||||
let c = store
|
||||
.ledger()
|
||||
.conventions()
|
||||
.convention("close-checklist")
|
||||
.expect("the convention");
|
||||
assert_eq!(c.source.decided_by, "Pat Smith");
|
||||
assert_eq!(c.source.citation.as_deref(), Some("IRS Pub. 538"));
|
||||
assert_eq!((c.body_md.as_str(), c.revision), ("Reconcile, then close.", 1));
|
||||
let d = store.ledger().conventions().decision(1).expect("the decision");
|
||||
assert_eq!(d.new.decided_by, ["Owner", "Pat Smith"]);
|
||||
assert_eq!(d.new.citation.as_deref(), Some("IRS Pub. 538"));
|
||||
assert_eq!(d.new.decision_md, "Five hundred dollars.");
|
||||
let i = store.ledger().conventions().open_item(1).expect("the item");
|
||||
assert_eq!(i.new.source.decided_by, "Pat Smith");
|
||||
// P4b S5's send replays as written, and moves no figure.
|
||||
let sends = &store.ledger().get_invoice(1).expect("the invoice").sends;
|
||||
assert_eq!(sends.len(), 1);
|
||||
|
||||
+18
-2
@@ -173,6 +173,16 @@ as it is made.
|
||||
`policy`, `close_step`, `calendar`, `mapping`, `note`, in words). An id is
|
||||
a slug, revised in place (same kind), never reused once retired:
|
||||
`books.retire_convention { id, reason, decided_by, ... }` (destructive).
|
||||
**Correcting a source** (P5, journal feature `source_revisions`):
|
||||
`books.set_convention` with `reason` and the text exactly as it stands
|
||||
changes only who decided it, when and on what (`decided_by`,
|
||||
`decided_on`, `citation`, `note`), journaled as `convention.source_revised
|
||||
{id, source, reason}`; the revision number and the words do not move.
|
||||
Without `reason` the same call is refused ("already says exactly that;
|
||||
to correct only who decided it, when or on what, add reason"), and with
|
||||
`reason` and different words it is refused ("changes more than its
|
||||
source"). Books that have not had the owner's upgrade refuse it with
|
||||
`format_upgrade_pending`.
|
||||
- `books.draft_from_template { template_id, date, amount, accounts?,
|
||||
description?, party?, no_party_reason? }` → `{ entry_id, description,
|
||||
lines }`: a draft whose lines split the amount (at most a line's) by the
|
||||
@@ -190,8 +200,14 @@ as it is made.
|
||||
decision without them journals the bytes it always did, and one with
|
||||
them needs the journal feature `projects`. `books.decisions` gives each
|
||||
decision's `projects: [{ id, name }]` (left out when none); `project`
|
||||
filters to the decisions naming one.
|
||||
- `books.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }`,
|
||||
filters to the decisions naming one. `books.record_decision { revises:
|
||||
ID, reason, decided_by: [..], decided_on, citation? }` corrects only
|
||||
that decision's source (who decided it, when, on what; its words stay,
|
||||
and `subject` and `decision_md`, if given, must be the ones it has):
|
||||
`decision.source_revised {id, source, reason}`, returning `{ id }`.
|
||||
- `books.raise_item { title, detail_md?, due?, decided_by, ... }` → `{ id }`
|
||||
(`books.raise_item { revises: ID, reason, decided_by, ... }` corrects
|
||||
only that item's source: `open_item.source_revised`),
|
||||
`books.resolve_item { id, resolution, decided_by, ... }`,
|
||||
`books.open_items { include_resolved? }`.
|
||||
- `books.approve_entry { entry_id, approver, note?, approved_on? }`: the
|
||||
|
||||
@@ -64,7 +64,14 @@ apply is safe — on startup the applier replays every journal line with
|
||||
`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`)
|
||||
draft's earlier lines stay in the journal) (P4, feature `drafts_v2`),
|
||||
and `convention.source_revised {id, source, reason}`,
|
||||
`decision.source_revised {id, source: {decided_by: [..], decided_on,
|
||||
citation?}, reason}` and `open_item.source_revised {id, source, reason}`
|
||||
(P5, feature `source_revisions`: who decided a convention, a decision or
|
||||
an open item, when and on what, corrected without touching its words;
|
||||
replay sets the source and nothing else, and the projection updates the
|
||||
row's existing source columns, so no schema changed)
|
||||
(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
|
||||
|
||||
+4
-1
@@ -1761,7 +1761,10 @@ 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
|
||||
being built, B-43). P5's is `source_revisions` (a
|
||||
`convention.source_revised`, `decision.source_revised` or
|
||||
`open_item.source_revised`, new event types: an older version would
|
||||
refuse them, or show the old source as the record's authority). 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
|
||||
|
||||
@@ -45,6 +45,7 @@ const TYPES: &[&str] = &[
|
||||
"reconciliation.voided", "role.assigned", "statement.recorded", "statement.voided",
|
||||
"project.set", "entry.settles",
|
||||
"entry.draft_state_set", "entry.draft_discarded",
|
||||
"convention.source_revised", "decision.source_revised", "open_item.source_revised",
|
||||
// Merged 2026-10-28 (S7, P6): every type EVENT_TYPES names.
|
||||
];
|
||||
|
||||
|
||||
Reference in new issue
Block a user