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
9pub mod prelude {
11 pub use crate::{Balance, DebitCredit, JournalEntry, LedgerEntry, LedgerError, Posting};
12}
13
14#[derive(Clone, Copy, Debug, Eq, Hash, Ord, PartialEq, PartialOrd)]
16pub enum DebitCredit {
17 Debit,
19 Credit,
21}
22
23#[derive(Clone, Debug, Eq, PartialEq)]
25pub struct Posting {
26 account_id: String,
27 amount: Money,
28 side: DebitCredit,
29}
30
31impl Posting {
32 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 #[must_use]
52 pub fn account_id(&self) -> &str {
53 &self.account_id
54 }
55
56 #[must_use]
58 pub const fn amount(&self) -> &Money {
59 &self.amount
60 }
61
62 #[must_use]
64 pub const fn side(&self) -> DebitCredit {
65 self.side
66 }
67}
68
69#[derive(Clone, Debug, Eq, PartialEq)]
71pub struct JournalEntry {
72 entry_id: String,
73 postings: Vec<Posting>,
74}
75
76impl JournalEntry {
77 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 #[must_use]
96 pub fn entry_id(&self) -> &str {
97 &self.entry_id
98 }
99
100 #[must_use]
102 pub fn postings(&self) -> &[Posting] {
103 &self.postings
104 }
105
106 pub fn iter(&self) -> slice::Iter<'_, Posting> {
108 self.postings.iter()
109 }
110
111 #[must_use]
113 pub fn is_balanced(&self) -> bool {
114 validate_balanced(&self.postings).is_ok()
115 }
116}
117
118#[derive(Clone, Debug, Eq, PartialEq)]
120pub struct LedgerEntry {
121 sequence: u64,
122 journal_entry: JournalEntry,
123}
124
125impl LedgerEntry {
126 #[must_use]
128 pub const fn new(sequence: u64, journal_entry: JournalEntry) -> Self {
129 Self {
130 sequence,
131 journal_entry,
132 }
133 }
134
135 #[must_use]
137 pub const fn sequence(&self) -> u64 {
138 self.sequence
139 }
140
141 #[must_use]
143 pub const fn journal_entry(&self) -> &JournalEntry {
144 &self.journal_entry
145 }
146}
147
148#[derive(Clone, Debug, Eq, PartialEq)]
150pub struct Balance {
151 account_id: String,
152 amount: Money,
153}
154
155impl Balance {
156 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 #[must_use]
170 pub fn account_id(&self) -> &str {
171 &self.account_id
172 }
173
174 #[must_use]
176 pub const fn amount(&self) -> &Money {
177 &self.amount
178 }
179
180 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#[derive(Clone, Debug, Eq, PartialEq)]
207pub enum LedgerError {
208 EmptyAccountId,
210 EmptyEntryId,
212 NoPostings,
214 NotBalanced,
216 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}