Skip to main content

librotenado/
balance.rs

1// SPDX-License-Identifier: MIT
2//! Balances (spec part 0004): what the postings of a set of books add up to, per owner, account and currency.
3
4use std::collections::BTreeMap;
5
6use crate::{AccountId, Chart, Currency, Date, EventSet, PartyId, Payload};
7
8/// Balances: per owner, account and currency, the sum of the base units of the postings. Positive is a debit
9/// balance, negative a credit balance.
10#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct Balances(BTreeMap<(PartyId, AccountId, Currency), i128>);
12
13/// Why balances cannot be computed.
14#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum BalanceError {
16    /// A sum does not fit the number range.
17    #[error("the balances are too large")]
18    Overflow,
19}
20
21impl Balances {
22    /// The balance of `account` in the books of `owner` in `currency`; zero where nothing was posted.
23    pub fn get(&self, owner: &PartyId, account: &AccountId, currency: &Currency) -> i128 {
24        self.0.get(&(owner.clone(), account.clone(), currency.clone())).copied().unwrap_or(0)
25    }
26
27    /// Every balance: owner, account, currency, base units – in that order.
28    pub fn iter(&self) -> impl Iterator<Item = (&PartyId, &AccountId, &Currency, i128)> {
29        self.0.iter().map(|((owner, account, currency), units)| (owner, account, currency, *units))
30    }
31
32    /// The balances with every account's balance also added to each account it refines, so that an account's
33    /// balance includes all accounts below it. `parents` maps an account to the account it refines.
34    pub fn rolled_up(&self, parents: &BTreeMap<AccountId, AccountId>) -> Result<Balances, BalanceError> {
35        let mut rolled = BTreeMap::new();
36        for ((owner, account, currency), units) in &self.0 {
37            let mut next = Some(account);
38            let mut steps = 0;
39            while let Some(id) = next {
40                let sum: &mut i128 = rolled.entry((owner.clone(), id.clone(), currency.clone())).or_default();
41                *sum = sum.checked_add(*units).ok_or(BalanceError::Overflow)?;
42                next = parents.get(id);
43                steps += 1;
44                if steps > parents.len() {
45                    break; // a cycle; the review of accounts reports it
46                }
47            }
48        }
49        Ok(Balances(rolled))
50    }
51}
52
53impl EventSet {
54    /// The balances of all bookings – recorded and reversing – dated on or before `until`, or of all bookings.
55    pub fn balances(&self, until: Option<Date>) -> Result<Balances, BalanceError> {
56        let mut sums: BTreeMap<(PartyId, AccountId, Currency), i128> = BTreeMap::new();
57        for (_, event) in self.ordered() {
58            let booking = match &event.payload {
59                Payload::BookingRecorded { booking } | Payload::BookingReversed { booking, .. } => booking,
60                _ => continue,
61            };
62            if until.is_some_and(|until| booking.date > until) {
63                continue;
64            }
65            for posting in &booking.postings {
66                let key = (posting.owner.clone(), posting.account.clone(), posting.amount.currency().clone());
67                let sum = sums.entry(key).or_default();
68                *sum = sum.checked_add(posting.amount.base_units()).ok_or(BalanceError::Overflow)?;
69            }
70        }
71        Ok(Balances(sums))
72    }
73
74    /// Which account each account refines: the accounts of `chart` and those opened in the books.
75    pub fn account_parents(&self, chart: &Chart) -> BTreeMap<AccountId, AccountId> {
76        let mut parents: BTreeMap<AccountId, AccountId> =
77            chart.accounts.iter().filter_map(|account| Some((account.id.clone(), account.parent.clone()?))).collect();
78        for (_, event) in self.ordered() {
79            if let Payload::AccountOpened { account } = &event.payload
80                && let Some(parent) = &account.parent
81            {
82                parents.entry(account.id.clone()).or_insert_with(|| parent.clone());
83            }
84        }
85        parents
86    }
87
88    /// A trial balance: the balances on or before `until`, rolled up the hierarchy of `chart` and the opened
89    /// accounts.
90    pub fn trial_balance(&self, chart: &Chart, until: Option<Date>) -> Result<Balances, BalanceError> {
91        self.balances(until)?.rolled_up(&self.account_parents(chart))
92    }
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::{
99        Account, AccountKind, Booking, Clock, Event, Money, Posting, booking::tests::account, party::tests::party,
100    };
101
102    fn eur() -> Currency {
103        "EUR".parse().unwrap()
104    }
105
106    /// A booking on `date` of `cents` from account 2 (bank) to account `to`, for owner 1.
107    fn booking(date: &str, to: u8, cents: i128) -> Booking {
108        let posting = |n, units| Posting {
109            owner: party(1),
110            account: account(n),
111            amount: Money::new(units, eur()).unwrap(),
112            note: None,
113            tax: None,
114        };
115        Booking {
116            date: date.parse().unwrap(),
117            text: "test".into(),
118            postings: vec![posting(to, cents), posting(2, -cents)],
119            documents: vec![],
120        }
121    }
122
123    fn append(set: &mut EventSet, clock: &mut Clock, ms: u64, payload: Payload) {
124        let (seq, prev) = set.chain_head(&"a".parse().unwrap());
125        set.insert(Event { time: clock.now(ms), seq, prev, payload });
126    }
127
128    #[test]
129    fn balances_per_owner_account_and_currency_up_to_a_date() {
130        let mut set = EventSet::new();
131        let mut clock = Clock::new("a".parse().unwrap());
132        append(&mut set, &mut clock, 1, Payload::BookingRecorded { booking: booking("2026-10-01", 3, 1240) });
133        append(&mut set, &mut clock, 2, Payload::BookingRecorded { booking: booking("2026-11-01", 3, 2990) });
134
135        let all = set.balances(None).unwrap();
136        assert_eq!(all.get(&party(1), &account(3), &eur()), 4230);
137        assert_eq!(all.get(&party(1), &account(2), &eur()), -4230);
138        assert_eq!(all.get(&party(2), &account(3), &eur()), 0, "another owner's books");
139        let october = set.balances(Some("2026-10-31".parse().unwrap())).unwrap();
140        assert_eq!(october.get(&party(1), &account(3), &eur()), 1240);
141        assert_eq!(all.iter().map(|(_, _, _, units)| units).sum::<i128>(), 0, "debit equals credit");
142    }
143
144    #[test]
145    fn a_reversal_brings_the_balance_back() {
146        let mut set = EventSet::new();
147        let mut clock = Clock::new("a".parse().unwrap());
148        let recorded = booking("2026-10-01", 3, 1240);
149        let reversal = recorded.reversed("2026-10-02".parse().unwrap(), "reversal".into());
150        append(&mut set, &mut clock, 1, Payload::BookingRecorded { booking: recorded });
151        let hash = set.ordered()[0].0;
152        append(&mut set, &mut clock, 2, Payload::BookingReversed { reverses: hash, booking: reversal });
153        assert_eq!(set.balances(None).unwrap().get(&party(1), &account(3), &eur()), 0);
154    }
155
156    #[test]
157    fn a_trial_balance_rolls_up_the_hierarchy() {
158        // Chart: 10 expenses ← 3 office; opened in the books: 11 software ← 3.
159        let node = |n, parent: Option<u8>| Account {
160            id: account(n),
161            kind: AccountKind::Expense,
162            parent: parent.map(account),
163            attributes: Default::default(),
164        };
165        let chart =
166            Chart { name: "test".into(), version: "1".into(), accounts: vec![node(10, None), node(3, Some(10))] };
167        let mut set = EventSet::new();
168        let mut clock = Clock::new("a".parse().unwrap());
169        append(&mut set, &mut clock, 1, Payload::AccountOpened { account: node(11, Some(3)) });
170        append(&mut set, &mut clock, 2, Payload::BookingRecorded { booking: booking("2026-10-01", 3, 1000) });
171        append(&mut set, &mut clock, 3, Payload::BookingRecorded { booking: booking("2026-10-02", 11, 500) });
172
173        let trial = set.trial_balance(&chart, None).unwrap();
174        assert_eq!(trial.get(&party(1), &account(11), &eur()), 500);
175        assert_eq!(trial.get(&party(1), &account(3), &eur()), 1500, "office includes software");
176        assert_eq!(trial.get(&party(1), &account(10), &eur()), 1500, "expenses include office");
177    }
178}