use std::collections::BTreeMap;
use std::marker::PhantomData;
use smallvec::SmallVec;
use time::Date;
use uuid::Uuid;
use crate::account::{AccountId, AccountRegistry, encode_date};
use crate::balance::Balance;
use crate::canonical::{Canonical, CanonicalWriter};
use crate::dimensions::{DimensionError, Label};
use crate::hash::{Hash, tag, tagged};
use crate::money::{Amount, Currency, MoneyError};
use crate::period::{PeriodCalendar, PeriodState};
use crate::posting::Posting;
use crate::serde_support::validating_string_serde;
pub const MAX_DESCRIPTION_LEN: usize = 512;
pub const MAX_IDEMPOTENCY_KEY_LEN: usize = 128;
const INLINE_POSTINGS: usize = 4;
mod sealed {
pub trait State {}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Draft;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Balanced;
impl sealed::State for Draft {}
impl sealed::State for Balanced {}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct EntryId(Uuid);
impl EntryId {
#[must_use]
pub const fn from_uuid(id: Uuid) -> Self {
Self(id)
}
#[must_use]
pub const fn as_uuid(&self) -> &Uuid {
&self.0
}
#[must_use]
pub fn generate() -> Self {
Self(Uuid::now_v7())
}
}
impl std::fmt::Display for EntryId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct IdempotencyKey(Vec<u8>);
#[cfg(feature = "serde")]
impl serde::Serialize for IdempotencyKey {
fn serialize<S: serde::Serializer>(&self, s: S) -> Result<S::Ok, S::Error> {
s.serialize_str(&self.to_hex())
}
}
#[cfg(feature = "serde")]
impl<'de> serde::Deserialize<'de> for IdempotencyKey {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let s = <std::borrow::Cow<'_, str> as serde::Deserialize>::deserialize(d)?;
Self::parse_hex(&s).map_err(serde::de::Error::custom)
}
}
impl IdempotencyKey {
pub fn new(bytes: impl Into<Vec<u8>>) -> Result<Self, EntryFieldError> {
let bytes = bytes.into();
if bytes.is_empty() {
return Err(EntryFieldError::EmptyIdempotencyKey);
}
if bytes.len() > MAX_IDEMPOTENCY_KEY_LEN {
return Err(EntryFieldError::IdempotencyKeyTooLong);
}
Ok(Self(bytes))
}
#[must_use]
pub fn as_bytes(&self) -> &[u8] {
&self.0
}
#[must_use]
pub fn to_hex(&self) -> String {
let mut out = String::with_capacity(self.0.len().saturating_mul(2));
for b in &self.0 {
out.push(nibble_to_hex(b >> 4));
out.push(nibble_to_hex(b & 0x0f));
}
out
}
pub fn parse_hex(s: &str) -> Result<Self, EntryFieldError> {
if s.len() & 1 == 1 {
return Err(EntryFieldError::MalformedIdempotencyKey);
}
let mut out = Vec::with_capacity(s.len() / 2);
for pair in s.as_bytes().chunks_exact(2) {
let [hi, lo] = pair else {
return Err(EntryFieldError::MalformedIdempotencyKey);
};
let (hi, lo) = (hex_to_nibble(*hi)?, hex_to_nibble(*lo)?);
out.push(hi.wrapping_shl(4) | lo);
}
Self::new(out)
}
}
const fn nibble_to_hex(n: u8) -> char {
match n & 0x0f {
0 => '0',
1 => '1',
2 => '2',
3 => '3',
4 => '4',
5 => '5',
6 => '6',
7 => '7',
8 => '8',
9 => '9',
10 => 'a',
11 => 'b',
12 => 'c',
13 => 'd',
14 => 'e',
_ => 'f',
}
}
const fn hex_to_nibble(c: u8) -> Result<u8, EntryFieldError> {
match c {
b'0'..=b'9' => Ok(c.wrapping_sub(b'0')),
b'a'..=b'f' => Ok(c.wrapping_sub(b'a').wrapping_add(10)),
b'A'..=b'F' => Ok(c.wrapping_sub(b'A').wrapping_add(10)),
_ => Err(EntryFieldError::MalformedIdempotencyKey),
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Description(String);
validating_string_serde!(Description);
impl Description {
pub fn new(s: impl Into<String>) -> Result<Self, EntryFieldError> {
let s = s.into();
if s.chars().count() > MAX_DESCRIPTION_LEN {
return Err(EntryFieldError::DescriptionTooLong);
}
if s.chars().any(|c| c.is_control() && c != '\n') {
return Err(EntryFieldError::DescriptionControlCharacter);
}
Ok(Self(s))
}
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Provenance {
pub actor: Option<Label>,
pub source: Option<Label>,
pub correlation: Option<Label>,
}
impl Provenance {
#[must_use]
pub fn none() -> Self {
Self::default()
}
pub fn with_actor(mut self, actor: &str) -> Result<Self, DimensionError> {
self.actor = Some(Label::new(actor)?);
Ok(self)
}
pub fn with_source(mut self, source: &str) -> Result<Self, DimensionError> {
self.source = Some(Label::new(source)?);
Ok(self)
}
pub fn with_correlation(mut self, correlation: &str) -> Result<Self, DimensionError> {
self.correlation = Some(Label::new(correlation)?);
Ok(self)
}
}
impl Canonical for Provenance {
fn encode(&self, w: &mut CanonicalWriter) {
w.option(self.actor.as_ref(), |w, v| v.encode(w));
w.option(self.source.as_ref(), |w, v| v.encode(w));
w.option(self.correlation.as_ref(), |w, v| v.encode(w));
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct DocumentRef {
pub id: Label,
pub content_hash: Option<Hash>,
}
impl DocumentRef {
pub fn new(id: &str, content_hash: Hash) -> Result<Self, DimensionError> {
Ok(Self {
id: Label::new(id)?,
content_hash: Some(content_hash),
})
}
pub fn unverified(id: &str) -> Result<Self, DimensionError> {
Ok(Self {
id: Label::new(id)?,
content_hash: None,
})
}
#[must_use]
pub const fn is_verifiable(&self) -> bool {
self.content_hash.is_some()
}
}
impl Canonical for DocumentRef {
fn encode(&self, w: &mut CanonicalWriter) {
self.id.encode(w);
w.option(self.content_hash.as_ref(), |w, h| {
w.fixed(h.as_bytes());
});
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum EntryFieldError {
#[error("idempotency key is empty")]
EmptyIdempotencyKey,
#[error("idempotency key exceeds {MAX_IDEMPOTENCY_KEY_LEN} bytes")]
IdempotencyKeyTooLong,
#[error("idempotency key is not valid hex")]
MalformedIdempotencyKey,
#[error("description exceeds {MAX_DESCRIPTION_LEN} characters")]
DescriptionTooLong,
#[error("description contains a control character")]
DescriptionControlCharacter,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum CurrencyPolicy {
#[default]
Multi,
Single(Currency),
}
#[derive(Debug, Clone, Default)]
pub struct LedgerPolicy {
pub currency: CurrencyPolicy,
pub require_activity: bool,
pub max_value_date_drift_days: Option<i64>,
}
#[derive(Debug, Clone, Copy)]
pub struct SealContext<'a> {
pub accounts: &'a AccountRegistry,
pub calendar: &'a PeriodCalendar,
pub policy: &'a LedgerPolicy,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ValidationError {
#[error("entry has {count} posting(s); double-entry requires at least two")]
TooFewPostings {
count: usize,
},
#[error("posting {index} has a zero amount")]
ZeroAmount {
index: usize,
},
#[error("posting {index} has a negative amount; the side is carried by the direction")]
NegativeAmount {
index: usize,
},
#[error("posting {index} references unregistered account {account}")]
UnknownAccount {
index: usize,
account: AccountId,
},
#[error("posting {index} targets account {account}, which has descendants")]
NonLeafAccount {
index: usize,
account: AccountId,
},
#[error("posting {index} targets account {account}, not open on {on}")]
AccountNotOpen {
index: usize,
account: AccountId,
on: Date,
},
#[error(
"{currency} is unbalanced at scale {scale}: debits {debits_minor}, \
credits {credits_minor} (difference {difference_minor})"
)]
Unbalanced {
currency: Currency,
debits_minor: i64,
credits_minor: i64,
difference_minor: i64,
scale: u8,
},
#[error("booking date {on} falls in a {state} period")]
ClosedPeriod {
on: Date,
state: PeriodState,
},
#[error("currency {currency} is not permitted by this ledger")]
CurrencyNotAllowed {
currency: Currency,
},
#[error("posting {index} has no activity dimension")]
MissingActivity {
index: usize,
},
#[error("value date {value} is more than {max_days} days from booking date {booking}")]
ValueDateDrift {
booking: Date,
value: Date,
max_days: i64,
},
#[error("summing {currency} overflowed")]
Overflow {
currency: Currency,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ValidationErrors(Vec<ValidationError>);
impl ValidationErrors {
#[must_use]
pub fn errors(&self) -> &[ValidationError] {
&self.0
}
#[must_use]
pub fn len(&self) -> usize {
self.0.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.0.is_empty()
}
#[must_use]
pub fn any(&self, predicate: impl Fn(&ValidationError) -> bool) -> bool {
self.0.iter().any(predicate)
}
}
impl std::fmt::Display for ValidationErrors {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "entry validation failed with {} error(s)", self.0.len())?;
for e in &self.0 {
write!(f, "\n - {e}")?;
}
Ok(())
}
}
impl std::error::Error for ValidationErrors {}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Entry<S: sealed::State, const P: u8> {
id: EntryId,
idempotency_key: IdempotencyKey,
booking_date: Date,
value_date: Date,
description: Description,
postings: SmallVec<[Posting<P>; INLINE_POSTINGS]>,
kind: Option<Label>,
provenance: Provenance,
document: Option<DocumentRef>,
reverses: Option<EntryId>,
original_booking_date: Option<Date>,
_state: PhantomData<S>,
}
impl<const P: u8> Entry<Draft, P> {
#[must_use]
pub fn new(id: EntryId, idempotency_key: IdempotencyKey, booking_date: Date) -> Self {
Self {
id,
idempotency_key,
booking_date,
value_date: booking_date,
description: Description::default(),
postings: SmallVec::new(),
kind: None,
provenance: Provenance::none(),
document: None,
reverses: None,
original_booking_date: None,
_state: PhantomData,
}
}
#[must_use]
pub fn post(mut self, posting: Posting<P>) -> Self {
self.postings.push(posting);
self
}
#[must_use]
pub fn debit(self, account: AccountId, amount: Amount<P>, currency: Currency) -> Self {
self.post(Posting::debit(account, amount, currency))
}
#[must_use]
pub fn credit(self, account: AccountId, amount: Amount<P>, currency: Currency) -> Self {
self.post(Posting::credit(account, amount, currency))
}
#[must_use]
pub fn with_value_date(mut self, date: Date) -> Self {
self.value_date = date;
self
}
#[must_use]
pub fn with_description(mut self, description: Description) -> Self {
self.description = description;
self
}
#[must_use]
pub fn with_kind(mut self, kind: Label) -> Self {
self.kind = Some(kind);
self
}
#[must_use]
pub fn with_provenance(mut self, provenance: Provenance) -> Self {
self.provenance = provenance;
self
}
#[must_use]
pub fn with_document(mut self, document: DocumentRef) -> Self {
self.document = Some(document);
self
}
#[must_use]
pub fn reversing(mut self, original: EntryId, original_booking_date: Date) -> Self {
self.reverses = Some(original);
self.original_booking_date = Some(original_booking_date);
self
}
pub fn adopt_verified(self, expected: Hash) -> Result<Entry<Balanced, P>, IntegrityError> {
let adopted = Entry::<Balanced, P> {
id: self.id,
idempotency_key: self.idempotency_key,
booking_date: self.booking_date,
value_date: self.value_date,
description: self.description,
postings: self.postings,
kind: self.kind,
provenance: self.provenance,
document: self.document,
reverses: self.reverses,
original_booking_date: self.original_booking_date,
_state: PhantomData,
};
let actual = adopted.content_hash();
if actual == expected {
Ok(adopted)
} else {
Err(IntegrityError { expected, actual })
}
}
pub fn seal(self, ctx: &SealContext<'_>) -> Result<Entry<Balanced, P>, ValidationErrors> {
let mut errors = Vec::new();
if self.postings.len() < 2 {
errors.push(ValidationError::TooFewPostings {
count: self.postings.len(),
});
}
let mut totals: BTreeMap<Currency, Balance<P>> = BTreeMap::new();
for (index, posting) in self.postings.iter().enumerate() {
if posting.amount.is_zero() {
errors.push(ValidationError::ZeroAmount { index });
}
if posting.amount.is_negative() {
errors.push(ValidationError::NegativeAmount { index });
}
match ctx.accounts.get(posting.account) {
None => errors.push(ValidationError::UnknownAccount {
index,
account: posting.account,
}),
Some(account) => {
if !ctx.accounts.is_leaf(posting.account) {
errors.push(ValidationError::NonLeafAccount {
index,
account: posting.account,
});
}
if !account.is_open_on(self.booking_date) {
errors.push(ValidationError::AccountNotOpen {
index,
account: posting.account,
on: self.booking_date,
});
}
}
}
if let CurrencyPolicy::Single(allowed) = ctx.policy.currency
&& posting.currency != allowed
{
errors.push(ValidationError::CurrencyNotAllowed {
currency: posting.currency,
});
}
if ctx.policy.require_activity && posting.dimensions.activity.is_none() {
errors.push(ValidationError::MissingActivity { index });
}
let balance = totals.entry(posting.currency).or_default();
if balance.add(posting.direction, posting.amount).is_err() {
errors.push(ValidationError::Overflow {
currency: posting.currency,
});
}
}
for (currency, balance) in &totals {
if !balance.is_balanced() {
let debits_minor = balance.debits.to_minor();
let credits_minor = balance.credits.to_minor();
errors.push(ValidationError::Unbalanced {
currency: *currency,
debits_minor,
credits_minor,
difference_minor: debits_minor.saturating_sub(credits_minor),
scale: P,
});
}
}
let state = ctx.calendar.state_on(self.booking_date);
if !state.accepts_postings() {
errors.push(ValidationError::ClosedPeriod {
on: self.booking_date,
state,
});
}
if let Some(max_days) = ctx.policy.max_value_date_drift_days {
let booking = i64::from(self.booking_date.to_julian_day());
let value = i64::from(self.value_date.to_julian_day());
let drift = value.saturating_sub(booking).saturating_abs();
if drift > max_days {
errors.push(ValidationError::ValueDateDrift {
booking: self.booking_date,
value: self.value_date,
max_days,
});
}
}
if errors.is_empty() {
Ok(Entry {
id: self.id,
idempotency_key: self.idempotency_key,
booking_date: self.booking_date,
value_date: self.value_date,
description: self.description,
postings: self.postings,
kind: self.kind,
provenance: self.provenance,
document: self.document,
reverses: self.reverses,
original_booking_date: self.original_booking_date,
_state: PhantomData,
})
} else {
Err(ValidationErrors(errors))
}
}
}
impl<S: sealed::State, const P: u8> Entry<S, P> {
#[must_use]
pub fn id(&self) -> EntryId {
self.id
}
#[must_use]
pub fn idempotency_key(&self) -> &IdempotencyKey {
&self.idempotency_key
}
#[must_use]
pub fn booking_date(&self) -> Date {
self.booking_date
}
#[must_use]
pub fn value_date(&self) -> Date {
self.value_date
}
#[must_use]
pub fn description(&self) -> &Description {
&self.description
}
#[must_use]
pub fn postings(&self) -> &[Posting<P>] {
&self.postings
}
#[must_use]
pub fn kind(&self) -> Option<&Label> {
self.kind.as_ref()
}
#[must_use]
pub fn provenance(&self) -> &Provenance {
&self.provenance
}
#[must_use]
pub fn document(&self) -> Option<&DocumentRef> {
self.document.as_ref()
}
#[must_use]
pub fn reverses(&self) -> Option<EntryId> {
self.reverses
}
#[must_use]
pub fn original_booking_date(&self) -> Option<Date> {
self.original_booking_date
}
}
impl<const P: u8> Entry<Balanced, P> {
#[must_use]
pub fn content_hash(&self) -> Hash {
tagged(tag::ENTRY_V1, &self.to_canonical_bytes())
}
pub fn totals(&self) -> Result<BTreeMap<Currency, Balance<P>>, MoneyError> {
let mut out: BTreeMap<Currency, Balance<P>> = BTreeMap::new();
for posting in &self.postings {
out.entry(posting.currency)
.or_default()
.add(posting.direction, posting.amount)?;
}
Ok(out)
}
#[must_use]
pub fn reverse(&self, id: EntryId, key: IdempotencyKey, booking_date: Date) -> Entry<Draft, P> {
let mut draft = Entry::<Draft, P>::new(id, key, booking_date)
.with_description(self.description.clone())
.reversing(self.id, self.booking_date);
for posting in &self.postings {
draft = draft.post(posting.inverted());
}
draft
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[error("stored entry does not match its recorded content hash")]
pub struct IntegrityError {
pub expected: Hash,
pub actual: Hash,
}
#[cfg(feature = "serde")]
#[derive(serde::Serialize)]
struct EntryRef<'a, const P: u8> {
id: &'a EntryId,
idempotency_key: &'a IdempotencyKey,
booking_date: &'a Date,
value_date: &'a Date,
description: &'a Description,
postings: &'a [Posting<P>],
kind: &'a Option<Label>,
provenance: &'a Provenance,
document: &'a Option<DocumentRef>,
reverses: &'a Option<EntryId>,
original_booking_date: &'a Option<Date>,
}
#[cfg(feature = "serde")]
#[derive(serde::Deserialize)]
struct EntryOwned<const P: u8> {
id: EntryId,
idempotency_key: IdempotencyKey,
booking_date: Date,
value_date: Date,
description: Description,
postings: Vec<Posting<P>>,
kind: Option<Label>,
provenance: Provenance,
document: Option<DocumentRef>,
reverses: Option<EntryId>,
original_booking_date: Option<Date>,
}
#[cfg(feature = "serde")]
impl<S: sealed::State, const P: u8> serde::Serialize for Entry<S, P> {
fn serialize<Ser: serde::Serializer>(&self, serializer: Ser) -> Result<Ser::Ok, Ser::Error> {
EntryRef::<P> {
id: &self.id,
idempotency_key: &self.idempotency_key,
booking_date: &self.booking_date,
value_date: &self.value_date,
description: &self.description,
postings: &self.postings,
kind: &self.kind,
provenance: &self.provenance,
document: &self.document,
reverses: &self.reverses,
original_booking_date: &self.original_booking_date,
}
.serialize(serializer)
}
}
#[cfg(feature = "serde")]
impl<'de, const P: u8> serde::Deserialize<'de> for Entry<Draft, P> {
fn deserialize<D: serde::Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
let raw = EntryOwned::<P>::deserialize(d)?;
Ok(Self {
id: raw.id,
idempotency_key: raw.idempotency_key,
booking_date: raw.booking_date,
value_date: raw.value_date,
description: raw.description,
postings: SmallVec::from_vec(raw.postings),
kind: raw.kind,
provenance: raw.provenance,
document: raw.document,
reverses: raw.reverses,
original_booking_date: raw.original_booking_date,
_state: PhantomData,
})
}
}
impl<const P: u8> Canonical for Entry<Balanced, P> {
fn encode(&self, w: &mut CanonicalWriter) {
w.u8(P);
w.bytes(self.idempotency_key.as_bytes());
encode_date(w, self.booking_date);
encode_date(w, self.value_date);
w.str(self.description.as_str());
w.seq(self.postings.iter(), |w, p| p.encode(w));
w.option(self.kind.as_ref(), |w, v| v.encode(w));
self.provenance.encode(w);
w.option(self.document.as_ref(), |w, v| v.encode(w));
w.option(self.reverses.as_ref(), |w, id| {
w.fixed(id.as_uuid().as_bytes());
});
w.option(self.original_booking_date.as_ref(), |w, d| {
encode_date(w, *d);
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::account::{Account, AccountPath};
use crate::dimensions::{ActivityId, Dimensions};
use crate::period::{Period, PeriodId};
use crate::posting::Layer;
use time::macros::date;
type Eur = Amount<2>;
struct Fixture {
accounts: AccountRegistry,
calendar: PeriodCalendar,
policy: LedgerPolicy,
cash: AccountId,
revenue: AccountId,
}
impl Fixture {
fn new() -> Self {
let mut accounts = AccountRegistry::new();
let cash = accounts
.register_path("Assets:Cash", date!(2026 - 01 - 01))
.expect("registers");
let revenue = accounts
.register_path("Income:Sales", date!(2026 - 01 - 01))
.expect("registers");
Self {
accounts,
calendar: PeriodCalendar::new(),
policy: LedgerPolicy::default(),
cash,
revenue,
}
}
fn ctx(&self) -> SealContext<'_> {
SealContext {
accounts: &self.accounts,
calendar: &self.calendar,
policy: &self.policy,
}
}
fn draft(&self) -> Entry<Draft, 2> {
Entry::new(
EntryId::generate(),
IdempotencyKey::new(b"key-1".to_vec()).expect("valid"),
date!(2026 - 03 - 15),
)
}
fn balanced(&self) -> Entry<Balanced, 2> {
self.draft()
.debit(self.cash, Eur::from_minor(1000), Currency::EUR)
.credit(self.revenue, Eur::from_minor(1000), Currency::EUR)
.seal(&self.ctx())
.expect("balances")
}
}
#[test]
fn a_balanced_entry_seals() {
let f = Fixture::new();
let entry = f.balanced();
assert_eq!(entry.postings().len(), 2);
let totals = entry.totals().expect("no overflow");
assert!(totals[&Currency::EUR].is_balanced());
}
#[test]
fn an_unbalanced_entry_is_rejected() {
let f = Fixture::new();
let err = f
.draft()
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(999), Currency::EUR)
.seal(&f.ctx())
.expect_err("must not balance");
assert!(err.any(|e| matches!(e, ValidationError::Unbalanced { .. })));
}
#[test]
fn a_single_posting_is_not_double_entry() {
let f = Fixture::new();
let err = f
.draft()
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.seal(&f.ctx())
.expect_err("too few postings");
assert!(err.any(|e| matches!(e, ValidationError::TooFewPostings { count: 1 })));
}
#[test]
fn zero_and_negative_amounts_are_rejected() {
let f = Fixture::new();
let err = f
.draft()
.debit(f.cash, Eur::ZERO, Currency::EUR)
.credit(f.revenue, Eur::from_minor(-5), Currency::EUR)
.seal(&f.ctx())
.expect_err("invalid amounts");
assert!(err.any(|e| matches!(e, ValidationError::ZeroAmount { index: 0 })));
assert!(err.any(|e| matches!(e, ValidationError::NegativeAmount { index: 1 })));
}
#[test]
fn all_violations_are_reported_at_once() {
let f = Fixture::new();
let ghost = AccountId::from_index(999);
let err = f
.draft()
.debit(ghost, Eur::ZERO, Currency::EUR)
.credit(f.revenue, Eur::from_minor(500), Currency::EUR)
.seal(&f.ctx())
.expect_err("multiple problems");
assert!(err.any(|e| matches!(e, ValidationError::ZeroAmount { index: 0 })));
assert!(err.any(|e| matches!(e, ValidationError::UnknownAccount { index: 0, .. })));
assert!(err.any(|e| matches!(e, ValidationError::Unbalanced { .. })));
assert_eq!(err.len(), 3, "expected exactly three violations, got {err}");
}
#[test]
fn an_entry_of_only_zero_postings_is_not_reported_as_unbalanced() {
let f = Fixture::new();
let err = f
.draft()
.debit(f.cash, Eur::ZERO, Currency::EUR)
.credit(f.revenue, Eur::ZERO, Currency::EUR)
.seal(&f.ctx())
.expect_err("zero amounts");
assert!(!err.any(|e| matches!(e, ValidationError::Unbalanced { .. })));
assert_eq!(err.len(), 2);
}
#[test]
fn unknown_accounts_are_rejected() {
let f = Fixture::new();
let ghost = AccountId::from_index(999);
let err = f
.draft()
.debit(ghost, Eur::from_minor(10), Currency::EUR)
.credit(f.revenue, Eur::from_minor(10), Currency::EUR)
.seal(&f.ctx())
.expect_err("unknown account");
assert!(err.any(|e| matches!(e, ValidationError::UnknownAccount { index: 0, .. })));
}
#[test]
fn posting_to_an_aggregation_node_is_rejected() {
let mut f = Fixture::new();
let parent = f
.accounts
.register_path("Assets", date!(2026 - 01 - 01))
.expect("registers");
let err = f
.draft()
.debit(parent, Eur::from_minor(10), Currency::EUR)
.credit(f.revenue, Eur::from_minor(10), Currency::EUR)
.seal(&f.ctx())
.expect_err("non-leaf account");
assert!(err.any(|e| matches!(e, ValidationError::NonLeafAccount { index: 0, .. })));
}
#[test]
fn postings_outside_the_account_window_are_rejected() {
let mut f = Fixture::new();
let closed = f
.accounts
.register(
Account::new(
AccountPath::parse("Assets:Old").expect("valid"),
date!(2026 - 01 - 01),
)
.closing_on(date!(2026 - 02 - 01)),
)
.expect("registers");
let err = f
.draft()
.debit(closed, Eur::from_minor(10), Currency::EUR)
.credit(f.revenue, Eur::from_minor(10), Currency::EUR)
.seal(&f.ctx())
.expect_err("account closed");
assert!(err.any(|e| matches!(e, ValidationError::AccountNotOpen { index: 0, .. })));
}
#[test]
fn sealed_periods_reject_postings() {
let mut f = Fixture::new();
let id = PeriodId::new("2026-03").expect("valid");
f.calendar
.define(
Period::new(id.clone(), date!(2026 - 03 - 01), date!(2026 - 03 - 31))
.expect("valid range"),
)
.expect("defines");
f.calendar
.transition(&id, PeriodState::Closing)
.expect("ok");
f.calendar.transition(&id, PeriodState::Sealed).expect("ok");
let err = f
.draft()
.debit(f.cash, Eur::from_minor(10), Currency::EUR)
.credit(f.revenue, Eur::from_minor(10), Currency::EUR)
.seal(&f.ctx())
.expect_err("period sealed");
assert!(err.any(|e| matches!(
e,
ValidationError::ClosedPeriod {
state: PeriodState::Sealed,
..
}
)));
}
#[test]
fn each_currency_balances_independently() {
let mut f = Fixture::new();
let fx = f
.accounts
.register_path("Assets:FX", date!(2026 - 01 - 01))
.expect("registers");
let entry = f
.draft()
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.debit(fx, Eur::from_minor(500), Currency::USD)
.credit(f.revenue, Eur::from_minor(500), Currency::USD)
.seal(&f.ctx());
assert!(entry.is_ok(), "per-currency balance should succeed");
}
#[test]
fn a_currency_that_does_not_balance_is_named() {
let mut f = Fixture::new();
let fx = f
.accounts
.register_path("Assets:FX", date!(2026 - 01 - 01))
.expect("registers");
let err = f
.draft()
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.debit(fx, Eur::from_minor(500), Currency::USD)
.seal(&f.ctx())
.expect_err("USD is unbalanced");
assert!(err.any(|e| matches!(
e,
ValidationError::Unbalanced {
currency: Currency::USD,
..
}
)));
}
#[test]
fn single_currency_policy_rejects_others() {
let mut f = Fixture::new();
f.policy.currency = CurrencyPolicy::Single(Currency::EUR);
let err = f
.draft()
.debit(f.cash, Eur::from_minor(10), Currency::USD)
.credit(f.revenue, Eur::from_minor(10), Currency::USD)
.seal(&f.ctx())
.expect_err("USD not permitted");
assert!(err.any(|e| matches!(
e,
ValidationError::CurrencyNotAllowed {
currency: Currency::USD
}
)));
}
#[test]
fn activity_can_be_required_on_every_posting() {
let mut f = Fixture::new();
f.policy.require_activity = true;
let err = f
.draft()
.debit(f.cash, Eur::from_minor(10), Currency::EUR)
.credit(f.revenue, Eur::from_minor(10), Currency::EUR)
.seal(&f.ctx())
.expect_err("activity missing");
assert_eq!(
err.errors()
.iter()
.filter(|e| matches!(e, ValidationError::MissingActivity { .. }))
.count(),
2
);
let dims = Dimensions::none().with_activity(ActivityId::new("Network").expect("valid"));
let ok = f
.draft()
.post(
Posting::debit(f.cash, Eur::from_minor(10), Currency::EUR)
.with_dimensions(dims.clone()),
)
.post(
Posting::credit(f.revenue, Eur::from_minor(10), Currency::EUR)
.with_dimensions(dims),
)
.seal(&f.ctx());
assert!(ok.is_ok());
}
#[test]
fn value_date_drift_can_be_bounded() {
let mut f = Fixture::new();
f.policy.max_value_date_drift_days = Some(5);
let err = f
.draft()
.with_value_date(date!(2026 - 04 - 15))
.debit(f.cash, Eur::from_minor(10), Currency::EUR)
.credit(f.revenue, Eur::from_minor(10), Currency::EUR)
.seal(&f.ctx())
.expect_err("drift too large");
assert!(err.any(|e| matches!(e, ValidationError::ValueDateDrift { .. })));
}
#[test]
fn content_hash_is_stable_across_identical_entries() {
let f = Fixture::new();
let a = f.balanced();
let b = f.balanced();
assert_ne!(a.id(), b.id());
assert_eq!(a.content_hash(), b.content_hash());
}
#[test]
fn content_hash_changes_with_any_semantic_field() {
let f = Fixture::new();
let base = f.balanced();
let different_amount = f
.draft()
.debit(f.cash, Eur::from_minor(1001), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1001), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
assert_ne!(base.content_hash(), different_amount.content_hash());
let different_description = f
.draft()
.with_description(Description::new("something else").expect("valid"))
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
assert_ne!(base.content_hash(), different_description.content_hash());
let different_provenance = f
.draft()
.with_provenance(Provenance::none().with_actor("auditor").expect("valid"))
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
assert_ne!(base.content_hash(), different_provenance.content_hash());
}
#[test]
fn swapping_debit_and_credit_changes_the_hash() {
let f = Fixture::new();
let a = f.balanced();
let b = f
.draft()
.credit(f.cash, Eur::from_minor(1000), Currency::EUR)
.debit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
assert_ne!(a.content_hash(), b.content_hash());
}
#[test]
fn reversal_flips_every_side_and_keeps_the_reference() {
let f = Fixture::new();
let original = f.balanced();
let reversal = original
.reverse(
EntryId::generate(),
IdempotencyKey::new(b"reversal-1".to_vec()).expect("valid"),
date!(2026 - 04 - 01),
)
.seal(&f.ctx())
.expect("reversal balances");
assert_eq!(reversal.reverses(), Some(original.id()));
assert_eq!(
reversal.original_booking_date(),
Some(original.booking_date())
);
assert_eq!(reversal.booking_date(), date!(2026 - 04 - 01));
for (orig, rev) in original.postings().iter().zip(reversal.postings()) {
assert_eq!(rev.direction, orig.direction.inverse());
assert_eq!(rev.amount, orig.amount);
assert_eq!(rev.account, orig.account);
}
}
#[test]
fn reversal_of_a_sealed_period_books_into_an_open_one() {
let mut f = Fixture::new();
let original = f.balanced();
let march = PeriodId::new("2026-03").expect("valid");
f.calendar
.define(
Period::new(march.clone(), date!(2026 - 03 - 01), date!(2026 - 03 - 31))
.expect("valid range"),
)
.expect("defines");
f.calendar
.transition(&march, PeriodState::Closing)
.expect("ok");
f.calendar
.transition(&march, PeriodState::Sealed)
.expect("ok");
let refused = original
.reverse(
EntryId::generate(),
IdempotencyKey::new(b"r1".to_vec()).expect("valid"),
date!(2026 - 03 - 20),
)
.seal(&f.ctx());
assert!(refused.is_err());
let accepted = original
.reverse(
EntryId::generate(),
IdempotencyKey::new(b"r2".to_vec()).expect("valid"),
date!(2026 - 04 - 01),
)
.seal(&f.ctx())
.expect("books into an open period");
assert_eq!(
accepted.original_booking_date(),
Some(date!(2026 - 03 - 15))
);
}
#[test]
fn a_reversal_nets_the_original_to_zero() {
let f = Fixture::new();
let original = f.balanced();
let reversal = original
.reverse(
EntryId::generate(),
IdempotencyKey::new(b"r".to_vec()).expect("valid"),
date!(2026 - 04 - 01),
)
.seal(&f.ctx())
.expect("balances");
let mut tb = crate::balance::TrialBalance::<2>::new();
for p in original.postings().iter().chain(reversal.postings()) {
tb.apply(p).expect("no overflow");
}
for (_, balance) in tb.iter() {
assert!(balance.is_balanced(), "every account must net to zero");
}
}
#[test]
fn a_rehydrated_entry_must_match_its_recorded_hash() {
let f = Fixture::new();
let original = f.balanced();
let hash = original.content_hash();
let rebuilt = f
.draft()
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.adopt_verified(hash)
.expect("matches");
assert_eq!(rebuilt.content_hash(), hash);
let tampered = f
.draft()
.debit(f.cash, Eur::from_minor(1001), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1001), Currency::EUR)
.adopt_verified(hash)
.expect_err("must not be adopted");
assert_eq!(tampered.expected, hash);
assert_ne!(tampered.actual, hash);
}
#[test]
fn adoption_does_not_consult_the_current_ledger_state() {
let mut f = Fixture::new();
let original = f.balanced();
let hash = original.content_hash();
f.accounts
.close(f.cash, date!(2026 - 01 - 31))
.expect("closes");
assert!(
f.draft()
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.seal(&f.ctx())
.is_err()
);
assert!(
f.draft()
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.adopt_verified(hash)
.is_ok()
);
}
#[test]
fn field_bounds_are_enforced() {
assert_eq!(
IdempotencyKey::new(Vec::new()),
Err(EntryFieldError::EmptyIdempotencyKey)
);
assert_eq!(
IdempotencyKey::new(vec![0u8; MAX_IDEMPOTENCY_KEY_LEN + 1]),
Err(EntryFieldError::IdempotencyKeyTooLong)
);
assert_eq!(
Description::new("x".repeat(MAX_DESCRIPTION_LEN + 1)),
Err(EntryFieldError::DescriptionTooLong)
);
assert_eq!(
Description::new("bad\u{7}"),
Err(EntryFieldError::DescriptionControlCharacter)
);
assert!(Description::new("multi\nline").is_ok());
}
#[test]
fn document_reference_is_covered_by_the_hash() {
let f = Fixture::new();
let base = f.balanced();
let with_doc = f
.draft()
.with_document(
DocumentRef::new("INV-2026-0001", crate::hash::tagged(b"doc", b"content"))
.expect("valid"),
)
.debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.seal(&f.ctx())
.expect("balances");
assert_ne!(base.content_hash(), with_doc.content_hash());
}
#[test]
fn pending_postings_are_distinguished_from_settled() {
let f = Fixture::new();
let settled = f.balanced();
let pending = f
.draft()
.post(
Posting::debit(f.cash, Eur::from_minor(1000), Currency::EUR)
.in_layer(Layer::Pending),
)
.post(
Posting::credit(f.revenue, Eur::from_minor(1000), Currency::EUR)
.in_layer(Layer::Pending),
)
.seal(&f.ctx())
.expect("balances");
assert_ne!(settled.content_hash(), pending.content_hash());
}
}