1use std::collections::BTreeMap;
5
6use crate::{AccountId, Chart, Currency, Date, EventSet, PartyId, Payload};
7
8#[derive(Debug, Clone, Default, PartialEq, Eq)]
11pub struct Balances(BTreeMap<(PartyId, AccountId, Currency), i128>);
12
13#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
15pub enum BalanceError {
16 #[error("the balances are too large")]
18 Overflow,
19}
20
21impl Balances {
22 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 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 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; }
47 }
48 }
49 Ok(Balances(rolled))
50 }
51}
52
53impl EventSet {
54 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 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 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 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 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}