use std::collections::BTreeMap;
use time::Date;
use crate::account::AccountId;
use crate::dimensions::DimensionFilter;
use crate::money::{Amount, Currency, MoneyError};
use crate::posting::{Direction, Layer, Posting};
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum DateBasis {
#[default]
Booking,
Value,
}
impl std::fmt::Display for DateBasis {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Booking => "booking date",
Self::Value => "value date",
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EntryDates {
pub booking: Date,
pub value: Date,
}
impl EntryDates {
#[must_use]
pub const fn on(date: Date) -> Self {
Self {
booking: date,
value: date,
}
}
#[must_use]
pub const fn by(&self, basis: DateBasis) -> Date {
match basis {
DateBasis::Booking => self.booking,
DateBasis::Value => self.value,
}
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub struct BalanceQuery<'a> {
prefix: Option<u64>,
from: Option<Date>,
to: Option<Date>,
basis: DateBasis,
dimensions: Option<&'a DimensionFilter>,
}
impl<'a> BalanceQuery<'a> {
#[must_use]
pub fn all() -> Self {
Self::default()
}
#[must_use]
pub fn over_prefix(size: u64) -> Self {
Self {
prefix: Some(size),
..Self::default()
}
}
#[must_use]
pub fn through(end: Date) -> Self {
Self {
to: Some(end),
..Self::default()
}
}
#[must_use]
pub fn between(start: Date, end: Date) -> Self {
Self {
from: Some(start),
to: Some(end),
..Self::default()
}
}
#[must_use]
pub fn from(mut self, start: Date) -> Self {
self.from = Some(start);
self
}
#[must_use]
pub fn to(mut self, end: Date) -> Self {
self.to = Some(end);
self
}
#[must_use]
pub fn within_prefix(mut self, size: u64) -> Self {
self.prefix = Some(size);
self
}
#[must_use]
pub fn matching(mut self, filter: &'a DimensionFilter) -> Self {
self.dimensions = Some(filter);
self
}
#[must_use]
pub fn by_value_date(mut self) -> Self {
self.basis = DateBasis::Value;
self
}
#[must_use]
pub fn by_booking_date(mut self) -> Self {
self.basis = DateBasis::Booking;
self
}
#[must_use]
pub fn basis(&self) -> DateBasis {
self.basis
}
#[must_use]
pub fn prefix(&self) -> Option<u64> {
self.prefix
}
#[must_use]
pub fn start(&self) -> Option<Date> {
self.from
}
#[must_use]
pub fn end(&self) -> Option<Date> {
self.to
}
#[must_use]
pub fn dimensions(&self) -> Option<&'a DimensionFilter> {
self.dimensions.filter(|f| !f.is_empty())
}
#[must_use]
pub fn is_unrestricted(&self) -> bool {
self.prefix.is_none()
&& self.from.is_none()
&& self.to.is_none()
&& self.dimensions().is_none()
}
#[must_use]
pub fn opening(&self) -> Option<Self> {
let before = self.from?.previous_day()?;
Some(Self {
prefix: self.prefix,
from: None,
to: Some(before),
basis: self.basis,
dimensions: self.dimensions,
})
}
#[must_use]
pub fn includes_entry(&self, index: u64, dates: EntryDates) -> bool {
let on = dates.by(self.basis);
self.prefix.is_none_or(|size| index < size)
&& self.from.is_none_or(|start| on >= start)
&& self.to.is_none_or(|end| on <= end)
}
#[must_use]
pub fn includes_posting<const P: u8>(&self, posting: &Posting<P>) -> bool {
self.dimensions()
.is_none_or(|filter| filter.matches(&posting.dimensions))
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Balance<const P: u8> {
pub debits: Amount<P>,
pub credits: Amount<P>,
}
impl<const P: u8> Balance<P> {
pub const ZERO: Self = Self {
debits: Amount::ZERO,
credits: Amount::ZERO,
};
pub fn add(&mut self, direction: Direction, amount: Amount<P>) -> Result<(), MoneyError> {
match direction {
Direction::Debit => self.debits = self.debits.checked_add(amount)?,
Direction::Credit => self.credits = self.credits.checked_add(amount)?,
}
Ok(())
}
pub fn net(&self) -> Result<(Direction, Amount<P>), MoneyError> {
if self.debits >= self.credits {
Ok((Direction::Debit, self.debits.checked_sub(self.credits)?))
} else {
Ok((Direction::Credit, self.credits.checked_sub(self.debits)?))
}
}
pub fn signed_net(&self) -> Result<Amount<P>, MoneyError> {
self.debits.checked_sub(self.credits)
}
#[must_use]
pub fn is_balanced(&self) -> bool {
self.debits == self.credits
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.debits.is_zero() && self.credits.is_zero()
}
pub fn checked_add(&self, other: &Self) -> Result<Self, MoneyError> {
Ok(Self {
debits: self.debits.checked_add(other.debits)?,
credits: self.credits.checked_add(other.credits)?,
})
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BalanceKey {
pub account: AccountId,
pub currency: Currency,
pub layer: Layer,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct TrialBalance<const P: u8> {
entries: BTreeMap<BalanceKey, Balance<P>>,
}
impl<const P: u8> TrialBalance<P> {
#[must_use]
pub fn new() -> Self {
Self {
entries: BTreeMap::new(),
}
}
pub fn apply(&mut self, posting: &Posting<P>) -> Result<(), MoneyError> {
let key = BalanceKey {
account: posting.account,
currency: posting.currency,
layer: posting.layer,
};
self.entries
.entry(key)
.or_default()
.add(posting.direction, posting.amount)
}
pub fn set(&mut self, key: BalanceKey, balance: Balance<P>) {
self.entries.insert(key, balance);
}
pub fn remove(&mut self, key: &BalanceKey) -> Option<Balance<P>> {
self.entries.remove(key)
}
#[must_use]
pub fn get(&self, key: &BalanceKey) -> Option<&Balance<P>> {
self.entries.get(key)
}
#[must_use]
pub fn get_or_zero(&self, key: &BalanceKey) -> Balance<P> {
self.entries.get(key).copied().unwrap_or(Balance::ZERO)
}
pub fn iter(&self) -> impl Iterator<Item = (&BalanceKey, &Balance<P>)> {
self.entries.iter()
}
#[must_use]
pub fn len(&self) -> usize {
self.entries.len()
}
#[must_use]
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn totals(&self, currency: Currency, layer: Layer) -> Result<Balance<P>, MoneyError> {
let mut acc = Balance::ZERO;
for (key, balance) in &self.entries {
if key.currency == currency && key.layer == layer {
acc = acc.checked_add(balance)?;
}
}
Ok(acc)
}
#[must_use]
pub fn currencies(&self) -> Vec<Currency> {
let mut out: Vec<Currency> = self.entries.keys().map(|k| k.currency).collect();
out.sort_unstable();
out.dedup();
out
}
}
#[cfg(test)]
mod tests {
use super::*;
type Eur = Amount<2>;
fn acct(i: u32) -> AccountId {
AccountId::from_index(i)
}
#[test]
fn tracks_gross_totals_separately_from_the_net() {
let mut b = Balance::<2>::ZERO;
b.add(Direction::Debit, Eur::from_minor(1000))
.expect("no overflow");
b.add(Direction::Credit, Eur::from_minor(1000))
.expect("no overflow");
assert_eq!(b.signed_net().expect("no overflow"), Eur::ZERO);
assert_eq!(b.debits, Eur::from_minor(1000));
assert_eq!(b.credits, Eur::from_minor(1000));
assert!(b.is_balanced());
assert!(!b.is_empty());
}
#[test]
fn distinguishes_no_activity_from_offsetting_activity() {
let quiet = Balance::<2>::ZERO;
let mut busy = Balance::<2>::ZERO;
busy.add(Direction::Debit, Eur::from_minor(500))
.expect("no overflow");
busy.add(Direction::Credit, Eur::from_minor(500))
.expect("no overflow");
assert_eq!(
quiet.signed_net().expect("ok"),
busy.signed_net().expect("ok")
);
assert_ne!(quiet, busy);
assert!(quiet.is_empty());
assert!(!busy.is_empty());
}
#[test]
fn net_reports_the_dominant_side() {
let mut b = Balance::<2>::ZERO;
b.add(Direction::Debit, Eur::from_minor(300)).expect("ok");
b.add(Direction::Credit, Eur::from_minor(100)).expect("ok");
assert_eq!(
b.net().expect("ok"),
(Direction::Debit, Eur::from_minor(200))
);
let mut c = Balance::<2>::ZERO;
c.add(Direction::Credit, Eur::from_minor(300)).expect("ok");
c.add(Direction::Debit, Eur::from_minor(100)).expect("ok");
assert_eq!(
c.net().expect("ok"),
(Direction::Credit, Eur::from_minor(200))
);
}
#[test]
fn balance_addition_reports_overflow() {
let mut b = Balance::<2>::ZERO;
b.add(Direction::Debit, Eur::MAX).expect("ok");
assert_eq!(
b.add(Direction::Debit, Eur::from_minor(1)),
Err(MoneyError::Overflow)
);
}
#[test]
fn trial_balance_separates_account_currency_and_layer() {
let mut tb = TrialBalance::<2>::new();
tb.apply(&Posting::debit(
acct(0),
Eur::from_minor(100),
Currency::EUR,
))
.expect("ok");
tb.apply(&Posting::debit(
acct(0),
Eur::from_minor(100),
Currency::USD,
))
.expect("ok");
tb.apply(
&Posting::debit(acct(0), Eur::from_minor(100), Currency::EUR).in_layer(Layer::Pending),
)
.expect("ok");
assert_eq!(tb.len(), 3);
assert_eq!(tb.currencies(), vec![Currency::EUR, Currency::USD]);
}
#[test]
fn trial_balance_totals_match_for_a_balanced_set() {
let mut tb = TrialBalance::<2>::new();
tb.apply(&Posting::debit(
acct(0),
Eur::from_minor(250),
Currency::EUR,
))
.expect("ok");
tb.apply(&Posting::credit(
acct(1),
Eur::from_minor(250),
Currency::EUR,
))
.expect("ok");
let totals = tb.totals(Currency::EUR, Layer::Settled).expect("ok");
assert!(totals.is_balanced());
assert_eq!(totals.debits, Eur::from_minor(250));
}
#[test]
fn missing_keys_read_as_zero() {
let tb = TrialBalance::<2>::new();
let key = BalanceKey {
account: acct(7),
currency: Currency::EUR,
layer: Layer::Settled,
};
assert_eq!(tb.get(&key), None);
assert_eq!(tb.get_or_zero(&key), Balance::ZERO);
}
#[test]
fn iteration_order_is_deterministic() {
let build = || {
let mut tb = TrialBalance::<2>::new();
for i in [3u32, 1, 2, 0] {
tb.apply(&Posting::debit(acct(i), Eur::from_minor(1), Currency::EUR))
.expect("ok");
}
tb.iter().map(|(k, _)| k.account).collect::<Vec<_>>()
};
assert_eq!(build(), build());
assert_eq!(
build(),
vec![acct(0), acct(1), acct(2), acct(3)],
"keys must iterate in account order"
);
}
}