fix: Windows reads of lock files, update paths cfg(unix), merge main again (P11c)
spec_vault swept every file under a book, lock files included; on Windows a held LockFileEx lock is mandatory and the read failed. The locks are already dedicated files (never data), so the test skips *.lock. Merged main (P13 step 6, the macOS server.log assert, P11a's AppImage identity): host.rs keeps main's five-field identity on Unix and size/last-write on Windows; the update poll and set_program_version are Unix-only. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HS8WZziDnyxunC23MUxrLp
This commit is contained in:
commit
7749afc557
234 files changed
+13028
-18411
No files matched your search
@@ -67,6 +67,22 @@ pub struct Client {
|
||||
pub version: String,
|
||||
}
|
||||
|
||||
/// What an agent said about itself when it began its session
|
||||
/// (`books.start`'s `agent`): self-reported, never checked, kept beside the
|
||||
/// access so a record shows which model and harness made it.
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct Agent {
|
||||
/// The model's name.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub model: Option<String>,
|
||||
/// The reasoning effort it ran at.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub reasoning_effort: Option<String>,
|
||||
/// The program driving it.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub harness: Option<String>,
|
||||
}
|
||||
|
||||
/// What came of a call.
|
||||
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
@@ -105,6 +121,9 @@ pub struct Access {
|
||||
/// (whatever it answered).
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub head_after: Option<Head>,
|
||||
/// What the agent said about itself, on a `books.start` that said.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub agent: Option<Agent>,
|
||||
}
|
||||
|
||||
/// Mask any full TIN in a client's text, then cut it to
|
||||
@@ -186,6 +205,11 @@ impl AccessLog {
|
||||
c.name = asserted(&c.name);
|
||||
c.version = asserted(&c.version);
|
||||
}
|
||||
if let Some(a) = &mut access.agent {
|
||||
for f in [&mut a.model, &mut a.reasoning_effort, &mut a.harness] {
|
||||
*f = f.as_deref().map(asserted);
|
||||
}
|
||||
}
|
||||
access.request_id = access.request_id.as_deref().map(asserted);
|
||||
access.subject = asserted(&access.subject);
|
||||
let Value::Object(fields) = serde_json::to_value(&access).expect("a record serializes")
|
||||
@@ -261,6 +285,7 @@ mod tests {
|
||||
args_digest: Some(digest(&serde_json::json!({"as_of": "2026-10-19"}))),
|
||||
result: Outcome::Ok,
|
||||
head_after: None,
|
||||
agent: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,795 @@
|
||||
//! The audit's journal and the integrity checks (plan P8; rules.md B-45):
|
||||
//! `audit.run`, `receipt.attested`, `entry.reviewed` and `period.attested`,
|
||||
//! what "the current head" means, and the findings only the store can see.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an audit run is a journaled fact keyed by the books' figure
|
||||
//! head, so the same books give the same run once, and an
|
||||
//! attestation rests on a head that has not moved.
|
||||
//! SOURCE: IRS Pub. 583 (records); Rev. Proc. 98-25 §5.01 (the audit
|
||||
//! trail); docs/spec/rules.md B-45; DECISION D-9 (the bar), D-53
|
||||
//! (an audit is a run, not a job), IZ-1k (the head).
|
||||
//! INVARIANTS:
|
||||
//! 1. The figure head is the seq of the last journal line that bears on
|
||||
//! a figure: everything but the audit and attestation events, the
|
||||
//! evidence filings (an attachment, a link, a statement, a
|
||||
//! reconciliation, extraction, annotations) and the format
|
||||
//! declaration. An audit's own filing never moves it.
|
||||
//! 2. A run is idempotent on (through, figure head, scope, program
|
||||
//! version): asking again returns the run that exists and journals
|
||||
//! nothing.
|
||||
//! 3. A period is attested only on a run at the current figure head
|
||||
//! that covers it (its through date is on or after the period's
|
||||
//! end).
|
||||
//! 4. A receipt attestation whose amount or date differs from the
|
||||
//! entry's is recorded, marked `disagrees`, and never counts.
|
||||
//! 5. `counted` is never stored: it is read against
|
||||
//! `policy.attestation_bar` as it stands when asked.
|
||||
//! 6. The method is the client's own claim; the books keep it and say
|
||||
//! so.
|
||||
//! 7. Every write waits for the `audits` journal feature and journals
|
||||
//! nothing when refused.
|
||||
//! SCENARIOS:
|
||||
//! 1. IF the same audit is asked twice at one head: SHOULD return the
|
||||
//! first run both times.
|
||||
//! 2. IF an entry posts after a run: SHOULD refuse attesting a period
|
||||
//! on that run.
|
||||
//! ```
|
||||
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::audit::{Finding, Method, Scope, Severity, Subject};
|
||||
use rusqlite::params;
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::Value;
|
||||
|
||||
use super::{Store, apply_line_to_sqlite};
|
||||
use crate::error::StoreError;
|
||||
|
||||
/// The audit programme's version: the crate's, and a revision of its
|
||||
/// checks. A run at another version is another run.
|
||||
pub const PROGRAM_VERSION: &str = concat!("audit-", env!("CARGO_PKG_VERSION"), "-1");
|
||||
|
||||
/// Journal lines that do not bear on a figure (INVARIANT 1).
|
||||
pub const NOT_FIGURES: &[&str] = &[
|
||||
"audit.run",
|
||||
"receipt.attested",
|
||||
"entry.reviewed",
|
||||
"period.attested",
|
||||
"store.created",
|
||||
"journal.format_set",
|
||||
"journal.tail_repaired",
|
||||
"attachment.linked",
|
||||
"attachment.unlinked",
|
||||
"attachment.withdrawn",
|
||||
"attachment.text_extracted",
|
||||
"attachment.annotated",
|
||||
"receipt.attached",
|
||||
"receipt.linked",
|
||||
"receipt.unlinked",
|
||||
"document.filed",
|
||||
"statement.recorded",
|
||||
"statement.voided",
|
||||
"reconciliation.completed",
|
||||
"reconciliation.voided",
|
||||
"decision.recorded",
|
||||
"open_item.raised",
|
||||
"open_item.resolved",
|
||||
"entry.settles",
|
||||
];
|
||||
|
||||
/// The integrity checks' codes, severities and predicates (the
|
||||
/// statements checks are `ledger_core::audit::CODES`).
|
||||
pub const CODES: &[ledger_core::audit::CodeRow] = &[
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "journal_damaged",
|
||||
severity: Severity::Failure,
|
||||
predicate: "the journal's hash chain does not verify",
|
||||
},
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "projection_differs",
|
||||
severity: Severity::Failure,
|
||||
predicate: "the live books or the database differ from a replay of the journal",
|
||||
},
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "blob_missing",
|
||||
severity: Severity::Failure,
|
||||
predicate: "a stored file the books name is not on disk",
|
||||
},
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "blob_damaged",
|
||||
severity: Severity::Failure,
|
||||
predicate: "a stored file's bytes do not match their SHA-256",
|
||||
},
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "access_log_damaged",
|
||||
severity: Severity::Failure,
|
||||
predicate: "the access log's chain does not verify",
|
||||
},
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "extraction_blob_missing",
|
||||
severity: Severity::Warning,
|
||||
predicate: "a file's extracted text is not on disk",
|
||||
},
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "duplicate_blob",
|
||||
severity: Severity::Warning,
|
||||
predicate: "two filed attachments, neither withdrawn, hold the same bytes",
|
||||
},
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "backup_stale",
|
||||
severity: Severity::Warning,
|
||||
predicate: "a backup target has not reached the books' head within a day",
|
||||
},
|
||||
ledger_core::audit::CodeRow {
|
||||
code: "drill_failed",
|
||||
severity: Severity::Failure,
|
||||
predicate: "a backup failed, or failed its restore drill",
|
||||
},
|
||||
];
|
||||
|
||||
fn found(code: &'static str, subject: Subject, message: String) -> Finding {
|
||||
let severity = CODES
|
||||
.iter()
|
||||
.find(|r| r.code == code)
|
||||
.map_or(Severity::Failure, |r| r.severity);
|
||||
Finding {
|
||||
code,
|
||||
severity,
|
||||
subject,
|
||||
message,
|
||||
}
|
||||
}
|
||||
|
||||
/// What the backups' standing says, one target at a time, for the audit
|
||||
/// (the server reads the standing; the store does not know the backups).
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct BackupStanding {
|
||||
/// The target's kind and name, as the backups name it.
|
||||
pub target: String,
|
||||
/// It has not reached the head within a day.
|
||||
pub stale: bool,
|
||||
/// Its last run's error, if it failed.
|
||||
pub last_error: Option<String>,
|
||||
/// Its last restore drill's error, if it failed.
|
||||
pub drill_error: Option<String>,
|
||||
}
|
||||
|
||||
/// The backup findings: a target stale, failing, or failing its drill.
|
||||
/// Books with no target set up have none (that is the owner's standing
|
||||
/// choice, which `books.start` already says; an audit reports what the
|
||||
/// backups it can see do).
|
||||
pub fn backup_findings(targets: &[BackupStanding]) -> Vec<Finding> {
|
||||
let mut out = Vec::new();
|
||||
for t in targets {
|
||||
if t.stale {
|
||||
out.push(found(
|
||||
"backup_stale",
|
||||
Subject::Named(t.target.clone()),
|
||||
format!("{} has not reached the books' head within a day", t.target),
|
||||
));
|
||||
}
|
||||
if let Some(e) = &t.last_error {
|
||||
out.push(found(
|
||||
"drill_failed",
|
||||
Subject::Named(t.target.clone()),
|
||||
format!("{}'s last backup failed: {e}", t.target),
|
||||
));
|
||||
}
|
||||
if let Some(e) = &t.drill_error {
|
||||
out.push(found(
|
||||
"drill_failed",
|
||||
Subject::Named(t.target.clone()),
|
||||
format!("{}'s restore drill failed: {e}", t.target),
|
||||
));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// An audit run as the journal records it (`audit.run`).
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct AuditRun {
|
||||
/// Its id (1 for the first).
|
||||
pub id: u64,
|
||||
/// The audit covers the books through this date.
|
||||
pub through: NaiveDate,
|
||||
/// The figure head it was made at ([`Store::figure_head`]).
|
||||
pub through_seq: u64,
|
||||
/// What it covers.
|
||||
pub scope: Scope,
|
||||
/// The programme's version ([`PROGRAM_VERSION`]).
|
||||
pub program_version: String,
|
||||
/// SHA-256 of the trial balance through the date.
|
||||
pub tb_hash: String,
|
||||
/// SHA-256 of the findings.
|
||||
pub findings_sha256: String,
|
||||
/// The workpaper's JSON, filed as a document.
|
||||
pub workpaper_attachment: Option<u64>,
|
||||
/// The workpaper's PDF, filed as a document.
|
||||
pub workpaper_pdf: Option<u64>,
|
||||
/// The journal line that recorded it.
|
||||
pub seq: u64,
|
||||
}
|
||||
|
||||
/// What a run is made from, before it has an id or a seq.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct NewAuditRun {
|
||||
/// Through this date.
|
||||
pub through: NaiveDate,
|
||||
/// The figure head the findings were computed at.
|
||||
pub through_seq: u64,
|
||||
/// What it covers.
|
||||
pub scope: Scope,
|
||||
/// SHA-256 of the trial balance.
|
||||
pub tb_hash: String,
|
||||
/// SHA-256 of the findings.
|
||||
pub findings_sha256: String,
|
||||
/// The filed workpaper JSON.
|
||||
pub workpaper_attachment: Option<u64>,
|
||||
/// The filed workpaper PDF.
|
||||
pub workpaper_pdf: Option<u64>,
|
||||
}
|
||||
|
||||
/// What an attestation says it checked.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum Attested {
|
||||
/// A receipt against an entry.
|
||||
Receipt {
|
||||
/// The filed receipt.
|
||||
attachment_id: u64,
|
||||
/// The entry it supports.
|
||||
entry_id: u64,
|
||||
/// The amount the checker read off it.
|
||||
amount_checked: Decimal,
|
||||
/// The date the checker read off it.
|
||||
date_checked: NaiveDate,
|
||||
/// The checker's reading differs from the entry's.
|
||||
disagrees: bool,
|
||||
},
|
||||
/// An entry, reviewed whole.
|
||||
Entry {
|
||||
/// The entry.
|
||||
entry_id: u64,
|
||||
},
|
||||
/// A period, on an audit run.
|
||||
Period {
|
||||
/// The period.
|
||||
period_id: u64,
|
||||
/// The run it rests on.
|
||||
audit_run_id: u64,
|
||||
},
|
||||
}
|
||||
|
||||
/// A claim that someone checked, as the journal records it.
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct Attestation {
|
||||
/// The journal line.
|
||||
pub seq: u64,
|
||||
/// Who claims it: the caller's token name.
|
||||
pub by: String,
|
||||
/// When, UTC.
|
||||
pub at: String,
|
||||
/// How, as the client says.
|
||||
pub method: Method,
|
||||
/// A free note.
|
||||
pub note: Option<String>,
|
||||
/// What was checked.
|
||||
pub what: Attested,
|
||||
}
|
||||
|
||||
impl Attestation {
|
||||
/// Its kind's word: `receipt`, `entry` or `period`.
|
||||
pub fn kind(&self) -> &'static str {
|
||||
match self.what {
|
||||
Attested::Receipt { .. } => "receipt",
|
||||
Attested::Entry { .. } => "entry",
|
||||
Attested::Period { .. } => "period",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Who claims an attestation, how, and what they add.
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct Claim {
|
||||
/// The caller's token name.
|
||||
pub by: String,
|
||||
/// How, as the client says.
|
||||
pub method: Method,
|
||||
/// A free note.
|
||||
pub note: Option<String>,
|
||||
}
|
||||
|
||||
impl Claim {
|
||||
fn into_attestation(self, what: Attested) -> Attestation {
|
||||
Attestation {
|
||||
seq: 0,
|
||||
by: self.by,
|
||||
at: now(),
|
||||
method: self.method,
|
||||
note: self
|
||||
.note
|
||||
.map(|n| n.trim().to_string())
|
||||
.filter(|n| !n.is_empty()),
|
||||
what,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn refused(why: impl Into<String>) -> StoreError {
|
||||
StoreError::AuditRefused(why.into())
|
||||
}
|
||||
|
||||
fn now() -> String {
|
||||
chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
|
||||
}
|
||||
|
||||
impl Store {
|
||||
/// The seq of the last journal line that bears on a figure
|
||||
/// (INVARIANT 1); zero for books with none.
|
||||
pub fn figure_head(&self) -> Result<u64, StoreError> {
|
||||
let quoted: Vec<String> = NOT_FIGURES.iter().map(|t| format!("'{t}'")).collect();
|
||||
let sql = format!(
|
||||
"SELECT COALESCE(MAX(seq), 0) FROM events WHERE type NOT IN ({})",
|
||||
quoted.join(",")
|
||||
);
|
||||
let n: i64 = self.conn.query_row(&sql, [], |r| r.get(0))?;
|
||||
Ok(n as u64)
|
||||
}
|
||||
|
||||
/// The entries with at least one receipt filed on them or linked to
|
||||
/// them, not withdrawn.
|
||||
pub fn entries_with_receipts(&self) -> Result<BTreeSet<u64>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT entry_id FROM attachments WHERE withdrawn_at IS NULL AND entry_id IS NOT NULL
|
||||
UNION
|
||||
SELECT l.subject_id FROM attachment_links l JOIN attachments a ON a.id = l.attachment_id
|
||||
WHERE l.subject_kind = 'entry' AND a.withdrawn_at IS NULL",
|
||||
)?;
|
||||
let ids = stmt
|
||||
.query_map([], |r| r.get::<_, i64>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()?;
|
||||
Ok(ids.into_iter().map(|i| i as u64).collect())
|
||||
}
|
||||
|
||||
/// What only the store can see: the chain, the database against a
|
||||
/// replay, the stored files, the access log, extraction text and
|
||||
/// duplicate files. Reads; changes nothing.
|
||||
pub fn integrity_findings(&self) -> Vec<Finding> {
|
||||
let mut out = Vec::new();
|
||||
match self.verify() {
|
||||
Ok(()) => {}
|
||||
Err(StoreError::VerifyMismatch(why)) => out.push(found(
|
||||
"projection_differs",
|
||||
Subject::Books,
|
||||
format!("the books differ from a replay of the journal: {why}"),
|
||||
)),
|
||||
Err(e) => out.push(found(
|
||||
"journal_damaged",
|
||||
Subject::Books,
|
||||
format!("the journal does not verify: {e}"),
|
||||
)),
|
||||
}
|
||||
let shas = |sql: &str| -> Vec<String> {
|
||||
self.conn
|
||||
.prepare(sql)
|
||||
.and_then(|mut s| {
|
||||
s.query_map([], |r| r.get::<_, String>(0))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.unwrap_or_default()
|
||||
};
|
||||
let mut blobs = shas("SELECT sha256 FROM blobs ORDER BY sha256");
|
||||
blobs.dedup();
|
||||
for sha in blobs {
|
||||
if !self.blobs.exists(&sha) {
|
||||
out.push(found(
|
||||
"blob_missing",
|
||||
Subject::Named(sha.chars().take(12).collect()),
|
||||
format!("the stored file {sha} is not on disk"),
|
||||
));
|
||||
} else if let Err(StoreError::CorruptBlob(_)) = self.blobs.get(&sha) {
|
||||
out.push(found(
|
||||
"blob_damaged",
|
||||
Subject::Named(sha.chars().take(12).collect()),
|
||||
format!("the stored file {sha} does not match its SHA-256"),
|
||||
));
|
||||
}
|
||||
}
|
||||
if let Err(e) = crate::access::read(&self.data_dir) {
|
||||
out.push(found(
|
||||
"access_log_damaged",
|
||||
Subject::Named("access log".into()),
|
||||
format!("the access log does not verify: {e}"),
|
||||
));
|
||||
}
|
||||
for sha in shas(
|
||||
"SELECT DISTINCT text_sha256 FROM extractions WHERE text_sha256 IS NOT NULL ORDER BY text_sha256",
|
||||
) {
|
||||
if !self.blobs.exists(&sha) {
|
||||
out.push(found(
|
||||
"extraction_blob_missing",
|
||||
Subject::Named(sha.chars().take(12).collect()),
|
||||
format!("the extracted text {sha} is not on disk"),
|
||||
));
|
||||
}
|
||||
}
|
||||
let dups: Vec<(String, String)> = self
|
||||
.conn
|
||||
.prepare(
|
||||
"SELECT blob_sha, GROUP_CONCAT(id) FROM
|
||||
(SELECT blob_sha, id FROM attachments WHERE withdrawn_at IS NULL ORDER BY id)
|
||||
GROUP BY blob_sha HAVING COUNT(*) > 1 ORDER BY blob_sha",
|
||||
)
|
||||
.and_then(|mut s| {
|
||||
s.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))?
|
||||
.collect::<Result<Vec<_>, _>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for (sha, ids) in dups {
|
||||
out.push(found(
|
||||
"duplicate_blob",
|
||||
Subject::Named(sha.chars().take(12).collect()),
|
||||
format!("attachments {ids} hold the same bytes ({sha})"),
|
||||
));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Every audit run, oldest first.
|
||||
pub fn audit_runs(&self) -> Result<Vec<AuditRun>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT id, through, through_seq, scope, program_version, tb_hash, findings_sha256,
|
||||
workpaper_attachment, workpaper_pdf, seq FROM audits ORDER BY id",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, i64>(2)?,
|
||||
r.get::<_, String>(3)?,
|
||||
r.get::<_, String>(4)?,
|
||||
r.get::<_, String>(5)?,
|
||||
r.get::<_, String>(6)?,
|
||||
r.get::<_, Option<i64>>(7)?,
|
||||
r.get::<_, Option<i64>>(8)?,
|
||||
r.get::<_, i64>(9)?,
|
||||
))
|
||||
})?;
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (id, through, seq_at, scope, version, tb, fs, wp, pdf, seq) = row?;
|
||||
out.push(AuditRun {
|
||||
id: id as u64,
|
||||
through: NaiveDate::parse_from_str(&through, "%Y-%m-%d")
|
||||
.map_err(|_| StoreError::JournalCorrupt("audits.through".into()))?,
|
||||
through_seq: seq_at as u64,
|
||||
scope: Scope::parse(&scope)
|
||||
.ok_or_else(|| StoreError::JournalCorrupt("audits.scope".into()))?,
|
||||
program_version: version,
|
||||
tb_hash: tb,
|
||||
findings_sha256: fs,
|
||||
workpaper_attachment: wp.map(|v| v as u64),
|
||||
workpaper_pdf: pdf.map(|v| v as u64),
|
||||
seq: seq as u64,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// One run.
|
||||
pub fn audit_run(&self, id: u64) -> Result<Option<AuditRun>, StoreError> {
|
||||
Ok(self.audit_runs()?.into_iter().find(|r| r.id == id))
|
||||
}
|
||||
|
||||
/// The run that already answers an audit of `through` at `head`
|
||||
/// (INVARIANT 2).
|
||||
pub fn find_audit_run(
|
||||
&self,
|
||||
through: NaiveDate,
|
||||
head: u64,
|
||||
scope: Scope,
|
||||
) -> Result<Option<AuditRun>, StoreError> {
|
||||
Ok(self.audit_runs()?.into_iter().find(|r| {
|
||||
r.through == through
|
||||
&& r.through_seq == head
|
||||
&& r.scope == scope
|
||||
&& r.program_version == PROGRAM_VERSION
|
||||
}))
|
||||
}
|
||||
|
||||
/// Whether the books' figures are where `run` left them
|
||||
/// (IZ-1k): nothing bearing on a figure has been journaled since.
|
||||
pub fn audit_is_current(&self, run: &AuditRun) -> Result<bool, StoreError> {
|
||||
Ok(run.through_seq == self.figure_head()?)
|
||||
}
|
||||
|
||||
/// Record an audit run (`audit.run`). Waits for the `audits` feature.
|
||||
pub fn record_audit_run(&mut self, new: NewAuditRun) -> Result<AuditRun, StoreError> {
|
||||
self.ensure_feature(crate::format::AUDITS)?;
|
||||
if let Some(existing) = self.find_audit_run(new.through, new.through_seq, new.scope)? {
|
||||
return Ok(existing);
|
||||
}
|
||||
for a in [new.workpaper_attachment, new.workpaper_pdf]
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
self.attachment_state(a)?;
|
||||
}
|
||||
let id = self.audit_runs()?.last().map_or(1, |r| r.id + 1);
|
||||
let mut run = AuditRun {
|
||||
id,
|
||||
through: new.through,
|
||||
through_seq: new.through_seq,
|
||||
scope: new.scope,
|
||||
program_version: PROGRAM_VERSION.into(),
|
||||
tb_hash: new.tb_hash,
|
||||
findings_sha256: new.findings_sha256,
|
||||
workpaper_attachment: new.workpaper_attachment,
|
||||
workpaper_pdf: new.workpaper_pdf,
|
||||
seq: 0,
|
||||
};
|
||||
let line = self.append_journal("audit.run", crate::meta::audit_run(&run))?;
|
||||
run.seq = line.seq;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(run)
|
||||
}
|
||||
|
||||
/// Every attestation, oldest first.
|
||||
pub fn attestations(&self) -> Result<Vec<Attestation>, StoreError> {
|
||||
let mut stmt = self.conn.prepare(
|
||||
"SELECT seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts,
|
||||
method, amount_checked, date_checked, disagrees, note
|
||||
FROM attestations ORDER BY seq",
|
||||
)?;
|
||||
let rows = stmt.query_map([], |r| {
|
||||
Ok((
|
||||
r.get::<_, i64>(0)?,
|
||||
r.get::<_, String>(1)?,
|
||||
r.get::<_, Option<i64>>(2)?,
|
||||
r.get::<_, Option<i64>>(3)?,
|
||||
r.get::<_, Option<i64>>(4)?,
|
||||
r.get::<_, Option<i64>>(5)?,
|
||||
r.get::<_, String>(6)?,
|
||||
r.get::<_, String>(7)?,
|
||||
r.get::<_, String>(8)?,
|
||||
r.get::<_, Option<String>>(9)?,
|
||||
r.get::<_, Option<String>>(10)?,
|
||||
r.get::<_, Option<i64>>(11)?,
|
||||
r.get::<_, Option<String>>(12)?,
|
||||
))
|
||||
})?;
|
||||
let bad = |w: &str| StoreError::JournalCorrupt(format!("attestations.{w}"));
|
||||
let mut out = Vec::new();
|
||||
for row in rows {
|
||||
let (seq, kind, att, entry, period, run, by, at, method, amount, date, dis, note) =
|
||||
row?;
|
||||
let method: Value = serde_json::from_str(&method)?;
|
||||
let what = match kind.as_str() {
|
||||
"receipt" => Attested::Receipt {
|
||||
attachment_id: att.ok_or_else(|| bad("attachment_id"))? as u64,
|
||||
entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64,
|
||||
amount_checked: amount
|
||||
.and_then(|a| a.parse().ok())
|
||||
.ok_or_else(|| bad("amount_checked"))?,
|
||||
date_checked: date
|
||||
.and_then(|d| NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok())
|
||||
.ok_or_else(|| bad("date_checked"))?,
|
||||
disagrees: dis.unwrap_or(0) != 0,
|
||||
},
|
||||
"entry" => Attested::Entry {
|
||||
entry_id: entry.ok_or_else(|| bad("entry_id"))? as u64,
|
||||
},
|
||||
"period" => Attested::Period {
|
||||
period_id: period.ok_or_else(|| bad("period_id"))? as u64,
|
||||
audit_run_id: run.ok_or_else(|| bad("audit_run_id"))? as u64,
|
||||
},
|
||||
_ => return Err(bad("kind")),
|
||||
};
|
||||
out.push(Attestation {
|
||||
seq: seq as u64,
|
||||
by,
|
||||
at,
|
||||
method: crate::meta::method_from_value(&method)?,
|
||||
note,
|
||||
what,
|
||||
});
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
fn journal_attestation(
|
||||
&mut self,
|
||||
line_type: &str,
|
||||
a: Attestation,
|
||||
) -> Result<Attestation, StoreError> {
|
||||
let (line_type, payload) = (line_type, crate::meta::attestation(&a));
|
||||
let line = self.append_journal(line_type, payload)?;
|
||||
apply_line_to_sqlite(&self.conn, &line)?;
|
||||
Ok(Attestation { seq: line.seq, ..a })
|
||||
}
|
||||
|
||||
/// Attest that a receipt supports an entry (`receipt.attested`): the
|
||||
/// checker's reading of its amount and date against the entry's. A
|
||||
/// reading that differs is recorded as disagreeing and never counts
|
||||
/// (INVARIANT 4).
|
||||
pub fn attest_receipt(
|
||||
&mut self,
|
||||
attachment_id: u64,
|
||||
entry_id: u64,
|
||||
amount: Decimal,
|
||||
date: NaiveDate,
|
||||
claim: Claim,
|
||||
) -> Result<Attestation, StoreError> {
|
||||
self.ensure_feature(crate::format::AUDITS)?;
|
||||
let entry = self
|
||||
.ledger
|
||||
.get_entry(entry_id)
|
||||
.ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?;
|
||||
if entry.status() != ledger_core::EntryStatus::Posted {
|
||||
return Err(refused(format!("entry {entry_id} is not posted")));
|
||||
}
|
||||
let (_, withdrawn) = self.attachment_state(attachment_id)?;
|
||||
if withdrawn {
|
||||
return Err(StoreError::AttachmentWithdrawn(attachment_id));
|
||||
}
|
||||
if !self
|
||||
.attachment_links(attachment_id)?
|
||||
.contains(&crate::store::Subject::Entry(entry_id))
|
||||
{
|
||||
return Err(refused(format!(
|
||||
"attachment {attachment_id} is not filed on or linked to entry {entry_id}"
|
||||
)));
|
||||
}
|
||||
let debits = entry
|
||||
.lines()
|
||||
.iter()
|
||||
.filter(|l| l.side == ledger_core::Side::Debit)
|
||||
.try_fold(Decimal::ZERO, |s, l| s.checked_add(l.amount.amount));
|
||||
let amount_agrees =
|
||||
debits == Some(amount) || entry.lines().iter().any(|l| l.amount.amount == amount);
|
||||
let disagrees = !amount_agrees || date != entry.date();
|
||||
self.journal_attestation(
|
||||
"receipt.attested",
|
||||
claim.into_attestation(Attested::Receipt {
|
||||
attachment_id,
|
||||
entry_id,
|
||||
amount_checked: amount,
|
||||
date_checked: date,
|
||||
disagrees,
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/// Record that an entry was reviewed whole (`entry.reviewed`).
|
||||
pub fn review_entry(&mut self, entry_id: u64, claim: Claim) -> Result<Attestation, StoreError> {
|
||||
self.ensure_feature(crate::format::AUDITS)?;
|
||||
let entry = self
|
||||
.ledger
|
||||
.get_entry(entry_id)
|
||||
.ok_or(ledger_core::LedgerError::UnknownEntry(entry_id))?;
|
||||
if entry.status() != ledger_core::EntryStatus::Posted {
|
||||
return Err(refused(format!("entry {entry_id} is not posted")));
|
||||
}
|
||||
self.journal_attestation(
|
||||
"entry.reviewed",
|
||||
claim.into_attestation(Attested::Entry { entry_id }),
|
||||
)
|
||||
}
|
||||
|
||||
/// The run a period can be attested on: the latest at the current
|
||||
/// figure head whose through date is on or after the period's end
|
||||
/// (INVARIANT 3).
|
||||
pub fn run_for_period(&self, period_id: u64) -> Result<AuditRun, StoreError> {
|
||||
let period = self
|
||||
.ledger
|
||||
.get_period(period_id)
|
||||
.ok_or_else(|| refused(format!("no period {period_id}")))?;
|
||||
let head = self.figure_head()?;
|
||||
let runs = self.audit_runs()?;
|
||||
runs.into_iter()
|
||||
.rev()
|
||||
.find(|r| r.through_seq == head && r.through >= period.end)
|
||||
.ok_or_else(|| {
|
||||
refused(format!(
|
||||
"no audit through {} or later stands at the books' current head: run books.audit first (an entry, a document, a close or a settings change since the last one moves the head)",
|
||||
period.end
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
/// Attest a period on an audit run at the current head
|
||||
/// (`period.attested`).
|
||||
pub fn attest_period(
|
||||
&mut self,
|
||||
period_id: u64,
|
||||
claim: Claim,
|
||||
) -> Result<(Attestation, AuditRun), StoreError> {
|
||||
self.ensure_feature(crate::format::AUDITS)?;
|
||||
let run = self.run_for_period(period_id)?;
|
||||
let a = self.journal_attestation(
|
||||
"period.attested",
|
||||
claim.into_attestation(Attested::Period {
|
||||
period_id,
|
||||
audit_run_id: run.id,
|
||||
}),
|
||||
)?;
|
||||
Ok((a, run))
|
||||
}
|
||||
}
|
||||
|
||||
/// Read an `audits` row for a replayed line.
|
||||
pub(crate) fn insert_run(conn: &rusqlite::Connection, run: &AuditRun) -> Result<(), StoreError> {
|
||||
conn.execute(
|
||||
"INSERT INTO audits(id, through, through_seq, scope, program_version, tb_hash, findings_sha256,
|
||||
workpaper_attachment, workpaper_pdf, seq)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
run.id as i64,
|
||||
run.through.to_string(),
|
||||
run.through_seq as i64,
|
||||
run.scope.key(),
|
||||
run.program_version,
|
||||
run.tb_hash,
|
||||
run.findings_sha256,
|
||||
run.workpaper_attachment.map(|v| v as i64),
|
||||
run.workpaper_pdf.map(|v| v as i64),
|
||||
run.seq as i64,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Write an `attestations` row for a replayed line.
|
||||
pub(crate) fn insert_attestation(
|
||||
conn: &rusqlite::Connection,
|
||||
a: &Attestation,
|
||||
) -> Result<(), StoreError> {
|
||||
let (mut att, mut entry, mut period, mut run) = (None, None, None, None);
|
||||
let (mut amount, mut date, mut dis) = (None, None, None);
|
||||
match &a.what {
|
||||
Attested::Receipt {
|
||||
attachment_id,
|
||||
entry_id,
|
||||
amount_checked,
|
||||
date_checked,
|
||||
disagrees,
|
||||
} => {
|
||||
att = Some(*attachment_id as i64);
|
||||
entry = Some(*entry_id as i64);
|
||||
amount = Some(amount_checked.to_string());
|
||||
date = Some(date_checked.to_string());
|
||||
dis = Some(i64::from(*disagrees));
|
||||
}
|
||||
Attested::Entry { entry_id } => entry = Some(*entry_id as i64),
|
||||
Attested::Period {
|
||||
period_id,
|
||||
audit_run_id,
|
||||
} => {
|
||||
period = Some(*period_id as i64);
|
||||
run = Some(*audit_run_id as i64);
|
||||
}
|
||||
}
|
||||
conn.execute(
|
||||
"INSERT INTO attestations(seq, kind, attachment_id, entry_id, period_id, audit_run_id, by_who, at_ts,
|
||||
method, amount_checked, date_checked, disagrees, note)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?)",
|
||||
params![
|
||||
a.seq as i64,
|
||||
a.kind(),
|
||||
att,
|
||||
entry,
|
||||
period,
|
||||
run,
|
||||
a.by,
|
||||
a.at,
|
||||
crate::meta::method_json(&a.method).to_string(),
|
||||
amount,
|
||||
date,
|
||||
dis,
|
||||
a.note,
|
||||
],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,402 @@
|
||||
//! The journal in words: one sentence for each line, and the subjects a
|
||||
//! line is about. The one describer the server's `books.journal_history`
|
||||
//! reads.
|
||||
//!
|
||||
//! ```text
|
||||
//! GOAL: an agent can read what the books did, in order, without the
|
||||
//! journal file: each line says in a sentence what happened, to
|
||||
//! what, and which entry it belongs to.
|
||||
//! SOURCE: plan/served.md P5 step 4 (the only way to "read the journal");
|
||||
//! docs/receipt-store.md (the lines and their payloads);
|
||||
//! docs/spec/rules.md B-26 (no output holds a full TIN).
|
||||
//! INVARIANTS:
|
||||
//! 1. Every line type the store writes has a sentence (test: every
|
||||
//! `format::EVENT_TYPES` entry).
|
||||
//! 2. A sentence quotes only identifiers, dates, amounts and the
|
||||
//! short names and descriptions a person typed; never a setting's
|
||||
//! value, a payload's secret, or a full TIN (they are masked).
|
||||
//! 3. Lines are returned in journal order, from the line after
|
||||
//! `after_seq`, at most `limit`; a filter never reorders them.
|
||||
//! 4. A subject is `kind:id` (`entry:12`, `invoice:3`, `bill:3`,
|
||||
//! `account:10`, `party:5`, `asset:2`); a line is about the subject
|
||||
//! its type names and every one its payload refers to.
|
||||
//! ```
|
||||
|
||||
use crate::journal::JournalLine;
|
||||
use serde_json::Value;
|
||||
|
||||
/// The most characters of any quoted text.
|
||||
const QUOTE_MAX: usize = 80;
|
||||
|
||||
/// One line of the journal, described.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct Described {
|
||||
/// The line's seq.
|
||||
pub seq: u64,
|
||||
/// When it was written (UTC, RFC 3339).
|
||||
pub ts: String,
|
||||
/// Its type.
|
||||
pub line_type: String,
|
||||
/// A sentence.
|
||||
pub summary: String,
|
||||
/// The subjects it is about, as `kind:id`.
|
||||
pub subjects: Vec<String>,
|
||||
/// The entry it belongs to, if it is about one.
|
||||
pub entry_id: Option<u64>,
|
||||
}
|
||||
|
||||
/// Which lines to list (INVARIANT 3).
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct HistoryFilter {
|
||||
/// Only lines after this seq.
|
||||
pub after_seq: u64,
|
||||
/// Only these types: an exact type (`entry.posted`) or a group ending
|
||||
/// in a dot (`entry.`). Empty: every type.
|
||||
pub types: Vec<String>,
|
||||
/// Only lines about this subject (`entry:12`).
|
||||
pub subject: Option<String>,
|
||||
/// At most this many.
|
||||
pub limit: usize,
|
||||
}
|
||||
|
||||
/// A subject as `kind:id`, parsed.
|
||||
pub fn parse_subject(text: &str) -> Result<(String, u64), String> {
|
||||
let bad = || {
|
||||
format!(
|
||||
"subject {text:?}: give kind:id, like entry:12, invoice:3, bill:3, account:10, party:5 or asset:2"
|
||||
)
|
||||
};
|
||||
let (kind, id) = text.split_once(':').ok_or_else(bad)?;
|
||||
let kinds = [
|
||||
"entry",
|
||||
"invoice",
|
||||
"bill",
|
||||
"account",
|
||||
"party",
|
||||
"asset",
|
||||
"period",
|
||||
"schedule",
|
||||
"reconciliation",
|
||||
"payroll_run",
|
||||
"project",
|
||||
];
|
||||
if !kinds.contains(&kind.trim()) {
|
||||
return Err(bad());
|
||||
}
|
||||
let id = id.trim().parse::<u64>().map_err(|_| bad())?;
|
||||
Ok((kind.trim().to_string(), id))
|
||||
}
|
||||
|
||||
fn num(p: &Value, k: &str) -> Option<u64> {
|
||||
p.get(k).and_then(Value::as_u64)
|
||||
}
|
||||
|
||||
fn quote(text: &str) -> String {
|
||||
let t: String = text.split_whitespace().collect::<Vec<_>>().join(" ");
|
||||
if t.chars().count() > QUOTE_MAX {
|
||||
let mut s: String = t.chars().take(QUOTE_MAX - 1).collect();
|
||||
s.push('…');
|
||||
s
|
||||
} else {
|
||||
t
|
||||
}
|
||||
}
|
||||
|
||||
/// The subjects a line is about (INVARIANT 4).
|
||||
pub fn subjects(line: &JournalLine) -> Vec<String> {
|
||||
let p = &line.payload;
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut add = |kind: &str, id: Option<u64>| {
|
||||
if let Some(id) = id {
|
||||
let s = format!("{kind}:{id}");
|
||||
if !out.contains(&s) {
|
||||
out.push(s);
|
||||
}
|
||||
}
|
||||
};
|
||||
let prefix = line.line_type.split('.').next().unwrap_or("");
|
||||
let own = match prefix {
|
||||
"entry" => Some("entry"),
|
||||
"invoice" => Some("invoice"),
|
||||
"bill" => Some("bill"),
|
||||
"account" => Some("account"),
|
||||
"party" => Some("party"),
|
||||
"asset" => Some("asset"),
|
||||
"period" => Some("period"),
|
||||
"schedule" => Some("schedule"),
|
||||
"reconciliation" => Some("reconciliation"),
|
||||
"payroll" => Some("payroll_run"),
|
||||
"project" => Some("project"),
|
||||
_ => None,
|
||||
};
|
||||
if let Some(kind) = own {
|
||||
// `entry.posted` carries `entry_id`; `entry.created` carries `id`.
|
||||
let id = match prefix {
|
||||
"entry" => num(p, "entry_id").or_else(|| num(p, "id")),
|
||||
"invoice" => num(p, "invoice_id").or_else(|| num(p, "id")),
|
||||
"bill" => num(p, "bill_id").or_else(|| num(p, "id")),
|
||||
"asset" => num(p, "asset_id").or_else(|| num(p, "id")),
|
||||
"party" => num(p, "party_id").or_else(|| num(p, "id")),
|
||||
_ => num(p, "id"),
|
||||
};
|
||||
add(kind, id);
|
||||
}
|
||||
add("entry", num(p, "entry_id"));
|
||||
add("invoice", num(p, "invoice_id"));
|
||||
add("bill", num(p, "bill_id"));
|
||||
add("account", num(p, "account_id"));
|
||||
add("asset", num(p, "asset_id"));
|
||||
add("party", num(p, "party_id"));
|
||||
if line.line_type == "receipt.attached" || line.line_type == "receipt.linked" {
|
||||
add("entry", num(p, "entry_id"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// A line's amount, when it has one: a string, or `{amount: ..}`.
|
||||
fn amount_of(p: &Value) -> Option<String> {
|
||||
let a = p.get("amount")?;
|
||||
a.as_str()
|
||||
.map(str::to_string)
|
||||
.or_else(|| a.get("amount").and_then(Value::as_str).map(str::to_string))
|
||||
}
|
||||
|
||||
/// The sentence for a line (INVARIANT 2).
|
||||
pub fn describe(line: &JournalLine) -> String {
|
||||
let p = &line.payload;
|
||||
let ty = line.line_type.as_str();
|
||||
let mut head = ty.replace(['.', '_'], " ");
|
||||
if let Some(first) = head.get(0..1) {
|
||||
head = first.to_uppercase() + &head[1..];
|
||||
}
|
||||
let mut parts: Vec<String> = Vec::new();
|
||||
// What it is about, by id.
|
||||
for (k, label) in [
|
||||
("entry_id", "entry"),
|
||||
("invoice_id", "invoice"),
|
||||
("bill_id", "bill"),
|
||||
("account_id", "account"),
|
||||
("asset_id", "asset"),
|
||||
("party_id", "party"),
|
||||
("attachment_id", "attachment"),
|
||||
] {
|
||||
if let Some(n) = num(p, k) {
|
||||
parts.push(format!("{label} {n}"));
|
||||
}
|
||||
}
|
||||
if parts.is_empty()
|
||||
&& let Some(n) = num(p, "id")
|
||||
{
|
||||
parts.push(format!("#{n}"));
|
||||
}
|
||||
// What a person typed about it.
|
||||
for k in ["description", "title", "name", "subject", "code"] {
|
||||
if let Some(t) = p.get(k).and_then(Value::as_str)
|
||||
&& !t.is_empty()
|
||||
{
|
||||
parts.push(quote(t));
|
||||
break;
|
||||
}
|
||||
}
|
||||
if let Some(a) = amount_of(p) {
|
||||
parts.push(a);
|
||||
}
|
||||
if let Some(d) = p.get("date").and_then(Value::as_str) {
|
||||
parts.push(d.to_string());
|
||||
}
|
||||
// Settings: which keys, never their values.
|
||||
if ty == "settings.set"
|
||||
&& let Some(changes) = p.get("changes").and_then(Value::as_array)
|
||||
{
|
||||
let keys: Vec<&str> = changes
|
||||
.iter()
|
||||
.filter_map(|c| c.get("key").and_then(Value::as_str))
|
||||
.take(8)
|
||||
.collect();
|
||||
parts.push(keys.join(", "));
|
||||
}
|
||||
let text = if parts.is_empty() {
|
||||
head
|
||||
} else {
|
||||
format!("{head}: {}", parts.join(", "))
|
||||
};
|
||||
ledger_core::parties::redact_full_tins(&text).into_owned()
|
||||
}
|
||||
|
||||
/// Describe one line.
|
||||
pub fn describe_line(line: &JournalLine) -> Described {
|
||||
let subjects = subjects(line);
|
||||
let entry_id = subjects
|
||||
.iter()
|
||||
.find_map(|s| s.strip_prefix("entry:").and_then(|n| n.parse().ok()));
|
||||
Described {
|
||||
seq: line.seq,
|
||||
ts: line.ts.clone(),
|
||||
line_type: line.line_type.clone(),
|
||||
summary: describe(line),
|
||||
subjects,
|
||||
entry_id,
|
||||
}
|
||||
}
|
||||
|
||||
/// The lines `filter` asks for, described, in journal order (INVARIANT 3),
|
||||
/// and whether more match after the last one returned.
|
||||
pub fn history(lines: &[JournalLine], filter: &HistoryFilter) -> (Vec<Described>, bool) {
|
||||
let wanted = |l: &JournalLine| {
|
||||
l.seq > filter.after_seq
|
||||
&& (filter.types.is_empty()
|
||||
|| filter.types.iter().any(|t| {
|
||||
if t.ends_with('.') {
|
||||
l.line_type.starts_with(t.as_str())
|
||||
} else {
|
||||
l.line_type == *t
|
||||
}
|
||||
}))
|
||||
&& filter
|
||||
.subject
|
||||
.as_ref()
|
||||
.is_none_or(|s| subjects(l).contains(s))
|
||||
};
|
||||
let mut out = Vec::new();
|
||||
let mut more = false;
|
||||
for l in lines.iter().filter(|l| wanted(l)) {
|
||||
if out.len() == filter.limit {
|
||||
more = true;
|
||||
break;
|
||||
}
|
||||
out.push(describe_line(l));
|
||||
}
|
||||
(out, more)
|
||||
}
|
||||
|
||||
/// The journal in words, read from the books' own journal file.
|
||||
#[derive(Clone, Debug, PartialEq)]
|
||||
pub struct History {
|
||||
/// The lines asked for, in order.
|
||||
pub lines: Vec<Described>,
|
||||
/// Whether more match after the last.
|
||||
pub more: bool,
|
||||
/// The journal's last seq.
|
||||
pub head_seq: u64,
|
||||
/// Torn tails set aside on open: (when, bytes).
|
||||
pub tail_repairs: Vec<(String, u64)>,
|
||||
}
|
||||
|
||||
impl crate::Store {
|
||||
/// `filter`'s lines of the journal, described (the only way an agent
|
||||
/// reads the journal: `books.journal_history`). The file is read and
|
||||
/// its chain verified, as `Store::verify` does.
|
||||
pub fn journal_history(&self, filter: &HistoryFilter) -> Result<History, crate::StoreError> {
|
||||
let (lines, _format) = crate::journal::read_journal_and_format(
|
||||
&self.data_dir().join(crate::store::JOURNAL_FILE),
|
||||
)?;
|
||||
let (described, more) = history(&lines, filter);
|
||||
let tail_repairs = self
|
||||
.tail_repairs()?
|
||||
.into_iter()
|
||||
.map(|(when, bytes, _saved_as)| (when, bytes))
|
||||
.collect();
|
||||
Ok(History {
|
||||
lines: described,
|
||||
more,
|
||||
head_seq: lines.last().map_or(0, |l| l.seq),
|
||||
tail_repairs,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn line(seq: u64, ty: &str, payload: Value) -> JournalLine {
|
||||
JournalLine {
|
||||
seq,
|
||||
ts: "2026-10-29T00:00:00Z".into(),
|
||||
line_type: ty.into(),
|
||||
payload,
|
||||
prev: String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_event_type_has_a_sentence() {
|
||||
for ty in crate::format::EVENT_TYPES {
|
||||
let s = describe(&line(1, ty, json!({})));
|
||||
assert!(!s.is_empty(), "{ty}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_sentence_names_the_entry_and_what_a_person_typed() {
|
||||
let l = line(
|
||||
5,
|
||||
"entry.posted",
|
||||
json!({"entry_id": 12, "description": "Office supplies"}),
|
||||
);
|
||||
assert_eq!(describe(&l), "Entry posted: entry 12, Office supplies");
|
||||
assert_eq!(subjects(&l), vec!["entry:12"]);
|
||||
assert_eq!(describe_line(&l).entry_id, Some(12));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn settings_show_their_keys_never_their_values() {
|
||||
let l = line(
|
||||
2,
|
||||
"settings.set",
|
||||
json!({"changes": [{"key": "company.ein", "value": "12-3456789"}]}),
|
||||
);
|
||||
let s = describe(&l);
|
||||
assert!(
|
||||
s.contains("company.ein") && !s.contains("12-3456789"),
|
||||
"{s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_full_tin_in_a_description_is_masked() {
|
||||
let l = line(
|
||||
3,
|
||||
"entry.created",
|
||||
json!({"id": 1, "description": "TIN 123-45-6789"}),
|
||||
);
|
||||
assert!(!describe(&l).contains("6789"), "{}", describe(&l));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_filters_in_order_and_says_when_more_remain() {
|
||||
let lines: Vec<JournalLine> = (1..=6)
|
||||
.map(|i| {
|
||||
line(
|
||||
i,
|
||||
if i % 2 == 0 {
|
||||
"entry.posted"
|
||||
} else {
|
||||
"entry.created"
|
||||
},
|
||||
json!({"entry_id": i % 3, "id": i % 3}),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
let f = HistoryFilter {
|
||||
after_seq: 1,
|
||||
types: vec!["entry.posted".into()],
|
||||
subject: None,
|
||||
limit: 2,
|
||||
};
|
||||
let (got, more) = history(&lines, &f);
|
||||
assert_eq!(got.iter().map(|d| d.seq).collect::<Vec<_>>(), vec![2, 4]);
|
||||
assert!(more);
|
||||
let f = HistoryFilter {
|
||||
subject: Some("entry:0".into()),
|
||||
limit: 10,
|
||||
..Default::default()
|
||||
};
|
||||
let (got, more) = history(&lines, &f);
|
||||
assert_eq!(got.iter().map(|d| d.seq).collect::<Vec<_>>(), vec![3, 6]);
|
||||
assert!(!more);
|
||||
assert!(parse_subject("entry:12").is_ok());
|
||||
assert!(parse_subject("nonsense").is_err());
|
||||
}
|
||||
}
|
||||
@@ -162,6 +162,12 @@ pub enum StoreError {
|
||||
)]
|
||||
WriteOffNeedsEvidence,
|
||||
|
||||
/// An audit or an attestation the books refuse (plan P8; rules.md
|
||||
/// B-45): a stale head, an entry not posted, a receipt not on the
|
||||
/// entry. The words say what to do.
|
||||
#[error("{0}")]
|
||||
AuditRefused(String),
|
||||
|
||||
/// The requested base currency differs from the store's.
|
||||
#[error("base currency mismatch: store is {0}, requested {1}")]
|
||||
CurrencyMismatch(String, String),
|
||||
|
||||
@@ -65,6 +65,8 @@ pub const KNOWN_FEATURES: &[&str] = &[
|
||||
DOCUMENTS_V2,
|
||||
ATTACHMENT_TEXT,
|
||||
DRAFTS_V2,
|
||||
SOURCE_REVISIONS,
|
||||
AUDITS,
|
||||
];
|
||||
|
||||
/// Feature: `period.closed` closes to members with `"rounding":
|
||||
@@ -104,6 +106,9 @@ pub(crate) fn setting_feature(key: &str) -> Option<&'static str> {
|
||||
| "bills.discount_warn_days"
|
||||
| "company.email"
|
||||
| "company.website" => Some(DOCUMENTS_V2),
|
||||
// P8: what counts as an attestation, and whether the CPA package
|
||||
// waits for an audit.
|
||||
"policy.attestation_bar" | "policy.cpa_package_requires_audit" => Some(AUDITS),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -218,6 +223,26 @@ 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";
|
||||
|
||||
/// Feature: `audits` (plan P8): `audit.run` (a programme of checks run
|
||||
/// through a date, with the hash of its findings and its workpaper),
|
||||
/// `receipt.attested`, `entry.reviewed` and `period.attested` (claims by
|
||||
/// an authenticated client that it checked), and the two settings that
|
||||
/// say what counts (`policy.attestation_bar`) and whether the CPA package
|
||||
/// waits for an audit (`policy.cpa_package_requires_audit`). An older
|
||||
/// version doesn't know those event types or settings; it stops at the
|
||||
/// declaration instead of dropping the claims a handoff rests on
|
||||
/// (rules.md B-27, B-45).
|
||||
pub const AUDITS: &str = "audits";
|
||||
|
||||
/// The line type that declares the journal format.
|
||||
pub const FORMAT_SET: &str = "journal.format_set";
|
||||
|
||||
@@ -243,13 +268,16 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"attachment.text_extracted",
|
||||
"attachment.unlinked",
|
||||
"attachment.withdrawn",
|
||||
"audit.run",
|
||||
"bill.created",
|
||||
"bill.paid",
|
||||
"class.added",
|
||||
"convention.retired",
|
||||
"convention.set",
|
||||
"convention.source_revised",
|
||||
"customer.added",
|
||||
"decision.recorded",
|
||||
"decision.source_revised",
|
||||
"document.filed",
|
||||
"entry.approved",
|
||||
"entry.created",
|
||||
@@ -263,6 +291,7 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"entry.party_set",
|
||||
"entry.posted",
|
||||
"entry.reversed",
|
||||
"entry.reviewed",
|
||||
"entry.settles",
|
||||
"entry.vendor_set",
|
||||
"fx.rate_set",
|
||||
@@ -276,6 +305,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",
|
||||
@@ -285,10 +315,12 @@ pub const EVENT_TYPES: &[&str] = &[
|
||||
"party.w9_set",
|
||||
"payroll.run",
|
||||
"payroll.taxes_remitted",
|
||||
"period.attested",
|
||||
"period.closed",
|
||||
"period.created",
|
||||
"project.set",
|
||||
"receipt.attached",
|
||||
"receipt.attested",
|
||||
"receipt.linked",
|
||||
"receipt.unlinked",
|
||||
"reconciliation.completed",
|
||||
@@ -989,6 +1021,72 @@ 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"]);
|
||||
}
|
||||
|
||||
/// The version before P8 stops at a declaration of `audits` as newer,
|
||||
/// never dropping an attestation a handoff rests on; books that
|
||||
/// declared every earlier feature still wait for it (B-27, D-39).
|
||||
#[test]
|
||||
fn audits_is_its_own_feature() {
|
||||
assert!(KNOWN_FEATURES.contains(&AUDITS));
|
||||
let before: Vec<&str> = KNOWN_FEATURES
|
||||
.iter()
|
||||
.copied()
|
||||
.filter(|f| *f != AUDITS)
|
||||
.collect();
|
||||
let l = line(
|
||||
FORMAT_SET,
|
||||
json!({"format": 2, "features": [DRAFTS_V2, AUDITS]}),
|
||||
);
|
||||
match declared_knowing(&l, &before) {
|
||||
Err(StoreError::NewerJournal { need, .. }) => {
|
||||
assert_eq!(need, "journal features audits")
|
||||
}
|
||||
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(AUDITS),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
let up = older.upgrade().expect("waits for it");
|
||||
assert_eq!(up.adds(), vec!["the journal feature audits"]);
|
||||
}
|
||||
|
||||
/// This version's format: the newest it reads, every feature it
|
||||
/// knows, each once, sorted (what new books and an upgrade declare).
|
||||
#[test]
|
||||
|
||||
@@ -23,6 +23,7 @@ pub mod auth;
|
||||
pub mod backup;
|
||||
pub mod blob;
|
||||
pub mod chain;
|
||||
pub mod describe;
|
||||
pub mod error;
|
||||
pub mod exports;
|
||||
pub mod form1099;
|
||||
@@ -49,6 +50,7 @@ pub use error::StoreError;
|
||||
pub use format::{FormatUpgrade, JournalFormat, SUPPORTED_JOURNAL_FORMAT};
|
||||
pub use journal::{GENESIS_PREV, JournalLine, JournalWriter};
|
||||
pub use snapshot::{DiskCheck, Snapshot};
|
||||
pub use store::audit;
|
||||
pub use store::{
|
||||
Attachment, BillPreview, DepreciationRun, DisposalPreview, EntryRow, EventRow, Extraction,
|
||||
ExtractionStatus, InvoicePreview, JOURNAL_FILE, LEGACY_DB_FILE, LOCK_FILE, MAX_RECURRING_RUN,
|
||||
|
||||
@@ -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
|
||||
@@ -662,6 +729,198 @@ pub fn draft_discarded_of(p: &Value) -> Result<(u64, String), StoreError> {
|
||||
Ok((u64_of(p, E, "id")?, str_of(p, E, "reason")?))
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Audits and attestations (plan P8, feature `audits`)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// A method as journaled: only the parts the client gave.
|
||||
pub fn method_json(m: &ledger_core::audit::Method) -> Value {
|
||||
let mut o = Map::new();
|
||||
put(&mut o, "model", m.model.clone());
|
||||
put(
|
||||
&mut o,
|
||||
"reasoning_effort",
|
||||
m.reasoning_effort.map(|e| e.key()),
|
||||
);
|
||||
put(&mut o, "adversarial", m.adversarial);
|
||||
put(&mut o, "verifier_agents", m.verifier_agents);
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read a method. A key or an effort this version doesn't know is newer
|
||||
/// when a line is at hand (B-27), else corrupt.
|
||||
fn method_of(
|
||||
line: Option<&crate::journal::JournalLine>,
|
||||
m: &Value,
|
||||
event: &str,
|
||||
) -> Result<ledger_core::audit::Method, StoreError> {
|
||||
let o = m.as_object().ok_or_else(|| corrupt(event, "method"))?;
|
||||
let unknown = |what: String| match line {
|
||||
Some(l) => crate::format::newer_content(l, what),
|
||||
None => corrupt(event, "method"),
|
||||
};
|
||||
let mut out = ledger_core::audit::Method::default();
|
||||
for (k, v) in o {
|
||||
match k.as_str() {
|
||||
"model" => {
|
||||
out.model = Some(
|
||||
v.as_str()
|
||||
.ok_or_else(|| corrupt(event, "method.model"))?
|
||||
.to_string(),
|
||||
)
|
||||
}
|
||||
"reasoning_effort" => {
|
||||
let s = v
|
||||
.as_str()
|
||||
.ok_or_else(|| corrupt(event, "method.reasoning_effort"))?;
|
||||
out.reasoning_effort = Some(
|
||||
ledger_core::audit::Effort::parse(s)
|
||||
.ok_or_else(|| unknown(format!("the reasoning effort {s:?}")))?,
|
||||
);
|
||||
}
|
||||
"adversarial" => {
|
||||
out.adversarial = Some(
|
||||
v.as_bool()
|
||||
.ok_or_else(|| corrupt(event, "method.adversarial"))?,
|
||||
)
|
||||
}
|
||||
"verifier_agents" => {
|
||||
out.verifier_agents = Some(
|
||||
v.as_u64()
|
||||
.and_then(|n| u32::try_from(n).ok())
|
||||
.ok_or_else(|| corrupt(event, "method.verifier_agents"))?,
|
||||
)
|
||||
}
|
||||
other => return Err(unknown(format!("the method key {other:?}"))),
|
||||
}
|
||||
}
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// A method read back from the projection.
|
||||
pub fn method_from_value(m: &Value) -> Result<ledger_core::audit::Method, StoreError> {
|
||||
method_of(None, m, "attestations")
|
||||
}
|
||||
|
||||
/// `audit.run {id, through, through_seq, scope, program_version, tb_hash,
|
||||
/// findings_sha256, workpaper_attachment?, workpaper_pdf?}`.
|
||||
pub fn audit_run(r: &crate::audit::AuditRun) -> Value {
|
||||
let mut o = Map::new();
|
||||
o.insert("id".into(), json!(r.id));
|
||||
o.insert("through".into(), json!(r.through.to_string()));
|
||||
o.insert("through_seq".into(), json!(r.through_seq));
|
||||
o.insert("scope".into(), json!(r.scope.key()));
|
||||
o.insert("program_version".into(), json!(r.program_version));
|
||||
o.insert("tb_hash".into(), json!(r.tb_hash));
|
||||
o.insert("findings_sha256".into(), json!(r.findings_sha256));
|
||||
put(&mut o, "workpaper_attachment", r.workpaper_attachment);
|
||||
put(&mut o, "workpaper_pdf", r.workpaper_pdf);
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read an `audit.run`. A scope this version doesn't know is newer.
|
||||
pub fn audit_run_of(
|
||||
line: &crate::journal::JournalLine,
|
||||
) -> Result<crate::audit::AuditRun, StoreError> {
|
||||
const E: &str = "audit.run";
|
||||
let p = &line.payload;
|
||||
let scope_key = str_of(p, E, "scope")?;
|
||||
let scope = ledger_core::audit::Scope::parse(&scope_key).ok_or_else(|| {
|
||||
crate::format::newer_content(line, format!("the audit scope {scope_key:?}"))
|
||||
})?;
|
||||
Ok(crate::audit::AuditRun {
|
||||
id: u64_of(p, E, "id")?,
|
||||
through: date_of(&str_of(p, E, "through")?, E, "through")?,
|
||||
through_seq: u64_of(p, E, "through_seq")?,
|
||||
scope,
|
||||
program_version: str_of(p, E, "program_version")?,
|
||||
tb_hash: str_of(p, E, "tb_hash")?,
|
||||
findings_sha256: str_of(p, E, "findings_sha256")?,
|
||||
workpaper_attachment: opt_u64(p, E, "workpaper_attachment")?,
|
||||
workpaper_pdf: opt_u64(p, E, "workpaper_pdf")?,
|
||||
seq: line.seq,
|
||||
})
|
||||
}
|
||||
|
||||
/// The payload of `receipt.attested`, `entry.reviewed` or `period.attested`.
|
||||
pub fn attestation(a: &crate::audit::Attestation) -> Value {
|
||||
use crate::audit::Attested;
|
||||
let mut o = Map::new();
|
||||
match &a.what {
|
||||
Attested::Receipt {
|
||||
attachment_id,
|
||||
entry_id,
|
||||
amount_checked,
|
||||
date_checked,
|
||||
disagrees,
|
||||
} => {
|
||||
o.insert("attachment_id".into(), json!(attachment_id));
|
||||
o.insert("entry_id".into(), json!(entry_id));
|
||||
o.insert("amount_checked".into(), json!(amount_checked.to_string()));
|
||||
o.insert("date_checked".into(), json!(date_checked.to_string()));
|
||||
o.insert("disagrees".into(), json!(disagrees));
|
||||
}
|
||||
Attested::Entry { entry_id } => {
|
||||
o.insert("entry_id".into(), json!(entry_id));
|
||||
}
|
||||
Attested::Period {
|
||||
period_id,
|
||||
audit_run_id,
|
||||
} => {
|
||||
o.insert("period_id".into(), json!(period_id));
|
||||
o.insert("audit_run_id".into(), json!(audit_run_id));
|
||||
}
|
||||
}
|
||||
o.insert("by".into(), json!(a.by));
|
||||
o.insert("at".into(), json!(a.at));
|
||||
o.insert("method".into(), method_json(&a.method));
|
||||
put(&mut o, "note", a.note.clone());
|
||||
Value::Object(o)
|
||||
}
|
||||
|
||||
/// Read one of the three attestation events by its line.
|
||||
pub fn attestation_of(
|
||||
line: &crate::journal::JournalLine,
|
||||
) -> Result<crate::audit::Attestation, StoreError> {
|
||||
use crate::audit::Attested;
|
||||
let p = &line.payload;
|
||||
let e = line.line_type.as_str();
|
||||
let what = match e {
|
||||
"receipt.attested" => Attested::Receipt {
|
||||
attachment_id: u64_of(p, e, "attachment_id")?,
|
||||
entry_id: u64_of(p, e, "entry_id")?,
|
||||
amount_checked: str_of(p, e, "amount_checked")?
|
||||
.parse()
|
||||
.map_err(|_| corrupt(e, "amount_checked"))?,
|
||||
date_checked: date_of(&str_of(p, e, "date_checked")?, e, "date_checked")?,
|
||||
disagrees: p
|
||||
.get("disagrees")
|
||||
.and_then(Value::as_bool)
|
||||
.ok_or_else(|| corrupt(e, "disagrees"))?,
|
||||
},
|
||||
"entry.reviewed" => Attested::Entry {
|
||||
entry_id: u64_of(p, e, "entry_id")?,
|
||||
},
|
||||
"period.attested" => Attested::Period {
|
||||
period_id: u64_of(p, e, "period_id")?,
|
||||
audit_run_id: u64_of(p, e, "audit_run_id")?,
|
||||
},
|
||||
_ => return Err(crate::format::unknown_type(line)),
|
||||
};
|
||||
Ok(crate::audit::Attestation {
|
||||
seq: line.seq,
|
||||
by: str_of(p, e, "by")?,
|
||||
at: str_of(p, e, "at")?,
|
||||
method: method_of(
|
||||
Some(line),
|
||||
p.get("method").ok_or_else(|| corrupt(e, "method"))?,
|
||||
e,
|
||||
)?,
|
||||
note: opt_str(p, e, "note")?,
|
||||
what,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
//! is the current version.
|
||||
|
||||
/// Current schema version.
|
||||
pub const SCHEMA_VERSION: u32 = 19;
|
||||
pub const SCHEMA_VERSION: u32 = 20;
|
||||
|
||||
/// Migrations, in order. `MIGRATIONS[i]` migrates from version `i` to `i+1`.
|
||||
pub const MIGRATIONS: &[&str] = &[
|
||||
@@ -485,5 +485,41 @@ CREATE TABLE IF NOT EXISTS draft_states(
|
||||
state TEXT NOT NULL,
|
||||
note TEXT NULL
|
||||
);
|
||||
"#,
|
||||
r#"
|
||||
-- v20: audits and attestations (plan P8, feature `audits`). An audit run
|
||||
-- is a row per `audit.run`: the date it covers, the figure head it was
|
||||
-- made at, the hashes that make it reproducible and the filed workpaper.
|
||||
-- An attestation is a row per `receipt.attested`, `entry.reviewed` or
|
||||
-- `period.attested`; whether one counts is derived against
|
||||
-- `policy.attestation_bar` when asked and never stored. The method is the
|
||||
-- client's own claim, kept as canonical JSON. Amounts stay TEXT.
|
||||
CREATE TABLE IF NOT EXISTS audits(
|
||||
id INTEGER PRIMARY KEY,
|
||||
through TEXT NOT NULL,
|
||||
through_seq INTEGER NOT NULL,
|
||||
scope TEXT NOT NULL,
|
||||
program_version TEXT NOT NULL,
|
||||
tb_hash TEXT NOT NULL,
|
||||
findings_sha256 TEXT NOT NULL,
|
||||
workpaper_attachment INTEGER NULL,
|
||||
workpaper_pdf INTEGER NULL,
|
||||
seq INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS attestations(
|
||||
seq INTEGER PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
attachment_id INTEGER NULL,
|
||||
entry_id INTEGER NULL,
|
||||
period_id INTEGER NULL,
|
||||
audit_run_id INTEGER NULL,
|
||||
by_who TEXT NOT NULL,
|
||||
at_ts TEXT NOT NULL,
|
||||
method TEXT NOT NULL,
|
||||
amount_checked TEXT NULL,
|
||||
date_checked TEXT NULL,
|
||||
disagrees INTEGER NULL,
|
||||
note TEXT NULL
|
||||
);
|
||||
"#,
|
||||
];
|
||||
@@ -51,6 +51,9 @@ use rust_decimal::Decimal;
|
||||
use serde_json::{Value, json};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[path = "audit.rs"]
|
||||
pub mod audit;
|
||||
|
||||
/// File names inside the data directory.
|
||||
pub const JOURNAL_FILE: &str = "journal.jsonl";
|
||||
|
||||
@@ -1117,6 +1120,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(
|
||||
@@ -7533,6 +7597,21 @@ fn replay_line(
|
||||
let a = crate::meta::annotated_of(line)?;
|
||||
ledger.replay_annotate_attachment(a)?;
|
||||
}
|
||||
"audit.run" => {
|
||||
// Not accounting state; the projection records it. Checked
|
||||
// for shape here (an unknown scope is newer, rules.md B-27).
|
||||
crate::meta::audit_run_of(line)?;
|
||||
}
|
||||
// A claim that someone checked; no figure moves.
|
||||
"receipt.attested" => {
|
||||
crate::meta::attestation_of(line)?;
|
||||
}
|
||||
"entry.reviewed" => {
|
||||
crate::meta::attestation_of(line)?;
|
||||
}
|
||||
"period.attested" => {
|
||||
crate::meta::attestation_of(line)?;
|
||||
}
|
||||
"entry.settles" => {
|
||||
let (entry, settles) = crate::meta::entry_settles_of(p)?;
|
||||
ledger.replay_set_entry_settles(entry, settles)?;
|
||||
@@ -7945,6 +8024,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)?;
|
||||
@@ -8736,6 +8827,22 @@ fn apply_line_rows(conn: &Connection, line: &JournalLine) -> Result<(), StoreErr
|
||||
],
|
||||
)?;
|
||||
}
|
||||
"audit.run" => {
|
||||
let run = crate::meta::audit_run_of(line)?;
|
||||
audit::insert_run(conn, &run)?;
|
||||
}
|
||||
"receipt.attested" => {
|
||||
let a = crate::meta::attestation_of(line)?;
|
||||
audit::insert_attestation(conn, &a)?;
|
||||
}
|
||||
"entry.reviewed" => {
|
||||
let a = crate::meta::attestation_of(line)?;
|
||||
audit::insert_attestation(conn, &a)?;
|
||||
}
|
||||
"period.attested" => {
|
||||
let a = crate::meta::attestation_of(line)?;
|
||||
audit::insert_attestation(conn, &a)?;
|
||||
}
|
||||
"entry.settles" => {
|
||||
// Rebuilt into the ledger by replay; no report rows.
|
||||
}
|
||||
@@ -9042,6 +9149,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(
|
||||
|
||||
@@ -0,0 +1,612 @@
|
||||
//! Audits and attestations in the store (plan P8; rules.md B-45): the
|
||||
//! `audits` feature, the figure head, idempotent runs, attestations that
|
||||
//! rest on a head, and the integrity findings only the store can see.
|
||||
|
||||
use chrono::NaiveDate;
|
||||
use ledger_core::audit::{Effort, Method, Scope};
|
||||
use ledger_core::{AccountType, Currency, Money, Side};
|
||||
use ledger_store::audit::{self, Attested, BackupStanding, Claim, NewAuditRun, backup_findings};
|
||||
use ledger_store::format::{AUDITS, JournalFormat};
|
||||
use ledger_store::{BlobStore, JOURNAL_FILE, JournalWriter, Store, StoreError};
|
||||
use rust_decimal::Decimal;
|
||||
use serde_json::json;
|
||||
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!("wb-audit-{name}-{}", std::process::id()));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
TestDir(dir)
|
||||
}
|
||||
|
||||
fn d(m: u32, day: u32) -> NaiveDate {
|
||||
NaiveDate::from_ymd_opt(2026, m, day).unwrap()
|
||||
}
|
||||
|
||||
fn usd(s: &str) -> Money {
|
||||
Money::new(s.parse().unwrap(), Currency::USD)
|
||||
}
|
||||
|
||||
struct Fx {
|
||||
bank: u64,
|
||||
capital: u64,
|
||||
supplies: u64,
|
||||
/// A posted 40.00 purchase.
|
||||
entry: u64,
|
||||
}
|
||||
|
||||
/// A bank, capital and an expense account, and a posted 40.00 purchase on
|
||||
/// the 5th.
|
||||
fn books(dir: &std::path::Path, format: Option<&JournalFormat>) -> (Store, Fx) {
|
||||
let mut s = match format {
|
||||
Some(f) => Store::create_in_format(dir, Currency::USD, f).unwrap(),
|
||||
None => 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 supplies = s
|
||||
.add_account("6000", "Supplies", AccountType::Expense, None, false)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(1, 1), "Opening").unwrap();
|
||||
s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap();
|
||||
s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let entry = s.create_entry(d(1, 5), "Printer paper").unwrap();
|
||||
s.add_line(entry, supplies, Side::Debit, usd("40.00"))
|
||||
.unwrap();
|
||||
s.add_line(entry, bank, Side::Credit, usd("40.00")).unwrap();
|
||||
s.post_entry(entry).unwrap();
|
||||
(
|
||||
s,
|
||||
Fx {
|
||||
bank,
|
||||
capital,
|
||||
supplies,
|
||||
entry,
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
fn run_of(s: &Store, through: NaiveDate, scope: Scope) -> NewAuditRun {
|
||||
NewAuditRun {
|
||||
through,
|
||||
through_seq: s.figure_head().unwrap(),
|
||||
scope,
|
||||
tb_hash: "tb".into(),
|
||||
findings_sha256: "fs".into(),
|
||||
workpaper_attachment: None,
|
||||
workpaper_pdf: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn claim(effort: &str) -> Claim {
|
||||
Claim {
|
||||
by: "verifier-1".into(),
|
||||
method: Method {
|
||||
model: Some("opus".into()),
|
||||
reasoning_effort: Effort::parse(effort),
|
||||
adversarial: Some(true),
|
||||
verifier_agents: Some(2),
|
||||
},
|
||||
note: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn journal_bytes(dir: &std::path::Path) -> Vec<u8> {
|
||||
std::fs::read(dir.join(JOURNAL_FILE)).unwrap()
|
||||
}
|
||||
|
||||
/// R-A: every write of the feature waits for the owner's upgrade,
|
||||
/// journaling nothing; afterwards it is taken and the books replay to
|
||||
/// what was live.
|
||||
#[test]
|
||||
fn audit_writes_wait_for_audits() {
|
||||
let dir = test_dir("waits");
|
||||
let (mut s, fx) = books(&dir, Some(&JournalFormat::default()));
|
||||
assert!(!s.journal_format().has_feature(AUDITS));
|
||||
let (att, _, _) = s
|
||||
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None)
|
||||
.unwrap();
|
||||
let before = journal_bytes(&dir);
|
||||
let new = run_of(&s, d(1, 31), Scope::Month);
|
||||
for r in [
|
||||
s.record_audit_run(new.clone()).map(|_| ()),
|
||||
s.attest_receipt(
|
||||
att,
|
||||
fx.entry,
|
||||
"40.00".parse().unwrap(),
|
||||
d(1, 5),
|
||||
claim("high"),
|
||||
)
|
||||
.map(|_| ()),
|
||||
s.review_entry(fx.entry, claim("high")).map(|_| ()),
|
||||
] {
|
||||
match r {
|
||||
Err(StoreError::FormatUpgradePending { need }) => {
|
||||
assert_eq!(need, "the journal feature audits")
|
||||
}
|
||||
other => panic!("expected FormatUpgradePending, got {other:?}"),
|
||||
}
|
||||
}
|
||||
assert_eq!(journal_bytes(&dir), before, "a refusal journals nothing");
|
||||
s.upgrade_format().unwrap();
|
||||
let run = s
|
||||
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
|
||||
.unwrap();
|
||||
s.review_entry(fx.entry, claim("high")).unwrap();
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert_eq!(s.audit_runs().unwrap(), vec![run]);
|
||||
assert_eq!(s.attestations().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
/// INVARIANT 1 and 2: filing a workpaper and journaling the run do not
|
||||
/// move the figure head; an entry does; the same audit asked again
|
||||
/// returns the first run and journals nothing.
|
||||
#[test]
|
||||
fn a_run_is_idempotent_on_the_figure_head() {
|
||||
let dir = test_dir("idem");
|
||||
let (mut s, fx) = books(&dir, None);
|
||||
let head = s.figure_head().unwrap();
|
||||
let (wp, _, _) = s
|
||||
.file_document("workpaper", "w.json", "application/json", b"{}", None)
|
||||
.unwrap();
|
||||
assert_eq!(s.figure_head().unwrap(), head, "a filing is not a figure");
|
||||
let mut new = run_of(&s, d(1, 31), Scope::Month);
|
||||
new.workpaper_attachment = Some(wp);
|
||||
let first = s.record_audit_run(new.clone()).unwrap();
|
||||
assert_eq!(first.id, 1);
|
||||
assert_eq!(first.through_seq, head);
|
||||
assert_eq!(s.figure_head().unwrap(), head, "a run is not a figure");
|
||||
let bytes = journal_bytes(&dir);
|
||||
let again = s.record_audit_run(new).unwrap();
|
||||
assert_eq!(again, first);
|
||||
assert_eq!(journal_bytes(&dir), bytes, "asking again journals nothing");
|
||||
assert!(s.audit_is_current(&first).unwrap());
|
||||
// Another scope is another run.
|
||||
let other = s
|
||||
.record_audit_run(run_of(&s, d(1, 31), Scope::Full))
|
||||
.unwrap();
|
||||
assert_eq!(other.id, 2);
|
||||
// An entry moves the head: the run no longer stands.
|
||||
let e = s.create_entry(d(2, 1), "Stamps").unwrap();
|
||||
s.add_line(e, fx.supplies, Side::Debit, usd("3.00"))
|
||||
.unwrap();
|
||||
s.add_line(e, fx.bank, Side::Credit, usd("3.00")).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
assert!(s.figure_head().unwrap() > head);
|
||||
assert!(!s.audit_is_current(&first).unwrap());
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// A receipt reading that differs from the entry's is recorded, marked,
|
||||
/// and never counts; one that agrees is not marked. The receipt must be on
|
||||
/// the entry.
|
||||
#[test]
|
||||
fn amount_mismatch_never_counts() {
|
||||
let dir = test_dir("mismatch");
|
||||
let (mut s, fx) = books(&dir, None);
|
||||
let (att, _, _) = s
|
||||
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper", None)
|
||||
.unwrap();
|
||||
let other = s
|
||||
.attach_receipt(None, "x.txt", "text/plain", b"stray", None)
|
||||
.unwrap()
|
||||
.0;
|
||||
let amount = |t: &str| -> Decimal { t.parse().unwrap() };
|
||||
let disagrees = |a: &ledger_store::audit::Attestation| match a.what {
|
||||
Attested::Receipt { disagrees, .. } => disagrees,
|
||||
_ => panic!("not a receipt"),
|
||||
};
|
||||
let ok = s
|
||||
.attest_receipt(att, fx.entry, amount("40.00"), d(1, 5), claim("high"))
|
||||
.unwrap();
|
||||
assert!(!disagrees(&ok));
|
||||
let wrong_amount = s
|
||||
.attest_receipt(att, fx.entry, amount("41.00"), d(1, 5), claim("high"))
|
||||
.unwrap();
|
||||
assert!(disagrees(&wrong_amount));
|
||||
let wrong_date = s
|
||||
.attest_receipt(att, fx.entry, amount("40.00"), d(1, 6), claim("high"))
|
||||
.unwrap();
|
||||
assert!(disagrees(&wrong_date));
|
||||
// Refused: a receipt not on the entry, a draft, an unknown entry.
|
||||
assert!(matches!(
|
||||
s.attest_receipt(other, fx.entry, amount("40.00"), d(1, 5), claim("high")),
|
||||
Err(StoreError::AuditRefused(_))
|
||||
));
|
||||
let draft = s.create_entry(d(2, 2), "Draft").unwrap();
|
||||
assert!(matches!(
|
||||
s.review_entry(draft, claim("high")),
|
||||
Err(StoreError::AuditRefused(_))
|
||||
));
|
||||
assert!(s.review_entry(999, claim("high")).is_err());
|
||||
// All three kinds replay, in order.
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
let got = s.attestations().unwrap();
|
||||
assert_eq!(got.len(), 3);
|
||||
assert!(got.windows(2).all(|w| w[0].seq < w[1].seq));
|
||||
assert_eq!(got[0].method.reasoning_effort, Some(Effort::High));
|
||||
assert_eq!(got[0].by, "verifier-1");
|
||||
}
|
||||
|
||||
/// INVARIANT 3: a period is attested only on a run at the current head
|
||||
/// that covers it; an entry since makes the run stale and the attestation
|
||||
/// is refused until the audit is run again.
|
||||
#[test]
|
||||
fn stale_head_period_attestation_refused() {
|
||||
let dir = test_dir("stale");
|
||||
let (mut s, fx) = books(&dir, None);
|
||||
let jan = s.create_period("2026-01", d(1, 1), d(1, 31)).unwrap();
|
||||
// No audit yet.
|
||||
assert!(matches!(
|
||||
s.attest_period(jan, claim("high")),
|
||||
Err(StoreError::AuditRefused(_))
|
||||
));
|
||||
// An audit through before the period's end does not cover it.
|
||||
s.record_audit_run(run_of(&s, d(1, 15), Scope::Month))
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
s.attest_period(jan, claim("high")),
|
||||
Err(StoreError::AuditRefused(_))
|
||||
));
|
||||
let run = s
|
||||
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
|
||||
.unwrap();
|
||||
let (a, on) = s.attest_period(jan, claim("high")).unwrap();
|
||||
assert_eq!(on.id, run.id);
|
||||
assert_eq!(
|
||||
a.what,
|
||||
Attested::Period {
|
||||
period_id: jan,
|
||||
audit_run_id: run.id
|
||||
}
|
||||
);
|
||||
// An entry since: the run is stale.
|
||||
let e = s.create_entry(d(2, 1), "Stamps").unwrap();
|
||||
s.add_line(e, fx.supplies, Side::Debit, usd("3.00"))
|
||||
.unwrap();
|
||||
s.add_line(e, fx.capital, Side::Credit, usd("3.00"))
|
||||
.unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
let head = s.head();
|
||||
match s.attest_period(jan, claim("high")) {
|
||||
Err(StoreError::AuditRefused(why)) => assert!(why.contains("books.audit"), "{why}"),
|
||||
other => panic!("expected a refusal, got {other:?}"),
|
||||
}
|
||||
assert_eq!(s.head(), head, "a refusal journals nothing");
|
||||
s.verify().unwrap();
|
||||
}
|
||||
|
||||
/// The integrity table: each code seeded alone.
|
||||
#[test]
|
||||
fn integrity_detects_each_seeded_defect() {
|
||||
// A whole book finds nothing.
|
||||
{
|
||||
let dir = test_dir("whole");
|
||||
let (s, _) = books(&dir, None);
|
||||
assert_eq!(s.integrity_findings(), vec![]);
|
||||
}
|
||||
let seed = |code: &str| -> Vec<&'static str> {
|
||||
let dir = test_dir(&format!("seed-{code}"));
|
||||
let (mut s, fx) = books(&dir, None);
|
||||
let found = match code {
|
||||
"blob_missing" | "blob_damaged" | "extraction_blob_missing" => {
|
||||
let (_, sha, _) = s
|
||||
.attach_receipt(Some(fx.entry), "r.txt", "text/plain", b"paper 40.00", None)
|
||||
.unwrap();
|
||||
let target = if code == "extraction_blob_missing" {
|
||||
// The text the extractor read, content-addressed too.
|
||||
let x = s.extractions().unwrap().pop().expect("an extraction");
|
||||
x.text_sha256.expect("text")
|
||||
} else {
|
||||
sha
|
||||
};
|
||||
let path = dir.join("blobs").join(&target[..2]).join(&target);
|
||||
let path = if path.exists() {
|
||||
path
|
||||
} else {
|
||||
// The store's own layout.
|
||||
walk(&dir.join("blobs"), &target).expect("the blob file")
|
||||
};
|
||||
if code == "blob_damaged" {
|
||||
std::fs::write(&path, b"tampered").unwrap();
|
||||
} else {
|
||||
std::fs::remove_file(&path).unwrap();
|
||||
}
|
||||
s.integrity_findings()
|
||||
}
|
||||
"duplicate_blob" => {
|
||||
drop(s);
|
||||
let bytes = b"filed twice by an older version";
|
||||
let (sha, _) = BlobStore::open(&dir.join("blobs"))
|
||||
.unwrap()
|
||||
.put(bytes)
|
||||
.unwrap();
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
for (id, name) in [(1, "one.pdf"), (2, "two.pdf")] {
|
||||
w.append(
|
||||
"receipt.attached",
|
||||
json!({
|
||||
"attachment_id": id, "blob_sha": sha, "entry_id": null,
|
||||
"category": "receipts", "file_name": name,
|
||||
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
|
||||
"description": null,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(w);
|
||||
Store::open(&dir).unwrap().integrity_findings()
|
||||
}
|
||||
"access_log_damaged" => {
|
||||
std::fs::write(
|
||||
dir.join("access.jsonl"),
|
||||
b"{\"not\": \"a chained record\"}\n",
|
||||
)
|
||||
.unwrap();
|
||||
s.integrity_findings()
|
||||
}
|
||||
"projection_differs" => {
|
||||
let db = ledger_store::database_in_use(&dir);
|
||||
let conn = rusqlite::Connection::open(&db).unwrap();
|
||||
conn.execute("DELETE FROM lines", []).unwrap();
|
||||
drop(conn);
|
||||
s.integrity_findings()
|
||||
}
|
||||
"journal_damaged" => {
|
||||
let path = dir.join(JOURNAL_FILE);
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
std::fs::write(&path, text.replacen("Printer paper", "Printer papers", 1)).unwrap();
|
||||
s.integrity_findings()
|
||||
}
|
||||
"backup_stale" => backup_findings(&[BackupStanding {
|
||||
target: "folder".into(),
|
||||
stale: true,
|
||||
last_error: None,
|
||||
drill_error: None,
|
||||
}]),
|
||||
"drill_failed" => backup_findings(&[BackupStanding {
|
||||
target: "folder".into(),
|
||||
stale: false,
|
||||
last_error: None,
|
||||
drill_error: Some("restore did not reach the head".into()),
|
||||
}]),
|
||||
other => panic!("no seed for {other}"),
|
||||
};
|
||||
found.iter().map(|f| f.code).collect()
|
||||
};
|
||||
for row in audit::CODES {
|
||||
let got = seed(row.code);
|
||||
assert!(got.contains(&row.code), "{}: found {got:?}", row.code);
|
||||
// The seed implies nothing else but its own consequences: a
|
||||
// missing or damaged file is found once; a damaged journal can
|
||||
// also differ from its replay.
|
||||
for c in &got {
|
||||
assert!(
|
||||
*c == row.code
|
||||
|| (row.code == "journal_damaged" && *c == "projection_differs")
|
||||
// verify reads a file's text too, so a damaged or missing
|
||||
// stored file is also a replay that cannot be made.
|
||||
|| (row.code.starts_with("blob_") || row.code == "extraction_blob_missing")
|
||||
&& [
|
||||
"blob_missing",
|
||||
"blob_damaged",
|
||||
"extraction_blob_missing",
|
||||
"projection_differs",
|
||||
]
|
||||
.contains(c),
|
||||
"{}: also found {c}",
|
||||
row.code
|
||||
);
|
||||
}
|
||||
}
|
||||
// No backups set up is the owner's standing choice, not a finding.
|
||||
assert!(backup_findings(&[]).is_empty());
|
||||
}
|
||||
|
||||
fn walk(dir: &std::path::Path, name: &str) -> Option<PathBuf> {
|
||||
for e in std::fs::read_dir(dir).ok()? {
|
||||
let p = e.ok()?.path();
|
||||
if p.is_dir() {
|
||||
if let Some(f) = walk(&p, name) {
|
||||
return Some(f);
|
||||
}
|
||||
} else if p.file_name().is_some_and(|n| n == name) {
|
||||
return Some(p);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// 8.0.6 item 2: the books the version before P8 wrote
|
||||
/// (`old-books/p8_before`) open to the same journal, have no audit run or
|
||||
/// claim, are whole (nothing for the integrity checks to find), refuse
|
||||
/// the new writes until the owner's upgrade, and take them after it.
|
||||
#[test]
|
||||
fn books_the_version_before_p8_wrote_wait_for_the_upgrade() {
|
||||
let from =
|
||||
std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/old-books/p8_before");
|
||||
let dir = test_dir("p8-before");
|
||||
std::fs::create_dir_all(dir.join("blobs")).unwrap();
|
||||
std::fs::copy(from.join(JOURNAL_FILE), dir.join(JOURNAL_FILE)).unwrap();
|
||||
fn copy(from: &std::path::Path, to: &std::path::Path) {
|
||||
std::fs::create_dir_all(to).unwrap();
|
||||
for e in std::fs::read_dir(from).unwrap() {
|
||||
let e = e.unwrap();
|
||||
if e.file_type().unwrap().is_dir() {
|
||||
copy(&e.path(), &to.join(e.file_name()));
|
||||
} else {
|
||||
std::fs::copy(e.path(), to.join(e.file_name())).unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
copy(&from.join("blobs"), &dir.join("blobs"));
|
||||
let mut s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert!(!s.journal_format().has_feature(AUDITS));
|
||||
assert!(s.audit_runs().unwrap().is_empty());
|
||||
assert!(s.attestations().unwrap().is_empty());
|
||||
assert_eq!(s.integrity_findings(), vec![]);
|
||||
// The head is the last figure line; the filings after it don't move it.
|
||||
let head = s.figure_head().unwrap();
|
||||
assert!(head > 0 && head <= s.head().0);
|
||||
let jan = s.ledger().list_periods()[0].id;
|
||||
let before = journal_bytes(&dir);
|
||||
assert!(matches!(
|
||||
s.record_audit_run(run_of(&s, d(1, 31), Scope::Month)),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
assert!(matches!(
|
||||
s.attest_period(jan, claim("high")),
|
||||
Err(StoreError::FormatUpgradePending { .. })
|
||||
));
|
||||
assert_eq!(journal_bytes(&dir), before, "refused: nothing journaled");
|
||||
s.upgrade_format().unwrap();
|
||||
let run = s
|
||||
.record_audit_run(run_of(&s, d(1, 31), Scope::Month))
|
||||
.unwrap();
|
||||
s.attest_period(jan, claim("high")).unwrap();
|
||||
assert_eq!(s.figure_head().unwrap(), head, "claims are not figures");
|
||||
s.verify().unwrap();
|
||||
drop(s);
|
||||
let s = Store::open(&dir).unwrap();
|
||||
s.verify().unwrap();
|
||||
assert_eq!(s.audit_runs().unwrap(), vec![run]);
|
||||
}
|
||||
|
||||
/// Done-when 2: books with an unfooted cash flow, a posted entry with no
|
||||
/// receipt, an open draft and a duplicate file give exactly those four
|
||||
/// findings: the statements' and the store's, together.
|
||||
#[test]
|
||||
fn four_defects_give_exactly_four_findings() {
|
||||
use ledger_core::audit::{AuditInput, statements};
|
||||
let dir = test_dir("four");
|
||||
let (mut s, fx) = {
|
||||
let mut s = Store::create(&dir, Currency::USD).unwrap();
|
||||
let bank = s
|
||||
.add_account("1000", "Bank", AccountType::Asset, None, false)
|
||||
.unwrap();
|
||||
let card = s
|
||||
.add_account("2050", "Card", AccountType::Liability, None, false)
|
||||
.unwrap();
|
||||
let capital = s
|
||||
.add_account("3000", "Capital", AccountType::Equity, None, false)
|
||||
.unwrap();
|
||||
let supplies = s
|
||||
.add_account_with_tax(
|
||||
"6000",
|
||||
"Supplies",
|
||||
AccountType::Expense,
|
||||
None,
|
||||
false,
|
||||
Some(("supplies", None)),
|
||||
)
|
||||
.unwrap();
|
||||
let e = s.create_entry(d(1, 1), "Opening").unwrap();
|
||||
s.add_line(e, bank, Side::Debit, usd("500.00")).unwrap();
|
||||
s.add_line(e, capital, Side::Credit, usd("500.00")).unwrap();
|
||||
s.post_entry(e).unwrap();
|
||||
s.attach_receipt(Some(e), "open.txt", "text/plain", b"deposit 500.00", None)
|
||||
.unwrap();
|
||||
// A purchase over the threshold with no receipt: posted through the
|
||||
// primitive, as a hand entry made before the rule was tightened.
|
||||
let bare = s.create_entry(d(1, 5), "Printer paper").unwrap();
|
||||
s.add_line(bare, supplies, Side::Debit, usd("40.00"))
|
||||
.unwrap();
|
||||
s.add_line(bare, bank, Side::Credit, usd("40.00")).unwrap();
|
||||
s.post_entry(bare).unwrap();
|
||||
// An open draft.
|
||||
let draft = s.create_entry(d(1, 9), "Stamps").unwrap();
|
||||
s.add_line(draft, supplies, Side::Debit, usd("5.00"))
|
||||
.unwrap();
|
||||
s.add_line(draft, bank, Side::Credit, usd("5.00")).unwrap();
|
||||
(s, (bank, card, bare))
|
||||
};
|
||||
// A duplicate: two records an older version filed for one file.
|
||||
drop(s);
|
||||
let bytes = b"filed twice by an older version";
|
||||
let (sha, _) = BlobStore::open(&dir.join("blobs"))
|
||||
.unwrap()
|
||||
.put(bytes)
|
||||
.unwrap();
|
||||
let mut w = JournalWriter::open(&dir.join(JOURNAL_FILE)).unwrap();
|
||||
for (id, name) in [(10, "one.pdf"), (11, "two.pdf")] {
|
||||
w.append(
|
||||
"receipt.attached",
|
||||
json!({
|
||||
"attachment_id": id, "blob_sha": sha, "entry_id": null,
|
||||
"category": "receipts", "file_name": name,
|
||||
"mime_type": "application/pdf", "size_bytes": bytes.len() as u64,
|
||||
"description": null,
|
||||
}),
|
||||
)
|
||||
.unwrap();
|
||||
}
|
||||
drop(w);
|
||||
s = Store::open(&dir).unwrap();
|
||||
let (bank, card, _) = fx;
|
||||
// The statement of cash flows with a liability named as cash: the
|
||||
// engine refuses it, and that refusal is the finding.
|
||||
let params = ledger_core::CashFlowParams {
|
||||
start: d(1, 1),
|
||||
end: d(1, 31),
|
||||
cash_accounts: vec![bank, card],
|
||||
financing_accounts: vec![],
|
||||
investing_accounts: vec![],
|
||||
members: vec![],
|
||||
};
|
||||
let made = ledger_core::cash_flow(s.ledger(), ¶ms)
|
||||
.map(|c| c.reconciles)
|
||||
.map_err(|e| e.to_string());
|
||||
assert!(made.is_err(), "a liability is not a cash account");
|
||||
let receipts = s.entries_with_receipts().unwrap();
|
||||
let mut found = statements(
|
||||
s.ledger(),
|
||||
&AuditInput {
|
||||
settings: s.ledger().settings(),
|
||||
receipts: &receipts,
|
||||
cash_flow: Some(&made),
|
||||
settings_problems: &[],
|
||||
undecided_1099: &[],
|
||||
},
|
||||
d(1, 31),
|
||||
);
|
||||
found.extend(s.integrity_findings());
|
||||
ledger_core::audit::sort(&mut found);
|
||||
let mut codes: Vec<&str> = found.iter().map(|f| f.code).collect();
|
||||
codes.sort_unstable();
|
||||
assert_eq!(
|
||||
codes,
|
||||
[
|
||||
"cash_flow_unfooted",
|
||||
"draft_open",
|
||||
"duplicate_blob",
|
||||
"evidence_missing"
|
||||
],
|
||||
"{found:#?}"
|
||||
);
|
||||
}
|
||||
@@ -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,85 @@
|
||||
//! Writes `p8_before`: books the version before P8 (main b97234ac) wrote
|
||||
//! through its Store API: a receipted opening and purchase, a 40.00
|
||||
//! purchase with no receipt, a small one under the threshold, a reversal,
|
||||
//! an invoice with a part payment, an open draft, and a closed January.
|
||||
//! No audit run, attestation or attestation setting exists in that
|
||||
//! version; this version opens the same journal and must report the same
|
||||
//! figures, and an audit of it finds exactly the missing receipt and the
|
||||
//! open draft.
|
||||
use chrono::NaiveDate;
|
||||
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("p8_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 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();
|
||||
s.attach_receipt(Some(seed), "opening.txt", "text/plain", b"Opening deposit 1000.00 2026-01-01", None)
|
||||
.unwrap();
|
||||
let rent_jan = post(&mut s, d(2026, 1, 5), "January rent", rent, bank, 50_000);
|
||||
s.post_entry(rent_jan).unwrap();
|
||||
s.attach_receipt(Some(rent_jan), "rent.txt", "text/plain", b"Rent January 500.00 2026-01-05", None)
|
||||
.unwrap();
|
||||
// 40.00 is over the 25.00 threshold: no receipt, so an audit finds it.
|
||||
let paper = post(&mut s, d(2026, 1, 7), "Printer paper", software, bank, 4_000);
|
||||
s.post_entry(paper).unwrap();
|
||||
// Under the threshold: needs none.
|
||||
let stamps = post(&mut s, d(2026, 1, 8), "Stamps", software, bank, 1_200);
|
||||
s.post_entry(stamps).unwrap();
|
||||
let wrong = post(&mut s, d(2026, 1, 12), "Rent paid twice", rent, bank, 50_000);
|
||||
s.post_entry(wrong).unwrap();
|
||||
s.attach_receipt(Some(wrong), "rent2.txt", "text/plain", b"Rent again 500.00 2026-01-12", None)
|
||||
.unwrap();
|
||||
s.reverse_entry(wrong, d(2026, 1, 14), "Entered twice").unwrap();
|
||||
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();
|
||||
// An open draft in February; January closes around it.
|
||||
let _open = post(&mut s, d(2026, 2, 12), "Coworking day pass", rent, bank, 3_500);
|
||||
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,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"}
|
||||
+1
@@ -0,0 +1 @@
|
||||
Opening deposit 1000.00 2026-01-01
|
||||
+1
@@ -0,0 +1 @@
|
||||
Rent again 500.00 2026-01-12
|
||||
+1
@@ -0,0 +1 @@
|
||||
Rent January 500.00 2026-01-05
|
||||
@@ -0,0 +1,461 @@
|
||||
{
|
||||
"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": 4,
|
||||
"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-07",
|
||||
"description": "Printer paper",
|
||||
"id": 3,
|
||||
"lines": [
|
||||
{
|
||||
"account": 5,
|
||||
"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": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-08",
|
||||
"description": "Stamps",
|
||||
"id": 4,
|
||||
"lines": [
|
||||
{
|
||||
"account": 5,
|
||||
"amount": {
|
||||
"amount": "12.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "12.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-12",
|
||||
"description": "Rent paid twice",
|
||||
"id": 5,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"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": 6,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
},
|
||||
{
|
||||
"account": 1,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
}
|
||||
],
|
||||
"reversal_of": 5,
|
||||
"status": "Posted"
|
||||
},
|
||||
{
|
||||
"date": "2026-01-10",
|
||||
"description": "Invoice 1",
|
||||
"id": 7,
|
||||
"lines": [
|
||||
{
|
||||
"account": 6,
|
||||
"amount": {
|
||||
"amount": "1200.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 3,
|
||||
"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": 8,
|
||||
"lines": [
|
||||
{
|
||||
"account": 6,
|
||||
"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-02-12",
|
||||
"description": "Coworking day pass",
|
||||
"id": 9,
|
||||
"lines": [
|
||||
{
|
||||
"account": 4,
|
||||
"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-01-31",
|
||||
"description": "Close period 2026-01",
|
||||
"id": 10,
|
||||
"lines": [
|
||||
{
|
||||
"account": 3,
|
||||
"amount": {
|
||||
"amount": "1200.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Debit"
|
||||
},
|
||||
{
|
||||
"account": 4,
|
||||
"amount": {
|
||||
"amount": "500.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
},
|
||||
{
|
||||
"account": 5,
|
||||
"amount": {
|
||||
"amount": "52.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
},
|
||||
{
|
||||
"account": 7,
|
||||
"amount": {
|
||||
"amount": "648.00",
|
||||
"currency": [
|
||||
85,
|
||||
83,
|
||||
68
|
||||
]
|
||||
},
|
||||
"side": "Credit"
|
||||
}
|
||||
],
|
||||
"reversal_of": null,
|
||||
"status": "Posted"
|
||||
}
|
||||
],
|
||||
"entry_parties": [
|
||||
[
|
||||
7,
|
||||
[
|
||||
{
|
||||
"party_id": 1,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
],
|
||||
[
|
||||
8,
|
||||
[
|
||||
{
|
||||
"party_id": 1,
|
||||
"share": "derived"
|
||||
}
|
||||
]
|
||||
]
|
||||
],
|
||||
"invoices": [
|
||||
{
|
||||
"amount_paid": "400.00",
|
||||
"customer_id": 1,
|
||||
"date": "2026-01-10",
|
||||
"due_date": "2026-02-09",
|
||||
"entry_id": 7,
|
||||
"id": 1,
|
||||
"lines": [
|
||||
{
|
||||
"amount": "1200.00",
|
||||
"description": "Design"
|
||||
}
|
||||
],
|
||||
"status": "Partial"
|
||||
}
|
||||
],
|
||||
"payroll_runs": [],
|
||||
"periods": [
|
||||
{
|
||||
"close_seq": 1,
|
||||
"closing_entry": 10,
|
||||
"end": "2026-01-31",
|
||||
"id": 1,
|
||||
"member_close": null,
|
||||
"name": "2026-01",
|
||||
"start": "2026-01-01",
|
||||
"status": "Closed"
|
||||
}
|
||||
],
|
||||
"recurring": [],
|
||||
"schedules": [],
|
||||
"snapshot": {
|
||||
"Ok": {
|
||||
"balance_sheet": {
|
||||
"assets": "1648",
|
||||
"balanced": true,
|
||||
"current_earnings": "0",
|
||||
"equity": "1648",
|
||||
"liabilities": "0"
|
||||
},
|
||||
"profit_and_loss": {
|
||||
"expenses": "0",
|
||||
"net_income": "0",
|
||||
"revenue": "0"
|
||||
},
|
||||
"trial_balance": {
|
||||
"lines": [
|
||||
{
|
||||
"code": "1000",
|
||||
"credit": "0",
|
||||
"debit": "848",
|
||||
"name": "Bank"
|
||||
},
|
||||
{
|
||||
"code": "1200",
|
||||
"credit": "0",
|
||||
"debit": "800",
|
||||
"name": "Accounts Receivable"
|
||||
},
|
||||
{
|
||||
"code": "3000",
|
||||
"credit": "1000",
|
||||
"debit": "0",
|
||||
"name": "Capital"
|
||||
},
|
||||
{
|
||||
"code": "3999",
|
||||
"credit": "648",
|
||||
"debit": "0",
|
||||
"name": "Retained Earnings"
|
||||
}
|
||||
],
|
||||
"total_credits": "1648",
|
||||
"total_debits": "1648"
|
||||
}
|
||||
}
|
||||
},
|
||||
"verify": {
|
||||
"Ok": null
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
{"payload":{"base_currency":"USD"},"prev":"0000000000000000000000000000000000000000000000000000000000000000","seq":1,"ts":"2026-10-29T20:33: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","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":"a41fd982ec883c0f7dbeec0382bcf10c69246a4d063cf04997f40ad96467f00d","seq":2,"ts":"2026-10-29T20:33:25Z","type":"journal.format_set"}
|
||||
{"payload":{"account_type":"Asset","code":"1000","currency":"USD","id":1,"name":"Bank","parent":null,"placeholder":false},"prev":"f0adba7a9a2da79cc2b28f4ab2a69238da5f3ad9269e76c1c21e0027fdf86c51","seq":3,"ts":"2026-10-29T20:33:25Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Equity","code":"3000","currency":"USD","id":2,"name":"Capital","parent":null,"placeholder":false},"prev":"093d7586712b4e3379fea7da2923ab4b25efa7be0d533e212ea4c31ecbb81332","seq":4,"ts":"2026-10-29T20:33:25Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Revenue","code":"4000","currency":"USD","id":3,"name":"Sales","parent":null,"placeholder":false},"prev":"2b8f70ae6d4b736aa0cc523563b47cdd1be396e3fd3a5c2f84cceb001fbaff81","seq":5,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6000","currency":"USD","id":4,"name":"Rent","parent":null,"placeholder":false},"prev":"d4fc3949ba8c20b553504561a2c22f96d6a4351713ae66e83fe34f601ead0b2d","seq":6,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
|
||||
{"payload":{"account_type":"Expense","code":"6100","currency":"USD","id":5,"name":"Software","parent":null,"placeholder":false},"prev":"3088d962972a512f5d1a12a8dd4da1da6ae8d1388e67ea18666b87687401b97c","seq":7,"ts":"2026-10-29T20:33:26Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-01-01","description":"Opening capital","id":1},"prev":"5947b37f609878df3587e37c72411cc2505fb694aab1bc9a7199cecfd2897031","seq":8,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
|
||||
{"payload":{"account":1,"amount":"1000.00","currency":"USD","id":1,"side":"debit"},"prev":"d0aff2093f4f8114e04eb158ddfcfc24201846214141fea1def86cd965551006","seq":9,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"account":2,"amount":"1000.00","currency":"USD","id":1,"side":"credit"},"prev":"850e9125ac3b5d457e611882642600a7298fa3acdd386ce2b7c9494ecb29b0d0","seq":10,"ts":"2026-10-29T20:33:26Z","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":"abc483bc19148cc173cf93402702b60ee5e31029fcdcfa704f10204c38a707ec","seq":11,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
|
||||
{"payload":{"attachment_id":1,"blob_sha":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","category":"receipts","description":null,"entry_id":1,"file_name":"opening.txt","mime_type":"text/plain","size_bytes":34},"prev":"4a2665579fd49f5d11e27eaaca556990d58559dd1a9d8f23e9d274449ff61a8d","seq":12,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"}
|
||||
{"payload":{"attachment_id":1,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"0d462b0d3e7b13628aa5b6279a6ced262a8670bd5d7063a0668469398ac6523e","version":"0.1.0"},"prev":"d8c2d6bf43d24b9cf4b3c0a12b514cd9f77cf51f979ab54e17c6fc879fdc285f","seq":13,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"}
|
||||
{"payload":{"date":"2026-01-05","description":"January rent","id":2},"prev":"d048136f116634d3df443725f71b4f5a9ed2cfdc45a8dba9fc9a54bb993266ff","seq":14,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":2,"side":"debit"},"prev":"6850fe7154d84ec18c876c9c4985607b9660e1a4d6ff1c42814358039d25224d","seq":15,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":2,"side":"credit"},"prev":"24ecb6745877198c45149e53672d81a7e8b75051770c2eac046c373ad5020694","seq":16,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-05","description":"January rent","id":2,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"76e855cebe94f29e2bbf65e4be0634976eec4164867ec4c2152e39b21a5b9ab0","seq":17,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
|
||||
{"payload":{"attachment_id":2,"blob_sha":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","category":"receipts","description":null,"entry_id":2,"file_name":"rent.txt","mime_type":"text/plain","size_bytes":30},"prev":"72fb7f27dba8721febaffcc2dc93870d79272ca3208abfea27d1535f46fa8334","seq":18,"ts":"2026-10-29T20:33:26Z","type":"receipt.attached"}
|
||||
{"payload":{"attachment_id":2,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"dd3075d12716028c7ae1b8f9a10c590f8f4cc26ff4d854e30c9551ef7ad5898f","version":"0.1.0"},"prev":"bd55df8f6fce00e7a55262be3123985789504189f9bc8364a194fa886719fc6a","seq":19,"ts":"2026-10-29T20:33:26Z","type":"attachment.text_extracted"}
|
||||
{"payload":{"date":"2026-01-07","description":"Printer paper","id":3},"prev":"2129339f3ce925cb9d0ff71f108ce7ba5c1dadd8432834effb2586e7632c1add","seq":20,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
|
||||
{"payload":{"account":5,"amount":"40.00","currency":"USD","id":3,"side":"debit"},"prev":"3980e707c3feb852f13fff9daa38ae8bf64831d851a76b8a5e7e97eff3237506","seq":21,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"40.00","currency":"USD","id":3,"side":"credit"},"prev":"673065104b3196477ebed498501f952f62b0cc1ed8cb35cda30b00e3d4932590","seq":22,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-07","description":"Printer paper","id":3,"lines":[{"account":5,"amount":"40.00","currency":"USD","side":"debit"},{"account":1,"amount":"40.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"c2c265a5318dfc5437b94272a5ceb654e93eb982815eea919ce11ca492e69f53","seq":23,"ts":"2026-10-29T20:33:26Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2026-01-08","description":"Stamps","id":4},"prev":"e54f5a63600a423bc05f572cc2e5357b591afee176de438adbfc673e10028443","seq":24,"ts":"2026-10-29T20:33:26Z","type":"entry.created"}
|
||||
{"payload":{"account":5,"amount":"12.00","currency":"USD","id":4,"side":"debit"},"prev":"c0acdf9d0c8fd94c66be0718354953d951f377d961da3846bba4fcfe9715577a","seq":25,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"12.00","currency":"USD","id":4,"side":"credit"},"prev":"9231e3a5b347df6caabb01248419230918234919a32552ebb509706a79f4cfd0","seq":26,"ts":"2026-10-29T20:33:26Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-08","description":"Stamps","id":4,"lines":[{"account":5,"amount":"12.00","currency":"USD","side":"debit"},{"account":1,"amount":"12.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"66e4fd5f78d0499f6c748e283ba81325fb43d3277a73bc7ba02e1cc43a8e3d37","seq":27,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
|
||||
{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5},"prev":"e62d56bcde5b843e5b007e5d70bec0b39159e04c0e496533341af80fb2c04e88","seq":28,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"500.00","currency":"USD","id":5,"side":"debit"},"prev":"b9270e6df82d8f9972ec1ce5046113bc460e1c28b48a1d23a3f5631dae23eb6c","seq":29,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"500.00","currency":"USD","id":5,"side":"credit"},"prev":"91bd76318ce8bcdc02caaff76a44ade5b63aea97fc2e9406a914f87d91b7b87b","seq":30,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-12","description":"Rent paid twice","id":5,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"debit"},{"account":1,"amount":"500.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"994d4a25eb115362c405208ffc926af4898c61e0938d225c3cceb1aaff2520b1","seq":31,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
|
||||
{"payload":{"attachment_id":3,"blob_sha":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","category":"receipts","description":null,"entry_id":5,"file_name":"rent2.txt","mime_type":"text/plain","size_bytes":28},"prev":"97c54bdaa6d977bddbb61f0f797903f162a5445aadad0f86fbef119d26f03885","seq":32,"ts":"2026-10-29T20:33:27Z","type":"receipt.attached"}
|
||||
{"payload":{"attachment_id":3,"extractor":"wanderbooks","pages":1,"status":"ok","text_sha256":"65c730c09a9b8c3ecf4cc2ae55fafe083dc33e2111abe6cafaf6fcb02a7d8ad8","version":"0.1.0"},"prev":"6908282c4f663ee92e59574a7f772d341692a684975433afb0370fe7f25ad798","seq":33,"ts":"2026-10-29T20:33:27Z","type":"attachment.text_extracted"}
|
||||
{"payload":{"date":"2026-01-14","description":"Entered twice","id":6,"lines":[{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":1,"amount":"500.00","currency":"USD","side":"debit"}],"reverses":5},"prev":"d8fae93f64fa2c2fafa13ed4ed3a6f578a6d7a2af15cdef1bd2b3bed86242665","seq":34,"ts":"2026-10-29T20:33:27Z","type":"entry.reversed"}
|
||||
{"payload":{"id":1,"name":"Fernhill Labs"},"prev":"c525d0946d25b018a020b71072d59e39a6a842d85fa4c554af73965235b93aca","seq":35,"ts":"2026-10-29T20:33:27Z","type":"customer.added"}
|
||||
{"payload":{"account_type":"Asset","code":"1200","currency":"USD","id":6,"name":"Accounts Receivable","parent":null,"placeholder":false},"prev":"99f31ecc1df1963071728eb1927d66574cbbc48579604183b569b0507f725aaa","seq":36,"ts":"2026-10-29T20:33:27Z","type":"account.added"}
|
||||
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7},"prev":"000b3f21fbda30070ebdc436788844e60e7fb30a2c574a4fc96df1184e6a974f","seq":37,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
|
||||
{"payload":{"account":6,"amount":"1200.00","currency":"USD","id":7,"side":"debit"},"prev":"31912cd94f8cf8ab2381467d0c1f06e7b7d1aad7232dc34bf26976f8b07117df","seq":38,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"account":3,"amount":"1200.00","currency":"USD","id":7,"side":"credit"},"prev":"b3544c286be658d2569eaec61f1ecf924b6345125ccdee5de44c7be69eb80d87","seq":39,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-10","description":"Invoice 1","id":7,"lines":[{"account":6,"amount":"1200.00","currency":"USD","side":"debit"},{"account":3,"amount":"1200.00","currency":"USD","side":"credit"}],"reversal_of":null},"prev":"780aa79b403d46831221d2f9de731eafe6e20e3b916fa6bcc712485234a53a41","seq":40,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
|
||||
{"payload":{"customer_id":1,"date":"2026-01-10","due_date":"2026-02-09","entry_id":7,"id":1,"lines":[{"amount":"1200.00","description":"Design"}]},"prev":"d13a0896487f5aec07d43136ad9fd10e70d4f048528ff4e7a32a9d23a74f7225","seq":41,"ts":"2026-10-29T20:33:27Z","type":"invoice.created"}
|
||||
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8},"prev":"293d3e1a957fa177d28515cf7a4dfa324945bb73d8a2b535844fbaafa186ff1a","seq":42,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
|
||||
{"payload":{"account":6,"amount":"400.00","currency":"USD","id":8,"side":"credit"},"prev":"68c56c6c09a27ba436c95d4ebe2e0fb56511bf5db181ad424d7c6ed56cfd0bc3","seq":43,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"400.00","currency":"USD","id":8,"side":"debit"},"prev":"5284c73245f1f3a6d2820a6165fe720bc7b7034ad3cd07ba1320f30fed4acadc","seq":44,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"date":"2026-01-20","description":"Payment on invoice 1","id":8,"lines":[{"account":6,"amount":"400.00","currency":"USD","side":"credit"},{"account":1,"amount":"400.00","currency":"USD","side":"debit"}],"reversal_of":null},"prev":"c2afa1d4c940bbc35df2ec94272080c5fa0dad93a9547cbec6d07ab11f4ba4f3","seq":45,"ts":"2026-10-29T20:33:27Z","type":"entry.posted"}
|
||||
{"payload":{"amount":"400.00","cash_account":1,"date":"2026-01-20","entry_id":8,"invoice_id":1},"prev":"30a6274b20559dc7f83f2b00f39da4c72bc4244c013ad63368d0f6362311e058","seq":46,"ts":"2026-10-29T20:33:27Z","type":"invoice.payment_received"}
|
||||
{"payload":{"date":"2026-02-12","description":"Coworking day pass","id":9},"prev":"adb2e790bd245c5ab0286870ad837296d0df062498c080b4349579b9f5f67ed9","seq":47,"ts":"2026-10-29T20:33:27Z","type":"entry.created"}
|
||||
{"payload":{"account":4,"amount":"35.00","currency":"USD","id":9,"side":"debit"},"prev":"030bdf46cf4b8dae86f000cca47fd0f95d0f7dc01f3637729c082797846a1416","seq":48,"ts":"2026-10-29T20:33:27Z","type":"entry.line_added"}
|
||||
{"payload":{"account":1,"amount":"35.00","currency":"USD","id":9,"side":"credit"},"prev":"b6e4796ab7517782b0e5ee77126e5701246d2b4234c505bc079ba1ce93ad4b04","seq":49,"ts":"2026-10-29T20:33:28Z","type":"entry.line_added"}
|
||||
{"payload":{"end":"2026-01-31","id":1,"name":"2026-01","start":"2026-01-01"},"prev":"d5653ccd2b9fdc574ebb68c068246e70b69639f26a3934faf41eeeb77dc5f8bb","seq":50,"ts":"2026-10-29T20:33:28Z","type":"period.created"}
|
||||
{"payload":{"account_type":"Equity","code":"3999","currency":"USD","id":7,"name":"Retained Earnings","parent":null,"placeholder":false},"prev":"14a0f2b42d59a5a18e901c935c1fe15198d6a8d308917359b840504b74700cb8","seq":51,"ts":"2026-10-29T20:33:28Z","type":"account.added"}
|
||||
{"payload":{"close_to":"retained_earnings","closing_entry":10,"entry":{"date":"2026-01-31","description":"Close period 2026-01","id":10,"lines":[{"account":3,"amount":"1200.00","currency":"USD","side":"debit"},{"account":4,"amount":"500.00","currency":"USD","side":"credit"},{"account":5,"amount":"52.00","currency":"USD","side":"credit"},{"account":7,"amount":"648.00","currency":"USD","side":"credit"}]},"id":1},"prev":"724a1d45168bcb5282dd894952b98aa4b364641158c42e9204758de8c2afeba2","seq":52,"ts":"2026-10-29T20:33:28Z","type":"period.closed"}
|
||||
@@ -150,7 +150,7 @@ fn assert_corrupt<T>(got: Result<T, StoreError>) {
|
||||
fn old_reader_gets_newer_journal() {
|
||||
let cases = [
|
||||
(
|
||||
"period.attested",
|
||||
"ledger.not_yet_written",
|
||||
json!({"key": "company.name", "value": "X"}),
|
||||
"an event type this version doesn't know",
|
||||
),
|
||||
@@ -243,12 +243,24 @@ fn the_writer_and_the_viewer_agree_on_newer_books() {
|
||||
&[
|
||||
// Chained and of a known type, but replay refuses it.
|
||||
("entry.posted", json!({"id": 99})),
|
||||
("period.attested", json!({"key": "company.name"})),
|
||||
("ledger.not_yet_written", json!({"key": "company.name"})),
|
||||
],
|
||||
)[1];
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(
|
||||
Store::open(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
assert_newer(
|
||||
Snapshot::load(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Damage stays corruption, whatever the damaged line's type: the gate
|
||||
@@ -282,7 +294,7 @@ fn damaged_journals_are_still_corrupt() {
|
||||
];
|
||||
let newer = [
|
||||
(FORMAT_SET, json!({"format": 3, "features": []})),
|
||||
("period.attested", json!({"key": "company.name"})),
|
||||
("ledger.not_yet_written", json!({"key": "company.name"})),
|
||||
];
|
||||
for (name, damage) in damages {
|
||||
for (i, last) in newer.iter().enumerate() {
|
||||
@@ -566,32 +578,32 @@ fn a_newer_format_frames_its_lines_its_own_way() {
|
||||
let nexts: [(&str, Next, Option<bool>); 6] = [
|
||||
(
|
||||
"float",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": prev}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": prev}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"string-seq",
|
||||
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "period.attested", "payload": {}, "prev": prev}),
|
||||
|seq, prev| json!({"seq": seq.to_string(), "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"envelope-key",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": prev, "actor": "agent"}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": prev, "actor": "agent"}),
|
||||
Some(true),
|
||||
),
|
||||
(
|
||||
"no-prev",
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "parent": prev}),
|
||||
|seq, prev| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "parent": prev}),
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"prev-not-text",
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {}, "prev": 1.5}),
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {}, "prev": 1.5}),
|
||||
Some(false),
|
||||
),
|
||||
(
|
||||
"wrong-prev",
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "period.attested", "payload": {"rate": 0.5}, "prev": "00"}),
|
||||
|seq, _| json!({"seq": seq, "ts": "t", "type": "ledger.not_yet_written", "payload": {"rate": 0.5}, "prev": "00"}),
|
||||
None,
|
||||
),
|
||||
];
|
||||
@@ -686,11 +698,23 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
|
||||
} else {
|
||||
// After a refused line, it is a next line without a readable
|
||||
// prev: unverified, for both.
|
||||
let seq = append(&dir, &[("period.attested", json!({}))])[0];
|
||||
let seq = append(&dir, &[("ledger.not_yet_written", json!({}))])[0];
|
||||
append_raw(&dir, b"{\"prev\":\"\xff\"}\n");
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(Snapshot::load(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(
|
||||
Store::open(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
assert_newer(
|
||||
Snapshot::load(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -703,12 +727,21 @@ fn a_line_that_is_not_utf8_is_corrupt_for_the_viewer_and_the_writer() {
|
||||
fn an_old_reader_never_repairs_newer_books() {
|
||||
let dir = test_dir("newer-torn");
|
||||
books(&dir);
|
||||
let seq = append(&dir, &[("period.attested", json!({"key": "company.name"}))])[0];
|
||||
let seq = append(
|
||||
&dir,
|
||||
&[("ledger.not_yet_written", json!({"key": "company.name"}))],
|
||||
)[0];
|
||||
append_raw(&dir, b"{\"seq\":");
|
||||
let path = dir.join(JOURNAL_FILE);
|
||||
let before = std::fs::read(&path).unwrap();
|
||||
let need = "an event type this version doesn't know";
|
||||
assert_newer(Store::open(&dir), seq, "period.attested", need, false);
|
||||
assert_newer(
|
||||
Store::open(&dir),
|
||||
seq,
|
||||
"ledger.not_yet_written",
|
||||
need,
|
||||
false,
|
||||
);
|
||||
assert_eq!(std::fs::read(&path).unwrap(), before);
|
||||
let set_aside: Vec<_> = std::fs::read_dir(&dir)
|
||||
.unwrap()
|
||||
|
||||
@@ -0,0 +1,284 @@
|
||||
//! 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::Currency;
|
||||
use ledger_core::conventions::{
|
||||
ConventionKind, DecisionSource, NewConvention, NewDecision, NewOpenItem,
|
||||
};
|
||||
use ledger_core::settings::Source;
|
||||
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,78 @@ 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();
|
||||
// P8's new shapes, hand-written as this version writes them: an audit
|
||||
// run and the three kinds of attestation (the method is the client's
|
||||
// own claim). This reader takes them; the version before it stopped at
|
||||
// the declaration.
|
||||
let method = json!({"model": "opus", "reasoning_effort": "high",
|
||||
"adversarial": true, "verifier_agents": 2});
|
||||
w.append(
|
||||
"audit.run",
|
||||
json!({"id": 1, "through": "2026-05-31", "through_seq": 30, "scope": "month",
|
||||
"program_version": "audit-0.1.0-1", "tb_hash": "aa", "findings_sha256": "bb",
|
||||
"workpaper_attachment": 6, "workpaper_pdf": 7}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"receipt.attested",
|
||||
json!({"attachment_id": 5, "entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:00Z",
|
||||
"method": method, "amount_checked": "99.99", "date_checked": "2026-04-01",
|
||||
"disagrees": false, "note": "read the page"}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"entry.reviewed",
|
||||
json!({"entry_id": 2, "by": "verifier", "at": "2026-06-01T00:00:01Z", "method": {}}),
|
||||
)
|
||||
.unwrap();
|
||||
w.append(
|
||||
"period.attested",
|
||||
json!({"period_id": 1, "by": "verifier", "at": "2026-06-01T00:00:02Z",
|
||||
"method": method, "audit_run_id": 1}),
|
||||
)
|
||||
.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()));
|
||||
@@ -2169,6 +2241,17 @@ fn books_written_under_older_rules_still_open() {
|
||||
assert_eq!(annotation.fields.receipt_number, Some(3));
|
||||
assert_eq!(annotation.fields.payee.as_deref(), Some("Acme"));
|
||||
assert_eq!(store.ledger().next_receipt_number(), 4);
|
||||
// P8's shapes replayed: the run and the three claims, as written.
|
||||
let runs = store.audit_runs().unwrap();
|
||||
assert_eq!(runs.len(), 1);
|
||||
assert_eq!(runs[0].scope, ledger_core::audit::Scope::Month);
|
||||
assert_eq!(runs[0].workpaper_pdf, Some(7));
|
||||
let claims = store.attestations().unwrap();
|
||||
assert_eq!(claims.len(), 3);
|
||||
assert_eq!(claims[0].kind(), "receipt");
|
||||
assert_eq!(claims[0].method.verifier_agents, Some(2));
|
||||
assert_eq!(claims[1].method, ledger_core::audit::Method::default());
|
||||
assert_eq!(claims[2].kind(), "period");
|
||||
assert_eq!(store.ledger().entry_settles(7), [1]);
|
||||
assert!(store.ledger().settles_entry(1));
|
||||
let acme = store.ledger().get_party(1).expect("the party");
|
||||
@@ -2221,6 +2304,28 @@ 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);
|
||||
|
||||
Reference in new issue
Block a user