Skip to main content

librotenado/
booking.rs

1// SPDX-License-Identifier: MIT
2//! Bookings: postings on accounts that balance in every currency.
3
4use std::{collections::BTreeMap, fmt, str::FromStr};
5
6use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
7
8use crate::{Currency, Date, DocumentId, Money, Object, ParseError, PartyId, TaxCode};
9
10/// The identity of an account: a UUID (RFC 9562) in its one form, lower case `8-4-4-4-12` hexadecimal digits, e.g.
11/// `0192f1e4-7a3c-7b1e-9c2d-5e8f0a1b2c3d`. It carries no meaning – what an account is follows from its kind, parent
12/// and attributes, its name from translation data – so it never has to change. Version 7 is recommended.
13#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct AccountId(String);
15
16impl AccountId {
17    /// The account as text.
18    pub fn as_str(&self) -> &str {
19        &self.0
20    }
21}
22
23impl FromStr for AccountId {
24    type Err = ParseError;
25
26    fn from_str(s: &str) -> Result<Self, Self::Err> {
27        if crate::is_uuid(s) { Ok(AccountId(s.to_owned())) } else { Err(ParseError::Account(s.to_owned())) }
28    }
29}
30
31impl fmt::Display for AccountId {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.write_str(&self.0)
34    }
35}
36
37impl Serialize for AccountId {
38    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
39        serializer.serialize_str(&self.0)
40    }
41}
42
43impl<'de> Deserialize<'de> for AccountId {
44    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
45        String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
46    }
47}
48
49/// One line of a booking: an amount on an account. Positive is debit, negative is credit.
50#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct Posting {
52    /// The organisation or person whose books the posting is in (spec part 0008).
53    pub owner: PartyId,
54    /// The account.
55    pub account: AccountId,
56    /// The amount; positive is debit, negative is credit.
57    pub amount: Money,
58    /// A remark on the posting – for example why there is no document for it; left out when there is none.
59    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
60    pub note: Option<String>,
61    /// How the posting is treated for VAT, as a tax code of its country (`de-out-19`); left out when it is not.
62    #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present")]
63    pub tax: Option<TaxCode>,
64}
65
66/// A booking: a business transaction on a date, as postings that balance for every owner in every currency. One
67/// booking may concern several owners – an invoice split between a business and its owner's private books.
68#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub struct Booking {
70    /// The date the transaction belongs to.
71    pub date: Date,
72    /// What it is about.
73    pub text: String,
74    /// At least two postings.
75    pub postings: Vec<Posting>,
76    /// The documents it is based on (receipts, invoices, statements); left out of JSON when empty.
77    #[serde(default, skip_serializing_if = "Vec::is_empty", deserialize_with = "crate::non_empty")]
78    pub documents: Vec<DocumentId>,
79}
80
81impl Object for Posting {
82    const TYPE: &'static str = "posting";
83}
84
85impl Object for Booking {
86    const TYPE: &'static str = "booking";
87}
88
89/// Why a booking is not valid.
90#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
91pub enum BookingError {
92    /// Fewer than two postings.
93    #[error("a booking needs at least two postings")]
94    TooFewPostings,
95    /// A posting of zero.
96    #[error("posting {0} is zero")]
97    ZeroPosting(usize),
98    /// Debit and credit differ for an owner in a currency.
99    #[error("debit and credit of {owner} differ by {difference}")]
100    Unbalanced {
101        /// The owner whose postings do not balance.
102        owner: PartyId,
103        /// By how much.
104        difference: Money,
105    },
106    /// The sums do not fit the number range.
107    #[error("the amounts are too large")]
108    Overflow,
109}
110
111impl Booking {
112    /// Checks that the booking has at least two postings, none of them zero, and balances for every owner in every
113    /// currency – each owner's books stay balanced on their own.
114    pub fn validate(&self) -> Result<(), BookingError> {
115        if self.postings.len() < 2 {
116            return Err(BookingError::TooFewPostings);
117        }
118        let mut sums: BTreeMap<(&PartyId, Currency), i128> = BTreeMap::new();
119        for (i, posting) in self.postings.iter().enumerate() {
120            if posting.amount.base_units() == 0 {
121                return Err(BookingError::ZeroPosting(i));
122            }
123            let sum = sums.entry((&posting.owner, posting.amount.currency().clone())).or_insert(0);
124            *sum = sum.checked_add(posting.amount.base_units()).ok_or(BookingError::Overflow)?;
125        }
126        match sums.into_iter().find(|&(_, sum)| sum != 0) {
127            Some(((owner, currency), sum)) => match Money::new(sum, currency) {
128                Some(difference) => Err(BookingError::Unbalanced { owner: owner.clone(), difference }),
129                None => Err(BookingError::Overflow),
130            },
131            None => Ok(()),
132        }
133    }
134
135    /// The booking that cancels this one: same accounts, opposite amounts.
136    pub fn reversed(&self, date: Date, text: String) -> Booking {
137        let postings = self
138            .postings
139            .iter()
140            .map(|p| Posting {
141                owner: p.owner.clone(),
142                account: p.account.clone(),
143                amount: -p.amount.clone(),
144                note: None,
145                tax: p.tax.clone(),
146            })
147            .collect();
148        Booking { date, text, postings, documents: self.documents.clone() }
149    }
150}
151
152#[cfg(test)]
153pub(crate) mod tests {
154    use super::*;
155
156    /// A test account: `n` in the last digits of a UUID.
157    pub(crate) fn account(n: u8) -> AccountId {
158        format!("00000000-0000-7000-8000-{n:012x}").parse().unwrap()
159    }
160
161    fn posting(n: u8, base_units: i128, currency: &str) -> Posting {
162        let amount = Money::new(base_units, currency.parse().unwrap()).unwrap();
163        Posting { owner: crate::party::tests::party(1), account: account(n), amount, note: None, tax: None }
164    }
165
166    #[test]
167    fn every_owner_must_balance_on_their_own() {
168        // An internet bill of 50 euros: 30 for the business, 20 private, each paid from its own bank account.
169        let mut business = vec![posting(1, 3000, "EUR"), posting(3, -3000, "EUR")];
170        let mut private = vec![posting(2, 2000, "EUR"), posting(4, -2000, "EUR")];
171        for p in &mut private {
172            p.owner = crate::party::tests::party(2);
173        }
174        let mut postings = business.clone();
175        postings.extend(private.clone());
176        assert_eq!(booking(postings).validate(), Ok(()));
177
178        // The business pays it all: balanced in total, but not for each owner.
179        business[1].amount = Money::new(-5000, "EUR".parse().unwrap()).unwrap();
180        private.pop();
181        business.extend(private);
182        assert!(matches!(
183            booking(business).validate(),
184            Err(BookingError::Unbalanced { owner, .. }) if owner == crate::party::tests::party(1)
185        ));
186    }
187
188    #[test]
189    fn account_identities_are_uuids_in_lower_case() {
190        let id = "0192f1e4-7a3c-7b1e-9c2d-5e8f0a1b2c3d";
191        assert_eq!(id.parse::<AccountId>().unwrap().as_str(), id);
192        for bad in ["0192F1E4-7A3C-7B1E-9C2D-5E8F0A1B2C3D", "0192f1e47a3c7b1e9c2d5e8f0a1b2c3d", "bank", ""] {
193            assert!(bad.parse::<AccountId>().is_err(), "{bad}");
194        }
195    }
196
197    fn booking(postings: Vec<Posting>) -> Booking {
198        Booking { date: "2026-10-24".parse().unwrap(), text: "test".into(), postings, documents: vec![] }
199    }
200
201    #[test]
202    fn a_balanced_booking_is_valid() {
203        let b = booking(vec![posting(1, 8403, "EUR"), posting(2, 1597, "EUR"), posting(3, -10000, "EUR")]);
204        assert_eq!(b.validate(), Ok(()));
205    }
206
207    #[test]
208    fn every_currency_must_balance_on_its_own() {
209        let b = booking(vec![posting(4, -10000, "EUR"), posting(5, 9500, "CHF")]);
210        assert!(matches!(b.validate(), Err(BookingError::Unbalanced { .. })));
211        let b = booking(vec![
212            posting(4, -10000, "EUR"),
213            posting(6, 10000, "EUR"),
214            posting(6, -9500, "CHF"),
215            posting(5, 9500, "CHF"),
216        ]);
217        assert_eq!(b.validate(), Ok(()));
218    }
219
220    #[test]
221    fn rejects_single_and_zero_postings() {
222        assert_eq!(booking(vec![posting(7, 100, "EUR")]).validate(), Err(BookingError::TooFewPostings));
223        let b = booking(vec![posting(7, 0, "EUR"), posting(8, 0, "EUR")]);
224        assert_eq!(b.validate(), Err(BookingError::ZeroPosting(0)));
225    }
226
227    #[test]
228    fn a_reversal_cancels_the_booking() {
229        let b = booking(vec![posting(7, 1250, "EUR"), posting(8, -1250, "EUR")]);
230        let r = b.reversed("2026-10-25".parse().unwrap(), "reversal".into());
231        assert_eq!(r.validate(), Ok(()));
232        assert_eq!(r.postings[0].amount.base_units(), -1250);
233    }
234
235    #[test]
236    fn sums_beyond_the_range_are_an_overflow() {
237        let b = booking(vec![
238            posting(7, Money::MAX_BASE_UNITS, "EUR"),
239            posting(8, Money::MAX_BASE_UNITS, "EUR"),
240            posting(9, 1, "EUR"),
241        ]);
242        assert_eq!(b.validate(), Err(BookingError::Overflow));
243    }
244}