use time::Date;
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 balance: Balance<P>,
pub tree_head: TreeHead,
}
impl<const P: u8> Checkpoint<P> {
#[must_use]
pub fn new(key: BalanceKey, balance: Balance<P>, tree_head: TreeHead) -> Self {
Self {
key,
balance,
tree_head,
}
}
#[must_use]
pub const fn size(&self) -> u64 {
self.tree_head.size
}
#[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 covers {size} entries, beyond the journal")]
SizeOutOfRange {
size: 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 enum AssertAt {
Now,
Prefix {
size: u64,
},
OnDate {
date: Date,
},
}
#[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: AssertAt,
pub expected: Amount<P>,
}
impl<const P: u8> BalanceAssertion<P> {
#[must_use]
pub fn net(key: BalanceKey, expected: Amount<P>) -> Self {
Self {
key,
at: AssertAt::Now,
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 over_prefix(mut self, size: u64) -> Self {
self.at = AssertAt::Prefix { size };
self
}
#[must_use]
pub fn on_date(mut self, date: Date) -> Self {
self.at = AssertAt::OnDate { date };
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(), balance(100, 0), head(10, 1));
assert_eq!(
cp.size(),
10,
"the head names the prefix; nothing else does"
);
assert!(cp.matches_head(&head(10, 1)));
assert!(!cp.matches_head(&head(10, 2)));
assert!(!cp.matches_head(&head(11, 1)));
}
#[test]
fn an_assertion_carries_the_point_it_is_about() {
use time::macros::date;
let a = BalanceAssertion::net(key(), Eur::from_minor(100));
assert_eq!(a.at, AssertAt::Now);
assert_eq!(a.over_prefix(0).at, AssertAt::Prefix { size: 0 });
assert_eq!(
a.on_date(date!(2026 - 03 - 31)).at,
AssertAt::OnDate {
date: date!(2026 - 03 - 31)
}
);
}
#[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"
);
}
}