Files
wanderbooks/crates/ledger-store/src/error.rs
T
swaits 13282cf80e feat(arap): terms, numbers and line accounts in the core (P4b S4.1)
Documents could name only one revenue or general-expenses account per
project, had no number of their own, and took a due date the caller had
to work out. This slice gives the core the pieces the rest of S4 needs,
and journals nothing: an old-shape document still writes byte for byte,
so the owner's books need no upgrade for it.

- Payment terms (B-39): Net n, Net n EOM, due on receipt and a custom
  date, with the early-payment discount and its exact bound
  (kernel::discount_max, Kani + bridge). A due date past the calendar's
  end is refused, never a panic, and a discount is refused with terms
  that have no net days.
- Invoice numbers (B-40, A-2): first_number plus the count of numbered
  invoices, zero-padded to four digits and wider past 9,999, stored at
  issue, kept by a void; legacy invoices are uncounted.
- Line accounts (A-10): a document line may name its account, and the
  entry posts one line per (account, project) part. An invoice line takes
  revenue or a liability holding no role (deferred revenue); a bill line
  an expense or a non-cash asset holding no role. Roles, cash accounts
  and headers are refused.
- A line's amount is exactly its quantity times its unit price
  (kernel::line_amount, Kani + bridge): both or neither, never rounded.
- A vendor's invoice number names one standing bill per vendor, with
  case, spaces and hyphens ignored, unless a duplicate reason says why
  (B-40).
- The record fields S4.3 will journal: number, terms, bill-to and seller
  snapshots, customer reference, memo, the empty sends list S5 fills, and
  the bill's received_on, all optional and defaulted.

The store refuses a line carrying an account, quantity or unit price
before it rehearses or journals anything: those fields are journaled from
S4.3's documents_v2 on, and I-3 will not let a public write replay
without them. `document_line_fields_wait_for_documents_v2` proves the
refusal, the unchanged journal and allocators, and that an ordinary line
journals exactly as before. S4.3 removes this guard in the same change
that journals the fields and adds its byte-for-byte test.

The document commands keep their signatures; create_invoice_with and
create_bill_with take the optional fields for S4.3 and for tests. They
are the existing issue commands with more fields, not a new state
transition, so no Lean Step is added. Kani: line_amount_is_exact runs in
`just prove`; discount_max_is_bounded relates a product to a quotient and
runs in `prove-slow` (163 s), as the proration charge harnesses do.

just check green; just prove green.
2026-10-27 00:02:51 -06:00

203 lines
8.3 KiB
Rust

//! Error type for the store.
use thiserror::Error;
/// Every way a store operation can fail.
#[derive(Debug, Error)]
pub enum StoreError {
/// An attachment link names a subject that doesn't exist.
#[error("no {0} to link to")]
UnknownSubject(String),
/// The attachment is already linked to that subject.
#[error("attachment {0} is already linked to {1}")]
AlreadyLinked(u64, String),
/// The attachment isn't linked to that subject.
#[error("attachment {0} isn't linked to {1}")]
NotLinkedTo(u64, String),
/// A statement record that doesn't fit its account or itself.
#[error("bad statement: {0}")]
InvalidStatement(String),
/// The books don't agree with the statement yet.
#[error(
"not reconciled: the cleared balance differs from the statement by {difference}; \
clear the lines the statement shows (or find what's missing) first"
)]
NotReconciled {
/// Cleared balance less the statement balance.
difference: rust_decimal::Decimal,
},
/// A reconciliation command that breaks the sequence.
#[error("{0}")]
ReconciliationRefused(String),
/// An asset can't be corrected once depreciation for it is posted: the
/// posted entries would disagree with the corrected schedule.
#[error("asset {0} already has depreciation posted; correct it with entries instead")]
AssetDepreciationPosted(u64),
/// Filesystem error.
#[error("io error: {0}")]
Io(#[from] std::io::Error),
/// SQLite error.
#[error("database error: {0}")]
Rusqlite(#[from] rusqlite::Error),
/// JSON error.
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
/// A document line carries a field this version's journal cannot write
/// yet: line accounts, quantities and unit prices are journaled from
/// S4.3 (`documents_v2`) on. Refused before anything is journaled.
#[error(
"this document line names {field}, which these books cannot journal yet: line accounts, quantities and unit prices wait for the documents_v2 upgrade (S4.3). Nothing was changed; give the line one amount for now"
)]
DocumentFieldsAwaitUpgrade {
/// The field it names: "an account", "a quantity and unit price".
field: &'static str,
},
/// The engine rejected the operation. The journal is untouched.
#[error("ledger error: {0}")]
Ledger(#[from] ledger_core::LedgerError),
/// The journal failed integrity verification.
#[error("journal corrupt: {0}")]
JournalCorrupt(String),
/// A whole, correctly chained journal line this version can't read:
/// a newer wanderbooks wrote the books. When the line after it chains
/// onto it (`verified`), its own bytes are checked: it is intact,
/// though the lines after it are the newer version's to check. When
/// nothing checks it (it is the last line, or the next has no `prev`
/// this version can read), it is either newer or damaged, and the
/// message says so (rules.md B-27).
#[error("{}", newer_journal_text(*seq, event_type, need, *verified))]
NewerJournal {
/// The first line this version can't read.
seq: u64,
/// Its type.
event_type: String,
/// What reading it needs: a journal format, features, or an event
/// type this version doesn't know.
need: String,
/// Whether the line's own hash was checked (the next line's `prev`
/// matches it). False when nothing checks it: a damaged line would
/// read the same.
verified: bool,
},
/// A journal format this version can't declare: newer than it reads,
/// lower than the books already use, or with a feature it doesn't know.
#[error("{0}")]
FormatRefused(String),
/// A write that needs a journal format or feature these books haven't
/// declared: they were written by an older version, and wait for the
/// owner's go-ahead to move to this version's format (the format
/// upgrade: plan/served.md 3.9, D-39). Nothing was written; everything
/// that needs nothing new still works.
#[error("{}", format_upgrade_text(need))]
FormatUpgradePending {
/// What the write needs: "journal format 2", "the journal
/// feature x".
need: String,
},
/// Replay produced different ids than the journal recorded.
#[error("replay mismatch: {0}")]
ReplayMismatch(String),
/// [`crate::Store::verify`] found divergence.
#[error("verify failed: {0}")]
VerifyMismatch(String),
/// A blob's bytes did not match its SHA-256.
#[error("blob corrupt: hash mismatch for {0}")]
CorruptBlob(String),
/// Referenced attachment does not exist.
#[error("unknown attachment {0}")]
UnknownAttachment(u64),
/// The attachment is not linked to any entry.
#[error("attachment {0} is not linked to an entry")]
AttachmentNotLinked(u64),
/// The attachment is linked to an entry; unlink it first.
#[error("attachment {0} is linked to entry {1}; unlink it first with books.unlink_receipt")]
AttachmentLinked(u64, u64),
/// The attachment was withdrawn and can no longer be linked or changed.
#[error("attachment {0} is withdrawn")]
AttachmentWithdrawn(u64),
/// A correction to the evidence trail needs a reason.
#[error("a non-empty reason is required")]
ReasonRequired,
/// The store's schema is newer than this binary understands.
#[error("schema version {0} newer than supported {1}")]
SchemaTooNew(u32, u32),
/// `create` called where a store already exists.
#[error("store already exists at {0}")]
AlreadyExists(String),
/// Another process has these books open for writing. One writer at a
/// time: two would each extend the hash chain from the same place and
/// break it (found by the max-testing pass, 2026-10-13).
#[error("{0}")]
Locked(String),
/// `open` called where no store exists.
#[error("store not found at {0}")]
NotFound(String),
/// The requested base currency differs from the store's.
#[error("base currency mismatch: store is {0}, requested {1}")]
CurrencyMismatch(String, String),
}
/// The guard code a refusal for books waiting for their format upgrade
/// carries (plan/served.md 3.9: `format_upgrade_pending`).
pub const FORMAT_UPGRADE_PENDING: &str = "format_upgrade_pending";
/// What [`StoreError::FormatUpgradePending`] says: what the write needs,
/// that nothing was changed, that only the owner can give the go-ahead
/// (and where), and that the rest still works.
fn format_upgrade_text(need: &str) -> String {
format!(
"{FORMAT_UPGRADE_PENDING}: this needs {need}, and these books are still in the journal \
format an older version of wanderbooks wrote: they are waiting for the owner's \
go-ahead to upgrade to this version's format. Nothing was changed. Only the owner can \
give it, with the owner passphrase: in the app (it asks when it opens the books) or \
with `wanderbooks books upgrade ID` at a terminal; no tool can. Until then everything \
that needs nothing new works as before"
)
}
/// What [`StoreError::NewerJournal`] says. Only a line whose own hash was
/// checked lets it say that line is intact; it never vouches for the
/// lines after it, which this version can't read.
fn newer_journal_text(seq: u64, event_type: &str, need: &str, verified: bool) -> String {
let format = crate::format::SUPPORTED_JOURNAL_FORMAT;
if verified {
format!(
"these books were written by a newer version of wanderbooks: journal line {seq} \
({event_type}) needs {need}, and this version reads journal format {format}. \
Update wanderbooks to open them. Line {seq} is intact (the line after it checks \
it); this version can't check the lines after it"
)
} else {
format!(
"journal line {seq} ({event_type}) needs {need}, and this version reads journal \
format {format}. Nothing this version can read checks that line (it is the last \
line, or the line after it isn't one this version reads): either a newer version \
of wanderbooks wrote it, or the line is damaged. Update wanderbooks, then run \
verify to tell which"
)
}
}