use std::collections::BTreeMap;
use time::Date;
use uuid::Uuid;
use crate::account::AccountId;
use crate::balance::{Balance, BalanceKey};
use crate::entry::EntryId;
use crate::journal::LogIndex;
use crate::money::{Amount, Currency, MoneyError};
use crate::posting::{Direction, Layer, Posting};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PostingRef {
pub entry: EntryId,
pub index: u16,
}
impl PostingRef {
#[must_use]
pub const fn new(entry: EntryId, index: u16) -> Self {
Self { entry, index }
}
}
impl std::fmt::Display for PostingRef {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#{}", self.entry, self.index)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct PostingPosition {
pub index: LogIndex,
pub posting: u16,
}
impl PostingPosition {
#[must_use]
pub const fn new(index: LogIndex, posting: u16) -> Self {
Self { index, posting }
}
}
impl std::fmt::Display for PostingPosition {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}#{}", self.index, self.posting)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ClearingId(Uuid);
impl ClearingId {
#[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 ClearingId {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
std::fmt::Display::fmt(&self.0, f)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct ClearedItem<const P: u8> {
pub posting: PostingRef,
pub applied: Amount<P>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Clearing<const P: u8> {
pub id: ClearingId,
pub account: AccountId,
pub currency: Currency,
pub layer: Layer,
pub cleared_on: Date,
pub items: Vec<ClearedItem<P>>,
}
impl<const P: u8> Clearing<P> {
#[must_use]
pub fn key(&self) -> BalanceKey {
BalanceKey {
account: self.account,
currency: self.currency,
layer: self.layer,
}
}
#[must_use]
pub fn new(id: ClearingId, key: BalanceKey, cleared_on: Date) -> Self {
Self {
id,
account: key.account,
currency: key.currency,
layer: key.layer,
cleared_on,
items: Vec::new(),
}
}
#[must_use]
pub fn apply(mut self, posting: PostingRef, applied: Amount<P>) -> Self {
self.items.push(ClearedItem { posting, applied });
self
}
}
pub trait PostingLookup<const P: u8> {
fn posting(&self, reference: PostingRef) -> Option<&Posting<P>>;
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ClearingError {
#[error("a clearing needs at least two items, got {count}")]
TooFewItems {
count: usize,
},
#[error("posting {posting} appears more than once in the same clearing")]
DuplicateItem {
posting: PostingRef,
},
#[error("posting {posting} does not resolve")]
UnknownPosting {
posting: PostingRef,
},
#[error("posting {posting} is not on account {expected}")]
WrongAccount {
posting: PostingRef,
expected: AccountId,
},
#[error("posting {posting} is not in {expected}")]
WrongCurrency {
posting: PostingRef,
expected: Currency,
},
#[error("posting {posting} is not in the {expected} layer")]
WrongLayer {
posting: PostingRef,
expected: Layer,
},
#[error("posting {posting} was applied a non-positive amount")]
NonPositiveApplication {
posting: PostingRef,
},
#[error(
"applying {requested_minor} to {posting} exceeds its residual of \
{residual_minor} (scale {scale})"
)]
OverApplied {
posting: PostingRef,
requested_minor: i64,
residual_minor: i64,
scale: u8,
},
#[error(
"clearing is unbalanced at scale {scale}: debits {debits_minor}, \
credits {credits_minor}"
)]
Unbalanced {
debits_minor: i64,
credits_minor: i64,
scale: u8,
},
#[error("clearing {id} is already recorded")]
DuplicateId {
id: ClearingId,
},
#[error("clearing {id} is not recorded")]
UnknownClearing {
id: ClearingId,
},
#[error("clearing {id} has already been reset")]
AlreadyReset {
id: ClearingId,
},
#[error(transparent)]
Money(#[from] MoneyError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum ClearingEvent<const P: u8> {
Cleared(Clearing<P>),
Reset {
clearing: ClearingId,
on: Date,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OpenItem<const P: u8> {
pub position: PostingPosition,
pub posting: PostingRef,
pub direction: Direction,
pub original: Amount<P>,
pub applied: Amount<P>,
pub residual: Amount<P>,
}
#[derive(Debug, Clone)]
pub struct ClearingRegister<const P: u8> {
events: Vec<ClearingEvent<P>>,
applied: BTreeMap<PostingRef, Amount<P>>,
reset: BTreeMap<ClearingId, Date>,
by_id: BTreeMap<ClearingId, usize>,
}
impl<const P: u8> Default for ClearingRegister<P> {
fn default() -> Self {
Self::new()
}
}
impl<const P: u8> ClearingRegister<P> {
#[must_use]
pub fn new() -> Self {
Self {
events: Vec::new(),
applied: BTreeMap::new(),
reset: BTreeMap::new(),
by_id: BTreeMap::new(),
}
}
#[must_use]
pub fn applied_to(&self, posting: PostingRef) -> Amount<P> {
self.applied.get(&posting).copied().unwrap_or(Amount::ZERO)
}
pub fn residual_of(
&self,
posting: PostingRef,
lookup: &impl PostingLookup<P>,
) -> Result<Amount<P>, ClearingError> {
let found = lookup
.posting(posting)
.ok_or(ClearingError::UnknownPosting { posting })?;
Ok(found.amount.checked_sub(self.applied_to(posting))?)
}
pub fn clear(
&mut self,
clearing: Clearing<P>,
lookup: &impl PostingLookup<P>,
) -> Result<(), ClearingError> {
if self.by_id.contains_key(&clearing.id) {
return Err(ClearingError::DuplicateId { id: clearing.id });
}
if clearing.items.len() < 2 {
return Err(ClearingError::TooFewItems {
count: clearing.items.len(),
});
}
let mut seen: BTreeMap<PostingRef, ()> = BTreeMap::new();
let mut sides = Balance::<P>::ZERO;
for item in &clearing.items {
if seen.insert(item.posting, ()).is_some() {
return Err(ClearingError::DuplicateItem {
posting: item.posting,
});
}
if !item.applied.is_positive() {
return Err(ClearingError::NonPositiveApplication {
posting: item.posting,
});
}
let posting = lookup
.posting(item.posting)
.ok_or(ClearingError::UnknownPosting {
posting: item.posting,
})?;
if posting.account != clearing.account {
return Err(ClearingError::WrongAccount {
posting: item.posting,
expected: clearing.account,
});
}
if posting.currency != clearing.currency {
return Err(ClearingError::WrongCurrency {
posting: item.posting,
expected: clearing.currency,
});
}
if posting.layer != clearing.layer {
return Err(ClearingError::WrongLayer {
posting: item.posting,
expected: clearing.layer,
});
}
let residual = posting.amount.checked_sub(self.applied_to(item.posting))?;
if item.applied > residual {
return Err(ClearingError::OverApplied {
posting: item.posting,
requested_minor: item.applied.to_minor(),
residual_minor: residual.to_minor(),
scale: P,
});
}
sides.add(posting.direction, item.applied)?;
}
if !sides.is_balanced() {
return Err(ClearingError::Unbalanced {
debits_minor: sides.debits.to_minor(),
credits_minor: sides.credits.to_minor(),
scale: P,
});
}
let mut staged = Vec::with_capacity(clearing.items.len());
for item in &clearing.items {
let updated = self.applied_to(item.posting).checked_add(item.applied)?;
staged.push((item.posting, updated));
}
for (posting, updated) in staged {
self.applied.insert(posting, updated);
}
self.by_id.insert(clearing.id, self.events.len());
self.events.push(ClearingEvent::Cleared(clearing));
Ok(())
}
pub fn reset(&mut self, id: ClearingId, on: Date) -> Result<(), ClearingError> {
let Some(index) = self.by_id.get(&id).copied() else {
return Err(ClearingError::UnknownClearing { id });
};
if self.reset.contains_key(&id) {
return Err(ClearingError::AlreadyReset { id });
}
let Some(ClearingEvent::Cleared(clearing)) = self.events.get(index) else {
return Err(ClearingError::UnknownClearing { id });
};
let items = clearing.items.clone();
let mut staged = Vec::with_capacity(items.len());
for item in &items {
let updated = self.applied_to(item.posting).checked_sub(item.applied)?;
staged.push((item.posting, updated));
}
for (posting, updated) in staged {
self.applied.insert(posting, updated);
}
self.reset.insert(id, on);
self.events.push(ClearingEvent::Reset { clearing: id, on });
Ok(())
}
#[must_use]
pub fn is_reset(&self, id: ClearingId) -> bool {
self.reset.contains_key(&id)
}
#[must_use]
pub fn get(&self, id: ClearingId) -> Option<&Clearing<P>> {
match self.by_id.get(&id).and_then(|i| self.events.get(*i)) {
Some(ClearingEvent::Cleared(c)) => Some(c),
_ => None,
}
}
#[must_use]
pub fn events(&self) -> &[ClearingEvent<P>] {
&self.events
}
#[must_use]
pub fn len(&self) -> usize {
self.by_id.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.by_id.is_empty()
}
pub fn open_items(
&self,
candidates: impl IntoIterator<Item = (PostingPosition, PostingRef)>,
lookup: &impl PostingLookup<P>,
) -> Result<Vec<OpenItem<P>>, ClearingError> {
let mut out = Vec::new();
for (position, reference) in candidates {
let Some(posting) = lookup.posting(reference) else {
return Err(ClearingError::UnknownPosting { posting: reference });
};
let applied = self.applied_to(reference);
let residual = posting.amount.checked_sub(applied)?;
if residual.is_positive() {
out.push(OpenItem {
position,
posting: reference,
direction: posting.direction,
original: posting.amount,
applied,
residual,
});
}
}
Ok(out)
}
}
#[cfg(test)]
mod tests {
use super::*;
use time::macros::date;
type Eur = Amount<2>;
struct Table {
postings: BTreeMap<PostingRef, Posting<2>>,
}
impl PostingLookup<2> for Table {
fn posting(&self, reference: PostingRef) -> Option<&Posting<2>> {
self.postings.get(&reference)
}
}
fn account() -> AccountId {
AccountId::from_index(0)
}
fn positioned(refs: &[PostingRef]) -> Vec<(PostingPosition, PostingRef)> {
refs.iter()
.enumerate()
.map(|(i, r)| (PostingPosition::new(LogIndex::new(i as u64), r.index), *r))
.collect()
}
fn table(entries: &[(u16, Direction, i64)]) -> (Table, Vec<PostingRef>) {
let entry = EntryId::generate();
let mut postings = BTreeMap::new();
let mut refs = Vec::new();
for (index, direction, amount) in entries {
let reference = PostingRef::new(entry, *index);
postings.insert(
reference,
Posting::new(
account(),
*direction,
Eur::from_minor(*amount),
Currency::EUR,
),
);
refs.push(reference);
}
(Table { postings }, refs)
}
fn key(layer: Layer) -> BalanceKey {
BalanceKey {
account: account(),
currency: Currency::EUR,
layer,
}
}
fn clearing(items: &[(PostingRef, i64)]) -> Clearing<2> {
clearing_in(Layer::Settled, items)
}
fn clearing_in(layer: Layer, items: &[(PostingRef, i64)]) -> Clearing<2> {
items.iter().fold(
Clearing::new(ClearingId::generate(), key(layer), date!(2026 - 03 - 15)),
|c, (posting, applied)| c.apply(*posting, Eur::from_minor(*applied)),
)
}
#[test]
fn a_full_clearing_closes_both_items() {
let (t, r) = table(&[(0, Direction::Debit, 1000), (1, Direction::Credit, 1000)]);
let mut reg = ClearingRegister::<2>::new();
reg.clear(clearing(&[(r[0], 1000), (r[1], 1000)]), &t)
.expect("clears");
let open = reg.open_items(positioned(&r), &t).expect("ok");
assert!(open.is_empty(), "nothing should remain open");
assert_eq!(reg.len(), 1);
}
#[test]
fn a_partial_payment_leaves_both_sides_open() {
let (t, r) = table(&[(0, Direction::Debit, 1000), (1, Direction::Credit, 400)]);
let mut reg = ClearingRegister::<2>::new();
reg.clear(clearing(&[(r[0], 400), (r[1], 400)]), &t)
.expect("clears");
let open = reg.open_items(positioned(&r), &t).expect("ok");
assert_eq!(open.len(), 1, "the invoice remains partly open");
let item = open.first().expect("present");
assert_eq!(item.posting, r[0]);
assert_eq!(item.original, Eur::from_minor(1000));
assert_eq!(item.applied, Eur::from_minor(400));
assert_eq!(item.residual, Eur::from_minor(600));
}
#[test]
fn successive_partial_payments_accumulate() {
let (t, r) = table(&[
(0, Direction::Debit, 1000),
(1, Direction::Credit, 400),
(2, Direction::Credit, 600),
]);
let mut reg = ClearingRegister::<2>::new();
reg.clear(clearing(&[(r[0], 400), (r[1], 400)]), &t)
.expect("first payment");
reg.clear(clearing(&[(r[0], 600), (r[2], 600)]), &t)
.expect("second payment");
assert_eq!(reg.applied_to(r[0]), Eur::from_minor(1000));
assert!(reg.open_items(positioned(&r), &t).expect("ok").is_empty());
}
#[test]
fn over_application_is_refused() {
let (t, r) = table(&[(0, Direction::Debit, 1000), (1, Direction::Credit, 1000)]);
let mut reg = ClearingRegister::<2>::new();
reg.clear(clearing(&[(r[0], 600), (r[1], 600)]), &t)
.expect("first");
let err = reg
.clear(clearing(&[(r[0], 500), (r[1], 500)]), &t)
.expect_err("only 400 remains");
assert!(matches!(err, ClearingError::OverApplied { .. }));
assert_eq!(reg.applied_to(r[0]), Eur::from_minor(600));
}
#[test]
fn an_unbalanced_clearing_is_refused() {
let (t, r) = table(&[(0, Direction::Debit, 1000), (1, Direction::Credit, 1000)]);
let mut reg = ClearingRegister::<2>::new();
let err = reg
.clear(clearing(&[(r[0], 1000), (r[1], 900)]), &t)
.expect_err("sides differ");
assert!(matches!(err, ClearingError::Unbalanced { .. }));
assert_eq!(reg.applied_to(r[0]), Eur::ZERO);
}
#[test]
fn same_side_items_alone_cannot_clear() {
let (t, r) = table(&[(0, Direction::Debit, 500), (1, Direction::Debit, 500)]);
let mut reg = ClearingRegister::<2>::new();
assert!(matches!(
reg.clear(clearing(&[(r[0], 500), (r[1], 500)]), &t),
Err(ClearingError::Unbalanced { .. })
));
}
#[test]
fn a_reset_reopens_the_items() {
let (t, r) = table(&[(0, Direction::Debit, 1000), (1, Direction::Credit, 1000)]);
let mut reg = ClearingRegister::<2>::new();
let c = clearing(&[(r[0], 1000), (r[1], 1000)]);
let id = c.id;
reg.clear(c, &t).expect("clears");
assert!(reg.open_items(positioned(&r), &t).expect("ok").is_empty());
reg.reset(id, date!(2026 - 04 - 01)).expect("resets");
let open = reg.open_items(positioned(&r), &t).expect("ok");
assert_eq!(open.len(), 2, "both items reopen");
assert!(reg.is_reset(id));
assert_eq!(reg.events().len(), 2);
assert!(reg.get(id).is_some());
}
#[test]
fn a_clearing_cannot_be_reset_twice() {
let (t, r) = table(&[(0, Direction::Debit, 100), (1, Direction::Credit, 100)]);
let mut reg = ClearingRegister::<2>::new();
let c = clearing(&[(r[0], 100), (r[1], 100)]);
let id = c.id;
reg.clear(c, &t).expect("clears");
reg.reset(id, date!(2026 - 04 - 01)).expect("resets");
assert!(matches!(
reg.reset(id, date!(2026 - 04 - 02)),
Err(ClearingError::AlreadyReset { .. })
));
}
#[test]
fn resetting_an_unknown_clearing_is_an_error() {
let mut reg = ClearingRegister::<2>::new();
assert!(matches!(
reg.reset(ClearingId::generate(), date!(2026 - 04 - 01)),
Err(ClearingError::UnknownClearing { .. })
));
}
#[test]
fn reset_then_reclear_works() {
let (t, r) = table(&[
(0, Direction::Debit, 1000),
(1, Direction::Credit, 1000),
(2, Direction::Credit, 1000),
]);
let mut reg = ClearingRegister::<2>::new();
let wrong = clearing(&[(r[0], 1000), (r[1], 1000)]);
let wrong_id = wrong.id;
reg.clear(wrong, &t).expect("clears");
reg.reset(wrong_id, date!(2026 - 04 - 01)).expect("resets");
reg.clear(clearing(&[(r[0], 1000), (r[2], 1000)]), &t)
.expect("re-clears against the right item");
assert_eq!(reg.applied_to(r[1]), Eur::ZERO);
assert_eq!(reg.applied_to(r[2]), Eur::from_minor(1000));
}
#[test]
fn structural_rules_are_enforced() {
let (t, r) = table(&[(0, Direction::Debit, 100), (1, Direction::Credit, 100)]);
let mut reg = ClearingRegister::<2>::new();
assert!(matches!(
reg.clear(clearing(&[(r[0], 100)]), &t),
Err(ClearingError::TooFewItems { count: 1 })
));
assert!(matches!(
reg.clear(clearing(&[(r[0], 50), (r[0], 50)]), &t),
Err(ClearingError::DuplicateItem { .. })
));
assert!(matches!(
reg.clear(clearing(&[(r[0], 0), (r[1], 0)]), &t),
Err(ClearingError::NonPositiveApplication { .. })
));
let ghost = PostingRef::new(EntryId::generate(), 9);
assert!(matches!(
reg.clear(clearing(&[(r[0], 100), (ghost, 100)]), &t),
Err(ClearingError::UnknownPosting { .. })
));
}
#[test]
fn items_on_another_account_or_currency_are_refused() {
let entry = EntryId::generate();
let a = PostingRef::new(entry, 0);
let b = PostingRef::new(entry, 1);
let mut postings = BTreeMap::new();
postings.insert(
a,
Posting::new(
account(),
Direction::Debit,
Eur::from_minor(100),
Currency::EUR,
),
);
postings.insert(
b,
Posting::new(
AccountId::from_index(7),
Direction::Credit,
Eur::from_minor(100),
Currency::EUR,
),
);
let t = Table { postings };
let mut reg = ClearingRegister::<2>::new();
assert!(matches!(
reg.clear(clearing(&[(a, 100), (b, 100)]), &t),
Err(ClearingError::WrongAccount { .. })
));
let mut postings = BTreeMap::new();
postings.insert(
a,
Posting::new(
account(),
Direction::Debit,
Eur::from_minor(100),
Currency::EUR,
),
);
postings.insert(
b,
Posting::new(
account(),
Direction::Credit,
Eur::from_minor(100),
Currency::USD,
),
);
let t = Table { postings };
assert!(matches!(
reg.clear(clearing(&[(a, 100), (b, 100)]), &t),
Err(ClearingError::WrongCurrency { .. })
));
}
#[test]
fn a_duplicate_clearing_id_is_refused() {
let (t, r) = table(&[(0, Direction::Debit, 1000), (1, Direction::Credit, 1000)]);
let mut reg = ClearingRegister::<2>::new();
let mut first = clearing(&[(r[0], 400), (r[1], 400)]);
let id = first.id;
reg.clear(first.clone(), &t).expect("clears");
first.id = id;
first.items = vec![
ClearedItem {
posting: r[0],
applied: Eur::from_minor(100),
},
ClearedItem {
posting: r[1],
applied: Eur::from_minor(100),
},
];
assert!(matches!(
reg.clear(first, &t),
Err(ClearingError::DuplicateId { .. })
));
}
#[test]
fn reservations_clear_against_reservations() {
let entry = EntryId::generate();
let a = PostingRef::new(entry, 0);
let b = PostingRef::new(entry, 1);
let mut postings = BTreeMap::new();
for (reference, direction) in [(a, Direction::Debit), (b, Direction::Credit)] {
postings.insert(
reference,
Posting::new(account(), direction, Eur::from_minor(50), Currency::EUR)
.in_layer(Layer::Pending),
);
}
let t = Table { postings };
let mut reg = ClearingRegister::<2>::new();
assert!(
reg.clear(clearing_in(Layer::Pending, &[(a, 50), (b, 50)]), &t)
.is_ok()
);
}
#[test]
fn a_reservation_does_not_clear_against_a_settled_movement() {
let entry = EntryId::generate();
let reserved = PostingRef::new(entry, 0);
let settled = PostingRef::new(entry, 1);
let mut postings = BTreeMap::new();
postings.insert(
reserved,
Posting::new(
account(),
Direction::Debit,
Eur::from_minor(50),
Currency::EUR,
)
.in_layer(Layer::Pending),
);
postings.insert(
settled,
Posting::new(
account(),
Direction::Credit,
Eur::from_minor(50),
Currency::EUR,
),
);
let t = Table { postings };
let mut reg = ClearingRegister::<2>::new();
assert!(matches!(
reg.clear(
clearing_in(Layer::Settled, &[(reserved, 50), (settled, 50)]),
&t
),
Err(ClearingError::WrongLayer { .. })
));
}
}