#![forbid(unsafe_code)]
#![doc = include_str!("../README.md")]
use core::fmt;
use std::{collections::BTreeMap, error::Error, slice};
use use_money::{Money, MoneyError};
pub mod prelude {
pub use crate::{Balance, DebitCredit, JournalEntry, LedgerEntry, LedgerError, Posting};
}
#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
pub enum DebitCredit {
Debit,
Credit,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Posting {
account_id: String,
amount: Money,
side: DebitCredit,
}
impl Posting {
pub fn new(
account_id: impl AsRef<str>,
amount: Money,
side: DebitCredit,
) -> Result<Self, LedgerError> {
let account_id = non_empty(account_id, LedgerError::EmptyAccountId)?;
Ok(Self {
account_id,
amount,
side,
})
}
#[must_use]
pub fn account_id(&self) -> &str {
&self.account_id
}
#[must_use]
pub const fn amount(&self) -> &Money {
&self.amount
}
#[must_use]
pub const fn side(&self) -> DebitCredit {
self.side
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct JournalEntry {
entry_id: String,
postings: Vec<Posting>,
}
impl JournalEntry {
pub fn new(entry_id: impl AsRef<str>, postings: Vec<Posting>) -> Result<Self, LedgerError> {
let entry_id = non_empty(entry_id, LedgerError::EmptyEntryId)?;
if postings.is_empty() {
return Err(LedgerError::NoPostings);
}
validate_balanced(&postings)?;
Ok(Self { entry_id, postings })
}
#[must_use]
pub fn entry_id(&self) -> &str {
&self.entry_id
}
#[must_use]
pub fn postings(&self) -> &[Posting] {
&self.postings
}
pub fn iter(&self) -> slice::Iter<'_, Posting> {
self.postings.iter()
}
#[must_use]
pub fn is_balanced(&self) -> bool {
validate_balanced(&self.postings).is_ok()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct LedgerEntry {
sequence: u64,
journal_entry: JournalEntry,
}
impl LedgerEntry {
#[must_use]
pub const fn new(sequence: u64, journal_entry: JournalEntry) -> Self {
Self {
sequence,
journal_entry,
}
}
#[must_use]
pub const fn sequence(&self) -> u64 {
self.sequence
}
#[must_use]
pub const fn journal_entry(&self) -> &JournalEntry {
&self.journal_entry
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct Balance {
account_id: String,
amount: Money,
}
impl Balance {
pub fn new(account_id: impl AsRef<str>, amount: Money) -> Result<Self, LedgerError> {
Ok(Self {
account_id: non_empty(account_id, LedgerError::EmptyAccountId)?,
amount,
})
}
#[must_use]
pub fn account_id(&self) -> &str {
&self.account_id
}
#[must_use]
pub const fn amount(&self) -> &Money {
&self.amount
}
pub fn checked_add(&self, amount: &Money) -> Result<Self, LedgerError> {
Ok(Self {
account_id: self.account_id.clone(),
amount: self
.amount
.checked_add(amount)
.map_err(LedgerError::Money)?,
})
}
}
impl<'a> IntoIterator for &'a JournalEntry {
type Item = &'a Posting;
type IntoIter = slice::Iter<'a, Posting>;
fn into_iter(self) -> Self::IntoIter {
self.iter()
}
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub enum LedgerError {
EmptyAccountId,
EmptyEntryId,
NoPostings,
NotBalanced,
Money(MoneyError),
}
impl fmt::Display for LedgerError {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::EmptyAccountId => formatter.write_str("account identifier cannot be empty"),
Self::EmptyEntryId => formatter.write_str("entry identifier cannot be empty"),
Self::NoPostings => formatter.write_str("journal entry requires at least one posting"),
Self::NotBalanced => {
formatter.write_str("journal entry debits and credits must balance")
},
Self::Money(error) => error.fmt(formatter),
}
}
}
impl Error for LedgerError {
fn source(&self) -> Option<&(dyn Error + 'static)> {
match self {
Self::Money(error) => Some(error),
Self::EmptyAccountId | Self::EmptyEntryId | Self::NoPostings | Self::NotBalanced => {
None
},
}
}
}
fn non_empty(value: impl AsRef<str>, error: LedgerError) -> Result<String, LedgerError> {
let trimmed = value.as_ref().trim();
if trimmed.is_empty() {
Err(error)
} else {
Ok(trimmed.to_string())
}
}
fn validate_balanced(postings: &[Posting]) -> Result<(), LedgerError> {
let mut totals: BTreeMap<(String, u8), (i128, i128)> = BTreeMap::new();
for posting in postings {
let key = (
posting.amount.currency().as_str().to_string(),
posting.amount.amount().scale(),
);
let entry = totals.entry(key).or_insert((0, 0));
match posting.side {
DebitCredit::Debit => {
entry.0 = entry
.0
.checked_add(posting.amount.amount().minor_units())
.ok_or(LedgerError::NotBalanced)?;
},
DebitCredit::Credit => {
entry.1 = entry
.1
.checked_add(posting.amount.amount().minor_units())
.ok_or(LedgerError::NotBalanced)?;
},
}
}
if totals.values().all(|(debits, credits)| debits == credits) {
Ok(())
} else {
Err(LedgerError::NotBalanced)
}
}
#[cfg(test)]
mod tests {
use use_amount::Amount;
use use_currency::CurrencyCode;
use use_money::Money;
use super::{DebitCredit, JournalEntry, LedgerError, Posting};
fn usd_amount(minor_units: i128) -> Result<Money, Box<dyn std::error::Error>> {
Ok(Money::new(
Amount::from_minor_units(minor_units, 2)?,
CurrencyCode::new("USD")?,
))
}
#[test]
fn accepts_balanced_entries() -> Result<(), Box<dyn std::error::Error>> {
let amount = usd_amount(5_000)?;
let entry = JournalEntry::new(
"je-1001",
vec![
Posting::new("cash", amount.clone(), DebitCredit::Debit)?,
Posting::new("revenue", amount, DebitCredit::Credit)?,
],
)?;
assert!(entry.is_balanced());
assert_eq!(entry.postings().len(), 2);
Ok(())
}
#[test]
fn rejects_unbalanced_entries() -> Result<(), Box<dyn std::error::Error>> {
let entry = JournalEntry::new(
"je-1002",
vec![
Posting::new("cash", usd_amount(5_000)?, DebitCredit::Debit)?,
Posting::new("revenue", usd_amount(4_999)?, DebitCredit::Credit)?,
],
);
assert_eq!(entry, Err(LedgerError::NotBalanced));
Ok(())
}
#[test]
fn rejects_empty_postings_and_accounts() -> Result<(), Box<dyn std::error::Error>> {
assert_eq!(
JournalEntry::new("je-empty", Vec::new()),
Err(LedgerError::NoPostings)
);
assert_eq!(
Posting::new("", usd_amount(100)?, DebitCredit::Debit),
Err(LedgerError::EmptyAccountId)
);
Ok(())
}
}