1use 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#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
14pub struct AccountId(String);
15
16impl AccountId {
17 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#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
51pub struct Posting {
52 pub owner: PartyId,
54 pub account: AccountId,
56 pub amount: Money,
58 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present_text")]
60 pub note: Option<String>,
61 #[serde(default, skip_serializing_if = "Option::is_none", deserialize_with = "crate::present")]
63 pub tax: Option<TaxCode>,
64}
65
66#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
69pub struct Booking {
70 pub date: Date,
72 pub text: String,
74 pub postings: Vec<Posting>,
76 #[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#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
91pub enum BookingError {
92 #[error("a booking needs at least two postings")]
94 TooFewPostings,
95 #[error("posting {0} is zero")]
97 ZeroPosting(usize),
98 #[error("debit and credit of {owner} differ by {difference}")]
100 Unbalanced {
101 owner: PartyId,
103 difference: Money,
105 },
106 #[error("the amounts are too large")]
108 Overflow,
109}
110
111impl Booking {
112 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 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 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 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 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}