doubleentry 0.1.0

An immutable, tamper-evident double-entry bookkeeping engine. Balanced-by-construction entries, exact integer money, deterministic identity, and an append-only Merkle log with inclusion and consistency proofs. Zero I/O. No async. No chart of accounts.
Documentation
//! Checkpoints and balance assertions.
//!
//! Both are *claims about balances that the journal can check*, and they exist
//! for opposite reasons.
//!
//! A [`Checkpoint`] is an optimisation. A balance is defined as a fold over the
//! journal, which is correct and linear in history; a checkpoint records the
//! fold up to a point so later reads start from there. Because that trades a
//! definition for a cache, a checkpoint carries the log position *and* the tree
//! head it was taken against — so it cannot be quietly reused against a history
//! that has changed, and the journal can always re-derive it.
//!
//! A [`BalanceAssertion`] is the opposite: a claim from *outside* the ledger — a
//! bank statement, a counterparty confirmation, an ERP export — checked against
//! the fold. It catches the divergence that reconciliation exists to find, and
//! it is the cheapest such mechanism there is.

use crate::balance::{Balance, BalanceKey};
use crate::merkle::TreeHead;
use crate::money::{Amount, MoneyError};
use crate::posting::Direction;

/// A recorded balance at a known point in the log.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Checkpoint<const P: u8> {
    /// What the balance is for.
    pub key: BalanceKey,
    /// The last log index folded in, or `None` for a checkpoint over an empty
    /// prefix.
    pub through_index: Option<u64>,
    /// The balance at that point.
    pub balance: Balance<P>,
    /// The tree head the checkpoint was taken against.
    ///
    /// Pins the checkpoint to one specific history. A checkpoint whose head no
    /// longer matches the log it is being used with is stale by construction,
    /// not by convention.
    pub tree_head: TreeHead,
}

impl<const P: u8> Checkpoint<P> {
    /// Creates a checkpoint.
    #[must_use]
    pub fn new(
        key: BalanceKey,
        through_index: Option<u64>,
        balance: Balance<P>,
        tree_head: TreeHead,
    ) -> Self {
        Self {
            key,
            through_index,
            balance,
            tree_head,
        }
    }

    /// True when this checkpoint was taken against `head`.
    #[must_use]
    pub fn matches_head(&self, head: &TreeHead) -> bool {
        self.tree_head == *head
    }
}

/// Why a checkpoint failed verification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CheckpointError {
    /// The recorded balance disagrees with a fold over the journal.
    #[error("checkpoint balance does not match the journal")]
    BalanceMismatch,
    /// The checkpoint refers to a log position the journal does not have.
    #[error("checkpoint refers to log index {index}, beyond the journal")]
    IndexOutOfRange {
        /// The index claimed.
        index: u64,
    },
    /// The checkpoint was taken against a different history.
    #[error("checkpoint tree head does not match the journal at that size")]
    HeadMismatch,
    /// Folding the journal overflowed.
    #[error(transparent)]
    Money(#[from] MoneyError),
}

/// A claim that an account holds a particular balance at a point in the log.
///
/// The expected value is a **signed net**, debit positive, because that is the
/// form an external source reports: a statement says what the balance is, not
/// how much moved in each direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BalanceAssertion<const P: u8> {
    /// What is being asserted about.
    pub key: BalanceKey,
    /// The log position to evaluate at, or `None` for the current state.
    pub at: Option<u64>,
    /// The expected signed net, debit positive.
    pub expected: Amount<P>,
}

impl<const P: u8> BalanceAssertion<P> {
    /// Asserts a signed net, debit positive.
    #[must_use]
    pub fn net(key: BalanceKey, expected: Amount<P>) -> Self {
        Self {
            key,
            at: None,
            expected,
        }
    }

    /// Asserts a magnitude on a given side.
    pub fn on_side(
        key: BalanceKey,
        direction: Direction,
        magnitude: Amount<P>,
    ) -> Result<Self, MoneyError> {
        let expected = match direction {
            Direction::Debit => magnitude,
            Direction::Credit => magnitude.checked_neg()?,
        };
        Ok(Self::net(key, expected))
    }

    /// Evaluates the assertion at a specific log position.
    #[must_use]
    pub fn at_index(mut self, index: u64) -> Self {
        self.at = Some(index);
        self
    }

    /// Compares the assertion against an actual balance.
    pub fn check(&self, actual: &Balance<P>) -> Result<AssertionOutcome<P>, MoneyError> {
        let actual_net = actual.signed_net()?;
        if actual_net == self.expected {
            Ok(AssertionOutcome::Held { actual: *actual })
        } else {
            Ok(AssertionOutcome::Failed {
                expected: self.expected,
                actual: actual_net,
                difference: actual_net.checked_sub(self.expected)?,
            })
        }
    }
}

/// The result of checking a [`BalanceAssertion`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AssertionOutcome<const P: u8> {
    /// The ledger agrees with the claim.
    Held {
        /// The balance found, with both gross totals.
        actual: Balance<P>,
    },
    /// The ledger disagrees.
    Failed {
        /// What was claimed, as a signed net.
        expected: Amount<P>,
        /// What the ledger holds, as a signed net.
        actual: Amount<P>,
        /// `actual - expected`; the amount unaccounted for.
        difference: Amount<P>,
    },
}

impl<const P: u8> AssertionOutcome<P> {
    /// True when the ledger agreed.
    #[must_use]
    pub const fn held(&self) -> bool {
        matches!(self, Self::Held { .. })
    }
}

impl<const P: u8> std::fmt::Display for AssertionOutcome<P> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Held { actual } => {
                write!(
                    f,
                    "held (debits {}, credits {})",
                    actual.debits, actual.credits
                )
            }
            Self::Failed {
                expected,
                actual,
                difference,
            } => write!(
                f,
                "failed: expected {expected}, found {actual}, off by {difference}"
            ),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::account::AccountId;
    use crate::hash::Hash;
    use crate::money::Currency;
    use crate::posting::Layer;

    type Eur = Amount<2>;

    fn key() -> BalanceKey {
        BalanceKey {
            account: AccountId::from_index(0),
            currency: Currency::EUR,
            layer: Layer::Settled,
        }
    }

    fn balance(debits: i64, credits: i64) -> Balance<2> {
        let mut b = Balance::<2>::ZERO;
        b.add(Direction::Debit, Eur::from_minor(debits))
            .expect("ok");
        b.add(Direction::Credit, Eur::from_minor(credits))
            .expect("ok");
        b
    }

    fn head(size: u64, byte: u8) -> TreeHead {
        TreeHead {
            size,
            root: Hash::from_bytes([byte; 32]),
        }
    }

    #[test]
    fn an_assertion_that_matches_holds() {
        let a = BalanceAssertion::net(key(), Eur::from_minor(100));
        let outcome = a.check(&balance(100, 0)).expect("ok");
        assert!(outcome.held());
    }

    #[test]
    fn an_assertion_reports_the_difference_when_it_fails() {
        let a = BalanceAssertion::net(key(), Eur::from_minor(100));
        let outcome = a.check(&balance(130, 0)).expect("ok");
        assert_eq!(
            outcome,
            AssertionOutcome::Failed {
                expected: Eur::from_minor(100),
                actual: Eur::from_minor(130),
                difference: Eur::from_minor(30),
            }
        );
        assert!(!outcome.held());
    }

    #[test]
    fn an_assertion_matches_the_net_not_the_gross() {
        // 500 in each direction nets to zero, which is what a statement reports.
        let a = BalanceAssertion::net(key(), Eur::ZERO);
        assert!(a.check(&balance(500, 500)).expect("ok").held());
    }

    #[test]
    fn a_credit_side_assertion_is_a_negative_net() {
        let a =
            BalanceAssertion::on_side(key(), Direction::Credit, Eur::from_minor(250)).expect("ok");
        assert_eq!(a.expected, Eur::from_minor(-250));
        assert!(a.check(&balance(0, 250)).expect("ok").held());
    }

    #[test]
    fn a_debit_side_assertion_is_a_positive_net() {
        let a =
            BalanceAssertion::on_side(key(), Direction::Debit, Eur::from_minor(250)).expect("ok");
        assert_eq!(a.expected, Eur::from_minor(250));
        assert!(a.check(&balance(250, 0)).expect("ok").held());
    }

    #[test]
    fn a_checkpoint_knows_which_history_it_belongs_to() {
        let cp = Checkpoint::new(key(), Some(9), balance(100, 0), head(10, 1));
        assert!(cp.matches_head(&head(10, 1)));
        // Same size, different contents: a rewritten history.
        assert!(!cp.matches_head(&head(10, 2)));
        // Same contents, different size: the log has grown.
        assert!(!cp.matches_head(&head(11, 1)));
    }

    #[test]
    fn assertion_display_is_operator_readable() {
        let a = BalanceAssertion::net(key(), Eur::from_minor(100));
        let failed = a.check(&balance(130, 0)).expect("ok");
        assert_eq!(
            failed.to_string(),
            "failed: expected 1.00, found 1.30, off by 0.30"
        );
    }
}