Skip to main content

use_ledger/
lib.rs

1#![forbid(unsafe_code)]
2#![doc = include_str!("../README.md")]
3
4use core::fmt;
5use std::{collections::BTreeMap, error::Error, slice};
6
7use use_money::{Money, MoneyError};
8
9/// Common ledger primitives.
10pub mod prelude {
11    pub use crate::{Balance, DebitCredit, JournalEntry, LedgerEntry, LedgerError, Posting};
12}
13
14/// Debit or credit side of a posting.
15#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub enum DebitCredit {
17    /// Debit side.
18    Debit,
19    /// Credit side.
20    Credit,
21}
22
23/// A single account posting.
24#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct Posting {
26    account_id: String,
27    amount: Money,
28    side: DebitCredit,
29}
30
31impl Posting {
32    /// Creates a posting with a non-empty account identifier.
33    ///
34    /// # Errors
35    ///
36    /// Returns [`LedgerError::EmptyAccountId`] when the trimmed account identifier is empty.
37    pub fn new(
38        account_id: impl AsRef<str>,
39        amount: Money,
40        side: DebitCredit,
41    ) -> Result<Self, LedgerError> {
42        let account_id = non_empty(account_id, LedgerError::EmptyAccountId)?;
43        Ok(Self {
44            account_id,
45            amount,
46            side,
47        })
48    }
49
50    /// Returns the account identifier.
51    #[must_use]
52    pub fn account_id(&self) -> &str {
53        &self.account_id
54    }
55
56    /// Returns the posted amount.
57    #[must_use]
58    pub const fn amount(&self) -> &Money {
59        &self.amount
60    }
61
62    /// Returns the debit or credit side.
63    #[must_use]
64    pub const fn side(&self) -> DebitCredit {
65        self.side
66    }
67}
68
69/// A balanced journal entry.
70#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct JournalEntry {
72    entry_id: String,
73    postings: Vec<Posting>,
74}
75
76impl JournalEntry {
77    /// Creates a journal entry and validates that debits and credits balance.
78    ///
79    /// # Errors
80    ///
81    /// Returns [`LedgerError::EmptyEntryId`] for an empty entry identifier,
82    /// [`LedgerError::NoPostings`] when no postings are supplied, and
83    /// [`LedgerError::NotBalanced`] when debits and credits do not balance by currency and scale.
84    pub fn new(entry_id: impl AsRef<str>, postings: Vec<Posting>) -> Result<Self, LedgerError> {
85        let entry_id = non_empty(entry_id, LedgerError::EmptyEntryId)?;
86        if postings.is_empty() {
87            return Err(LedgerError::NoPostings);
88        }
89
90        validate_balanced(&postings)?;
91        Ok(Self { entry_id, postings })
92    }
93
94    /// Returns the journal entry identifier.
95    #[must_use]
96    pub fn entry_id(&self) -> &str {
97        &self.entry_id
98    }
99
100    /// Returns the postings.
101    #[must_use]
102    pub fn postings(&self) -> &[Posting] {
103        &self.postings
104    }
105
106    /// Iterates over postings.
107    pub fn iter(&self) -> slice::Iter<'_, Posting> {
108        self.postings.iter()
109    }
110
111    /// Returns whether debits and credits balance by currency and amount scale.
112    #[must_use]
113    pub fn is_balanced(&self) -> bool {
114        validate_balanced(&self.postings).is_ok()
115    }
116}
117
118/// A journal entry placed into a ledger sequence.
119#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct LedgerEntry {
121    sequence: u64,
122    journal_entry: JournalEntry,
123}
124
125impl LedgerEntry {
126    /// Creates a ledger entry from a sequence number and balanced journal entry.
127    #[must_use]
128    pub const fn new(sequence: u64, journal_entry: JournalEntry) -> Self {
129        Self {
130            sequence,
131            journal_entry,
132        }
133    }
134
135    /// Returns the ledger sequence number.
136    #[must_use]
137    pub const fn sequence(&self) -> u64 {
138        self.sequence
139    }
140
141    /// Returns the journal entry.
142    #[must_use]
143    pub const fn journal_entry(&self) -> &JournalEntry {
144        &self.journal_entry
145    }
146}
147
148/// An account balance.
149#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct Balance {
151    account_id: String,
152    amount: Money,
153}
154
155impl Balance {
156    /// Creates a balance with a non-empty account identifier.
157    ///
158    /// # Errors
159    ///
160    /// Returns [`LedgerError::EmptyAccountId`] when the trimmed account identifier is empty.
161    pub fn new(account_id: impl AsRef<str>, amount: Money) -> Result<Self, LedgerError> {
162        Ok(Self {
163            account_id: non_empty(account_id, LedgerError::EmptyAccountId)?,
164            amount,
165        })
166    }
167
168    /// Returns the account identifier.
169    #[must_use]
170    pub fn account_id(&self) -> &str {
171        &self.account_id
172    }
173
174    /// Returns the balance amount.
175    #[must_use]
176    pub const fn amount(&self) -> &Money {
177        &self.amount
178    }
179
180    /// Applies another same-currency balance amount.
181    ///
182    /// # Errors
183    ///
184    /// Returns [`LedgerError::Money`] when money addition fails.
185    pub fn checked_add(&self, amount: &Money) -> Result<Self, LedgerError> {
186        Ok(Self {
187            account_id: self.account_id.clone(),
188            amount: self
189                .amount
190                .checked_add(amount)
191                .map_err(LedgerError::Money)?,
192        })
193    }
194}
195
196impl<'a> IntoIterator for &'a JournalEntry {
197    type Item = &'a Posting;
198    type IntoIter = slice::Iter<'a, Posting>;
199
200    fn into_iter(self) -> Self::IntoIter {
201        self.iter()
202    }
203}
204
205/// Errors returned by ledger primitives.
206#[derive(Clone, Debug, Eq, PartialEq)]
207pub enum LedgerError {
208    /// Account identifiers must not be empty.
209    EmptyAccountId,
210    /// Entry identifiers must not be empty.
211    EmptyEntryId,
212    /// Journal entries require at least one posting.
213    NoPostings,
214    /// Debits and credits did not balance.
215    NotBalanced,
216    /// Money arithmetic failed.
217    Money(MoneyError),
218}
219
220impl fmt::Display for LedgerError {
221    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
222        match self {
223            Self::EmptyAccountId => formatter.write_str("account identifier cannot be empty"),
224            Self::EmptyEntryId => formatter.write_str("entry identifier cannot be empty"),
225            Self::NoPostings => formatter.write_str("journal entry requires at least one posting"),
226            Self::NotBalanced => {
227                formatter.write_str("journal entry debits and credits must balance")
228            },
229            Self::Money(error) => error.fmt(formatter),
230        }
231    }
232}
233
234impl Error for LedgerError {
235    fn source(&self) -> Option<&(dyn Error + 'static)> {
236        match self {
237            Self::Money(error) => Some(error),
238            Self::EmptyAccountId | Self::EmptyEntryId | Self::NoPostings | Self::NotBalanced => {
239                None
240            },
241        }
242    }
243}
244
245fn non_empty(value: impl AsRef<str>, error: LedgerError) -> Result<String, LedgerError> {
246    let trimmed = value.as_ref().trim();
247    if trimmed.is_empty() {
248        Err(error)
249    } else {
250        Ok(trimmed.to_string())
251    }
252}
253
254fn validate_balanced(postings: &[Posting]) -> Result<(), LedgerError> {
255    let mut totals: BTreeMap<(String, u8), (i128, i128)> = BTreeMap::new();
256
257    for posting in postings {
258        let key = (
259            posting.amount.currency().as_str().to_string(),
260            posting.amount.amount().scale(),
261        );
262        let entry = totals.entry(key).or_insert((0, 0));
263        match posting.side {
264            DebitCredit::Debit => {
265                entry.0 = entry
266                    .0
267                    .checked_add(posting.amount.amount().minor_units())
268                    .ok_or(LedgerError::NotBalanced)?;
269            },
270            DebitCredit::Credit => {
271                entry.1 = entry
272                    .1
273                    .checked_add(posting.amount.amount().minor_units())
274                    .ok_or(LedgerError::NotBalanced)?;
275            },
276        }
277    }
278
279    if totals.values().all(|(debits, credits)| debits == credits) {
280        Ok(())
281    } else {
282        Err(LedgerError::NotBalanced)
283    }
284}
285
286#[cfg(test)]
287mod tests {
288    use use_amount::Amount;
289    use use_currency::CurrencyCode;
290    use use_money::Money;
291
292    use super::{DebitCredit, JournalEntry, LedgerError, Posting};
293
294    fn usd_amount(minor_units: i128) -> Result<Money, Box<dyn std::error::Error>> {
295        Ok(Money::new(
296            Amount::from_minor_units(minor_units, 2)?,
297            CurrencyCode::new("USD")?,
298        ))
299    }
300
301    #[test]
302    fn accepts_balanced_entries() -> Result<(), Box<dyn std::error::Error>> {
303        let amount = usd_amount(5_000)?;
304        let entry = JournalEntry::new(
305            "je-1001",
306            vec![
307                Posting::new("cash", amount.clone(), DebitCredit::Debit)?,
308                Posting::new("revenue", amount, DebitCredit::Credit)?,
309            ],
310        )?;
311
312        assert!(entry.is_balanced());
313        assert_eq!(entry.postings().len(), 2);
314        Ok(())
315    }
316
317    #[test]
318    fn rejects_unbalanced_entries() -> Result<(), Box<dyn std::error::Error>> {
319        let entry = JournalEntry::new(
320            "je-1002",
321            vec![
322                Posting::new("cash", usd_amount(5_000)?, DebitCredit::Debit)?,
323                Posting::new("revenue", usd_amount(4_999)?, DebitCredit::Credit)?,
324            ],
325        );
326
327        assert_eq!(entry, Err(LedgerError::NotBalanced));
328        Ok(())
329    }
330
331    #[test]
332    fn rejects_empty_postings_and_accounts() -> Result<(), Box<dyn std::error::Error>> {
333        assert_eq!(
334            JournalEntry::new("je-empty", Vec::new()),
335            Err(LedgerError::NoPostings)
336        );
337        assert_eq!(
338            Posting::new("", usd_amount(100)?, DebitCredit::Debit),
339            Err(LedgerError::EmptyAccountId)
340        );
341        Ok(())
342    }
343}