use crate::balance::{Balance, BalanceKey};
use crate::merkle::TreeHead;
use crate::money::{Amount, MoneyError};
use crate::posting::Direction;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Checkpoint<const P: u8> {
pub key: BalanceKey,
pub through_index: Option<u64>,
pub balance: Balance<P>,
pub tree_head: TreeHead,
}
impl<const P: u8> Checkpoint<P> {
#[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,
}
}
#[must_use]
pub fn matches_head(&self, head: &TreeHead) -> bool {
self.tree_head == *head
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CheckpointError {
#[error("checkpoint balance does not match the journal")]
BalanceMismatch,
#[error("checkpoint refers to log index {index}, beyond the journal")]
IndexOutOfRange {
index: u64,
},
#[error("checkpoint tree head does not match the journal at that size")]
HeadMismatch,
#[error(transparent)]
Money(#[from] MoneyError),
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BalanceAssertion<const P: u8> {
pub key: BalanceKey,
pub at: Option<u64>,
pub expected: Amount<P>,
}
impl<const P: u8> BalanceAssertion<P> {
#[must_use]
pub fn net(key: BalanceKey, expected: Amount<P>) -> Self {
Self {
key,
at: None,
expected,
}
}
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))
}
#[must_use]
pub fn at_index(mut self, index: u64) -> Self {
self.at = Some(index);
self
}
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)?,
})
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AssertionOutcome<const P: u8> {
Held {
actual: Balance<P>,
},
Failed {
expected: Amount<P>,
actual: Amount<P>,
difference: Amount<P>,
},
}
impl<const P: u8> AssertionOutcome<P> {
#[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() {
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)));
assert!(!cp.matches_head(&head(10, 2)));
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"
);
}
}