1use std::{
5 collections::{BTreeMap, BTreeSet, btree_map::Entry},
6 fmt,
7 str::FromStr,
8 sync::Arc,
9};
10
11use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
12use sha2::{Digest, Sha256};
13
14use crate::{
15 Account, AccountId, Booking, Chart, ContentHash, Currency, DeviceId, Document, DocumentId, Hlc, Money, Object,
16 ObjectError, Organisation, ParseError, PartyId, Person, canonical, from_root, to_root,
17};
18
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
22pub struct EventHash([u8; 32]);
23
24impl FromStr for EventHash {
25 type Err = ParseError;
26
27 fn from_str(s: &str) -> Result<Self, Self::Err> {
28 crate::parse_sha256(s).map(EventHash).ok_or_else(|| ParseError::Hash(s.to_owned()))
29 }
30}
31
32impl fmt::Display for EventHash {
33 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
34 write!(f, "sha256:{}", hex::encode(self.0))
35 }
36}
37
38impl Serialize for EventHash {
39 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
40 serializer.collect_str(self)
41 }
42}
43
44impl<'de> Deserialize<'de> for EventHash {
45 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
46 String::deserialize(deserializer)?.parse().map_err(de::Error::custom)
47 }
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum Payload {
54 OrganisationDescribed {
56 organisation: Organisation,
58 },
59 PersonDescribed {
61 person: Person,
63 },
64 OwnerAdded {
66 owner: PartyId,
68 },
69 OwnerRemoved {
71 owner: PartyId,
73 },
74 ChartSelected {
76 chart: String,
78 },
79 AccountOpened {
81 account: Account,
83 },
84 BookingRecorded {
86 booking: Booking,
88 },
89 BookingReversed {
91 reverses: EventHash,
93 booking: Booking,
95 },
96 DocumentRecorded {
99 document: Document,
101 },
102 #[serde(other, skip_serializing)]
105 Unknown,
106}
107
108#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
111pub struct Event {
112 pub time: Hlc,
114 #[serde(deserialize_with = "crate::safe_u64")]
116 pub seq: u64,
117 #[serde(deserialize_with = "written")]
119 pub prev: Option<EventHash>,
120 #[serde(flatten)]
122 pub payload: Payload,
123}
124
125impl Object for Event {
126 const TYPE: &'static str = "event";
127}
128
129impl Event {
130 pub fn device(&self) -> &DeviceId {
132 &self.time.device
133 }
134
135 pub fn hash(&self) -> EventHash {
141 let json = to_root(self).expect("an event has only strings and safe integers");
142 EventHash(Sha256::digest(json).into())
143 }
144}
145
146fn written<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<EventHash>, D::Error> {
148 Option::deserialize(deserializer)
149}
150
151#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
153pub enum AccountProblem {
154 #[error("event {event} posts on account {account}, which is neither in the chart nor opened")]
156 UnknownAccount {
157 event: EventHash,
159 account: AccountId,
161 },
162 #[error("account {account}, opened in event {event}, refines no known account")]
164 UnknownParent {
165 event: EventHash,
167 account: AccountId,
169 },
170 #[error("account {account}, opened in event {event}, is of another kind than its parent")]
172 KindMismatch {
173 event: EventHash,
175 account: AccountId,
177 },
178 #[error("account {account} is opened again in event {event}")]
180 Duplicate {
181 event: EventHash,
183 account: AccountId,
185 },
186}
187
188#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
190pub enum PartyProblem {
191 #[error("the books belong to no organisation and no person")]
193 NoOwner,
194 #[error("event {event} names owner {owner}, who is not described")]
196 UnknownOwner {
197 event: EventHash,
199 owner: PartyId,
201 },
202 #[error("person {person} has a role in {organisation}, which is not described")]
204 UnknownOrganisation {
205 person: PartyId,
207 organisation: PartyId,
209 },
210 #[error("the shares held in {organisation} do not fit its shares of {nominal}")]
212 Shares {
213 organisation: PartyId,
215 nominal: Money,
217 },
218 #[error("event {event} posts in the books of {owner}, who is not an owner")]
220 NotAnOwner {
221 event: EventHash,
223 owner: PartyId,
225 },
226}
227
228#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
230pub enum DocumentProblem {
231 #[error("event {event} is based on document {document}, which has not been recorded")]
233 UnknownDocument {
234 event: EventHash,
236 document: DocumentId,
238 },
239 #[error("document {document} is recorded again in event {event}")]
241 Duplicate {
242 event: EventHash,
244 document: DocumentId,
246 },
247}
248
249#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
251pub enum ChainProblem {
252 #[error("device {device} has two events at position {seq}")]
254 Fork {
255 device: DeviceId,
257 seq: u64,
259 },
260 #[error("device {device} is missing events {from} to {to}")]
262 Gap {
263 device: DeviceId,
265 from: u64,
267 to: u64,
269 },
270 #[error("event {event} of device {device} does not follow the device's previous event")]
272 BrokenLink {
273 device: DeviceId,
275 event: EventHash,
277 },
278}
279
280#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub struct EventSet {
287 events: BTreeMap<EventHash, Stored>,
288}
289
290#[derive(Debug, Clone, PartialEq, Eq)]
292struct Stored {
293 event: Event,
294 canonical: Arc<[u8]>,
295}
296
297impl EventSet {
298 pub fn new() -> Self {
300 Self::default()
301 }
302
303 pub fn insert(&mut self, event: Event) -> (EventHash, bool) {
310 let canonical = to_root(&event).expect("an event written here has a canonical encoding");
311 self.add(Stored { event, canonical: canonical.into() })
312 }
313
314 pub fn receive(&mut self, json: &[u8]) -> Result<(EventHash, bool), ObjectError> {
317 let canonical = canonical::canonicalize(json)?;
318 let event = from_root(&canonical)?;
319 Ok(self.add(Stored { event, canonical: canonical.into() }))
320 }
321
322 fn add(&mut self, stored: Stored) -> (EventHash, bool) {
323 let hash = EventHash(Sha256::digest(&stored.canonical).into());
324 match self.events.entry(hash) {
325 Entry::Vacant(slot) => {
326 slot.insert(stored);
327 (hash, true)
328 }
329 Entry::Occupied(_) => (hash, false),
330 }
331 }
332
333 pub fn merge(&mut self, other: &EventSet) -> usize {
335 other.events.values().filter(|stored| self.add((*stored).clone()).1).count()
336 }
337
338 pub fn get(&self, hash: &EventHash) -> Option<&Event> {
340 self.events.get(hash).map(|stored| &stored.event)
341 }
342
343 pub fn canonical(&self, hash: &EventHash) -> Option<&[u8]> {
345 self.events.get(hash).map(|stored| &*stored.canonical)
346 }
347
348 pub fn len(&self) -> usize {
350 self.events.len()
351 }
352
353 pub fn is_empty(&self) -> bool {
355 self.events.is_empty()
356 }
357
358 pub fn ordered(&self) -> Vec<(EventHash, &Event)> {
360 let mut events: Vec<_> = self.events.iter().map(|(hash, stored)| (*hash, &stored.event)).collect();
361 events.sort_by(|(ha, a), (hb, b)| a.time.cmp(&b.time).then(ha.cmp(hb)));
362 events
363 }
364
365 pub fn chain_head(&self, device: &DeviceId) -> (u64, Option<EventHash>) {
367 self.events
368 .iter()
369 .map(|(hash, stored)| (hash, &stored.event))
370 .filter(|(_, event)| event.device() == device)
371 .max_by_key(|(_, event)| event.seq)
372 .map_or((1, None), |(hash, event)| (event.seq + 1, Some(*hash)))
373 }
374
375 pub fn selected_charts(&self) -> Vec<&str> {
377 let mut charts: Vec<&str> = Vec::new();
378 for (_, event) in self.ordered() {
379 if let Payload::ChartSelected { chart } = &event.payload
380 && !charts.contains(&chart.as_str())
381 {
382 charts.push(chart);
383 }
384 }
385 charts
386 }
387
388 pub fn review_accounts(&self, chart: &Chart) -> Vec<AccountProblem> {
391 let mut problems = Vec::new();
392 let mut opened: BTreeMap<&AccountId, (EventHash, &Account)> = BTreeMap::new();
393 for (hash, event) in self.ordered() {
394 if let Payload::AccountOpened { account } = &event.payload {
395 if chart.get(&account.id).is_some() || opened.contains_key(&account.id) {
396 problems.push(AccountProblem::Duplicate { event: hash, account: account.id.clone() });
397 } else {
398 opened.insert(&account.id, (hash, account));
399 }
400 }
401 }
402 let kind_of = |id: &AccountId| chart.get(id).map(|a| a.kind).or_else(|| opened.get(id).map(|(_, a)| a.kind));
403 for (hash, account) in opened.values() {
404 match account.parent.as_ref().and_then(kind_of) {
405 None => problems.push(AccountProblem::UnknownParent { event: *hash, account: account.id.clone() }),
406 Some(kind) if kind != account.kind => {
407 problems.push(AccountProblem::KindMismatch { event: *hash, account: account.id.clone() })
408 }
409 Some(_) => {}
410 }
411 }
412 for (hash, event) in self.ordered() {
413 let booking = match &event.payload {
414 Payload::BookingRecorded { booking } | Payload::BookingReversed { booking, .. } => booking,
415 _ => continue,
416 };
417 for posting in &booking.postings {
418 if kind_of(&posting.account).is_none() {
419 problems.push(AccountProblem::UnknownAccount { event: hash, account: posting.account.clone() });
420 }
421 }
422 }
423 problems
424 }
425
426 pub fn parties(&self) -> (BTreeMap<&PartyId, &Organisation>, BTreeMap<&PartyId, &Person>) {
428 let (mut organisations, mut persons) = (BTreeMap::new(), BTreeMap::new());
429 for (_, event) in self.ordered() {
430 match &event.payload {
431 Payload::OrganisationDescribed { organisation } => {
432 organisations.insert(&organisation.id, organisation);
433 }
434 Payload::PersonDescribed { person } => {
435 persons.insert(&person.id, person);
436 }
437 _ => {}
438 }
439 }
440 (organisations, persons)
441 }
442
443 pub fn owners(&self) -> Vec<&PartyId> {
445 let mut owners: Vec<&PartyId> = Vec::new();
446 for (_, event) in self.ordered() {
447 match &event.payload {
448 Payload::OwnerAdded { owner } if !owners.contains(&owner) => owners.push(owner),
449 Payload::OwnerRemoved { owner } => owners.retain(|o| *o != owner),
450 _ => {}
451 }
452 }
453 owners
454 }
455
456 pub fn review_parties(&self) -> Vec<PartyProblem> {
460 let (organisations, persons) = self.parties();
461 let owners = self.owners();
462 let mut problems = Vec::new();
463 if owners.is_empty() {
464 problems.push(PartyProblem::NoOwner);
465 }
466 for (hash, event) in self.ordered() {
467 if let Payload::OwnerAdded { owner } = &event.payload
468 && owners.contains(&owner)
469 && !organisations.contains_key(owner)
470 && !persons.contains_key(owner)
471 {
472 problems.push(PartyProblem::UnknownOwner { event: hash, owner: owner.clone() });
473 }
474 }
475 let mut held: BTreeMap<(&PartyId, &Currency, i128), u64> = BTreeMap::new();
477 for person in persons.values() {
478 for role in &person.roles {
479 if !organisations.contains_key(&role.organisation) {
480 problems.push(PartyProblem::UnknownOrganisation {
481 person: person.id.clone(),
482 organisation: role.organisation.clone(),
483 });
484 } else if let Some(shares) = &role.shares {
485 let nominal = (shares.nominal.currency(), shares.nominal.base_units());
486 let count = held.entry((&role.organisation, nominal.0, nominal.1)).or_insert(0);
487 *count = count.saturating_add(shares.count);
488 }
489 }
490 }
491 for ((organisation, currency, units), count) in held {
492 let nominal = Money::new(units, currency.clone()).expect("taken from an amount");
493 let has = organisations[organisation].shares.iter().find(|s| s.nominal == nominal).map(|s| s.count);
494 if has.is_none_or(|has| count > has) {
495 problems.push(PartyProblem::Shares { organisation: organisation.clone(), nominal });
496 }
497 }
498 for (hash, event) in self.ordered() {
499 if let Payload::BookingRecorded { booking } | Payload::BookingReversed { booking, .. } = &event.payload {
500 let mut named: Vec<&PartyId> = booking.postings.iter().map(|p| &p.owner).collect();
501 named.dedup();
502 for owner in named.into_iter().filter(|o| !owners.contains(o)) {
503 problems.push(PartyProblem::NotAnOwner { event: hash, owner: owner.clone() });
504 }
505 }
506 }
507 problems
508 }
509
510 pub fn state(&self) -> ContentHash {
513 let hashes: Vec<String> = self.ordered().iter().map(|(hash, _)| hash.to_string()).collect();
514 ContentHash::of(&canonical::to_canonical_vec(&hashes).expect("an array of strings"))
515 }
516
517 pub fn review_documents(&self) -> Vec<DocumentProblem> {
520 let mut problems = Vec::new();
521 let mut recorded = BTreeSet::new();
522 for (hash, event) in self.ordered() {
523 if let Payload::DocumentRecorded { document } = &event.payload
524 && !recorded.insert(&document.id)
525 {
526 problems.push(DocumentProblem::Duplicate { event: hash, document: document.id.clone() });
527 }
528 }
529 for (hash, event) in self.ordered() {
530 if let Payload::BookingRecorded { booking } | Payload::BookingReversed { booking, .. } = &event.payload {
531 for document in booking.documents.iter().filter(|d| !recorded.contains(d)) {
532 problems.push(DocumentProblem::UnknownDocument { event: hash, document: document.clone() });
533 }
534 }
535 }
536 problems
537 }
538
539 pub fn verify(&self) -> Vec<ChainProblem> {
541 type Positions<'a> = BTreeMap<u64, Vec<(EventHash, &'a Event)>>;
543 let mut chains: BTreeMap<&DeviceId, Positions> = BTreeMap::new();
544 for (hash, Stored { event, .. }) in &self.events {
545 chains.entry(event.device()).or_default().entry(event.seq).or_default().push((*hash, event));
546 }
547 let mut problems = Vec::new();
548 for (device, positions) in chains {
549 let mut expected = 1;
550 let mut previous: Option<EventHash> = None;
551 for (seq, events) in positions {
552 if seq > expected {
553 problems.push(ChainProblem::Gap { device: device.clone(), from: expected, to: seq - 1 });
554 previous = None;
555 }
556 if events.len() > 1 {
557 problems.push(ChainProblem::Fork { device: device.clone(), seq });
558 previous = None;
559 } else {
560 let (hash, event) = events[0];
561 let linked =
562 if seq == 1 { event.prev.is_none() } else { previous.is_none() || event.prev == previous };
563 if !linked {
564 problems.push(ChainProblem::BrokenLink { device: device.clone(), event: hash });
565 }
566 previous = Some(hash);
567 }
568 expected = seq + 1;
569 }
570 }
571 problems
572 }
573}
574
575#[cfg(test)]
576mod tests {
577 use super::*;
578 use crate::{AccountKind, Clock, Money, Posting, booking::tests::account, party::tests::party};
579
580 fn booking(base_units: i128) -> Booking {
581 let money = |units: i128| Money::new(units, "EUR".parse().unwrap()).unwrap();
582 Booking {
583 date: "2026-10-24".parse().unwrap(),
584 text: "groceries".into(),
585 postings: vec![
586 Posting { owner: party(1), account: account(1), amount: money(base_units), note: None, tax: None },
587 Posting { owner: party(1), account: account(2), amount: money(-base_units), note: None, tax: None },
588 ],
589 documents: vec![],
590 }
591 }
592
593 fn record(set: &mut EventSet, clock: &mut Clock, device: &str, ms: u64, base_units: i128) -> EventHash {
594 let (seq, prev) = set.chain_head(&device.parse().unwrap());
595 let event = Event {
596 time: clock.now(ms),
597 seq,
598 prev,
599 payload: Payload::BookingRecorded { booking: booking(base_units) },
600 };
601 set.insert(event).0
602 }
603
604 #[test]
605 fn hash_is_stable_and_round_trips_through_json() {
606 let mut set = EventSet::new();
607 let mut clock = Clock::new("a".parse().unwrap());
608 let hash = record(&mut set, &mut clock, "a", 1000, 10);
609 let event = set.get(&hash).unwrap();
610 let json = serde_json::to_string(event).unwrap();
611 let back: Event = serde_json::from_str(&json).unwrap();
612 assert_eq!(back.hash(), hash);
613 assert_eq!(hash.to_string().parse::<EventHash>().unwrap(), hash);
614 }
615
616 #[test]
617 fn the_hash_does_not_depend_on_field_order_or_whitespace() {
618 let mut set = EventSet::new();
619 let mut clock = Clock::new("a".parse().unwrap());
620 let hash = record(&mut set, &mut clock, "a", 1000, 10);
621 let event: serde_json::Value = serde_json::from_slice(set.canonical(&hash).unwrap()).unwrap();
622 let reordered = match event {
624 serde_json::Value::Object(map) => {
625 let members: Vec<String> = map.iter().rev().map(|(k, v)| format!("{k:?} : {v}")).collect();
626 format!("{{ {} }}", members.join(" ,\n "))
627 }
628 _ => unreachable!(),
629 };
630 let back: Event = from_root(reordered.as_bytes()).unwrap();
631 assert_eq!(back.hash(), hash);
632 let mut received = EventSet::new();
633 assert_eq!(received.receive(reordered.as_bytes()).unwrap(), (hash, true));
634 assert_eq!(received.canonical(&hash), set.canonical(&hash));
635 }
636
637 fn first_as_value(set: &EventSet) -> serde_json::Value {
639 let (hash, _) = set.ordered()[0];
640 serde_json::from_slice(set.canonical(&hash).unwrap()).unwrap()
641 }
642
643 #[test]
644 fn members_of_later_versions_survive() {
645 let mut set = EventSet::new();
646 let mut clock = Clock::new("a".parse().unwrap());
647 let local = record(&mut set, &mut clock, "a", 1000, 10);
648 let mut value = first_as_value(&set);
649 value["booking"]["future"] = serde_json::json!({"z": 1});
650 let json = serde_json::to_vec(&value).unwrap();
651
652 let mut b = EventSet::new();
653 let (hash, new) = b.receive(&json).unwrap();
654 assert!(new);
655 assert_ne!(hash, local, "a different event");
656 assert_ne!(b.get(&hash).unwrap().hash(), hash, "re-encoding would lose the member");
657 let mut c = EventSet::new();
658 c.merge(&b);
659 assert!(String::from_utf8_lossy(c.canonical(&hash).unwrap()).contains(r#""future":{"z":1}"#));
660 }
661
662 #[test]
663 fn payloads_of_later_versions_are_kept_but_not_read() {
664 let json = r#"{"time":{"wall_ms":1,"counter":0,"device":"a"},"seq":1,"prev":null,"type":"event","kind":"budget_set","amount":"5"}"#;
665 let mut set = EventSet::new();
666 let (hash, _) = set.receive(json.as_bytes()).unwrap();
667 assert_eq!(set.get(&hash).unwrap().payload, Payload::Unknown);
668 assert!(set.verify().is_empty());
669 assert_eq!(set.chain_head(&"a".parse().unwrap()), (2, Some(hash)));
670 }
671
672 #[test]
673 fn none_has_one_form() {
674 let mut set = EventSet::new();
675 let mut clock = Clock::new("a".parse().unwrap());
676 record(&mut set, &mut clock, "a", 1000, 10);
677 let value = first_as_value(&set);
678 let changed = |change: &dyn Fn(&mut serde_json::Value)| {
679 let mut value = value.clone();
680 change(&mut value);
681 EventSet::new().receive(&serde_json::to_vec(&value).unwrap())
682 };
683 assert!(changed(&|_| {}).is_ok());
684 assert!(changed(&|v| v["booking"]["documents"] = serde_json::json!([])).is_err());
685 assert!(changed(&|v| _ = v.as_object_mut().unwrap().remove("prev")).is_err());
686 let document = r#"{"time":{"wall_ms":1,"counter":0,"device":"a"},"seq":1,"prev":null,"type":"event","kind":"document_recorded","document":{"id":"0192f1e4-0000-7000-8000-0000000000d1","kind":"receipt","date":"2026-10-24","received":"2026-10-24","original":{"hash":"sha256:0000000000000000000000000000000000000000000000000000000000000000","size":1,"media_type":"application/pdf","format":null}}}"#;
687 assert!(EventSet::new().receive(document.as_bytes()).is_err());
688 assert!(EventSet::new().receive(document.replace(r#","format":null"#, "").as_bytes()).is_ok());
689 }
690
691 #[test]
692 fn an_event_is_a_root_object_with_its_kind() {
693 let mut set = EventSet::new();
694 let mut clock = Clock::new("a".parse().unwrap());
695 let hash = record(&mut set, &mut clock, "a", 1000, 10);
696 let text = String::from_utf8(set.canonical(&hash).unwrap().to_vec()).unwrap();
697 assert!(text.contains(r#""kind":"booking_recorded""#) && text.ends_with(r#""type":"event"}"#), "{text}");
698 assert!(!text.contains("payload"));
699 }
700
701 fn chart() -> Chart {
703 let account = |n, kind| Account { id: account(n), kind, parent: None, attributes: Default::default() };
704 Chart {
705 name: "test".into(),
706 version: "1".into(),
707 accounts: vec![account(1, AccountKind::Expense), account(2, AccountKind::Asset)],
708 }
709 }
710
711 fn open(set: &mut EventSet, clock: &mut Clock, ms: u64, n: u8, kind: AccountKind, parent: Option<u8>) {
712 let (seq, prev) = set.chain_head(&"a".parse().unwrap());
713 let account = Account { id: account(n), kind, parent: parent.map(account), attributes: Default::default() };
714 set.insert(Event { time: clock.now(ms), seq, prev, payload: Payload::AccountOpened { account } });
715 }
716
717 #[test]
718 fn opened_accounts_refine_known_ones() {
719 let mut set = EventSet::new();
720 let mut clock = Clock::new("a".parse().unwrap());
721 let (seq, prev) = set.chain_head(&"a".parse().unwrap());
722 let selected = Payload::ChartSelected { chart: "test/1".into() };
723 set.insert(Event { time: clock.now(1), seq, prev, payload: selected });
724 open(&mut set, &mut clock, 2, 10, AccountKind::Asset, Some(2));
725 open(&mut set, &mut clock, 3, 11, AccountKind::Asset, Some(10));
726 record(&mut set, &mut clock, "a", 4, 5);
727 assert_eq!(set.selected_charts(), ["test/1"]);
728 assert_eq!(set.review_accounts(&chart()), []);
729
730 open(&mut set, &mut clock, 5, 12, AccountKind::Income, Some(2));
731 open(&mut set, &mut clock, 6, 13, AccountKind::Asset, Some(99));
732 open(&mut set, &mut clock, 7, 13, AccountKind::Asset, Some(2));
733 open(&mut set, &mut clock, 8, 14, AccountKind::Asset, None);
734 let found: Vec<(&str, AccountId)> = set
735 .review_accounts(&chart())
736 .into_iter()
737 .map(|problem| match problem {
738 AccountProblem::KindMismatch { account: a, .. } => ("kind", a),
739 AccountProblem::UnknownParent { account: a, .. } => ("parent", a),
740 AccountProblem::Duplicate { account: a, .. } => ("duplicate", a),
741 AccountProblem::UnknownAccount { account: a, .. } => ("unknown", a),
742 })
743 .collect();
744 for want in [("kind", 12), ("parent", 13), ("duplicate", 13), ("parent", 14)] {
745 assert!(found.contains(&(want.0, account(want.1))), "{want:?} in {found:?}");
746 }
747 assert_eq!(found.len(), 4);
748 }
749
750 #[test]
751 fn postings_on_unknown_accounts_are_for_review() {
752 let mut set = EventSet::new();
753 let mut clock = Clock::new("a".parse().unwrap());
754 record(&mut set, &mut clock, "a", 1, 5);
755 assert_eq!(set.review_accounts(&chart()), []);
756 let empty = Chart { accounts: vec![], ..chart() };
757 assert_eq!(set.review_accounts(&empty).len(), 2, "both postings, until the accounts arrive");
758 }
759
760 #[test]
761 fn bookings_on_unrecorded_documents_are_for_review() {
762 use crate::{Document, DocumentKind, Original};
763 let mut set = EventSet::new();
764 let mut clock = Clock::new("a".parse().unwrap());
765 let id: DocumentId = "0192f1e4-0000-7000-8000-0000000000d1".parse().unwrap();
766 let document = Document {
767 id: id.clone(),
768 kind: DocumentKind::Receipt,
769 number: None,
770 date: "2026-10-24".parse().unwrap(),
771 received: "2026-10-24".parse().unwrap(),
772 original: Original::of(b"a receipt", "image/jpeg"),
773 };
774 let mut based = booking(5);
775 based.documents = vec![id.clone()];
776 let (seq, prev) = set.chain_head(&"a".parse().unwrap());
777 let event = Event { time: clock.now(1), seq, prev, payload: Payload::BookingRecorded { booking: based } };
778 let hash = set.insert(event).0;
779 assert_eq!(set.review_documents(), [DocumentProblem::UnknownDocument { event: hash, document: id.clone() }]);
780
781 for ms in [2, 3] {
782 let (seq, prev) = set.chain_head(&"a".parse().unwrap());
783 let payload = Payload::DocumentRecorded { document: document.clone() };
784 set.insert(Event { time: clock.now(ms), seq, prev, payload });
785 }
786 let problems = set.review_documents();
787 assert!(matches!(problems.as_slice(), [DocumentProblem::Duplicate { document, .. }] if *document == id));
788 }
789
790 #[test]
791 fn a_note_explains_a_posting_and_is_never_empty() {
792 let mut set = EventSet::new();
793 let mut clock = Clock::new("a".parse().unwrap());
794 let mut noted = booking(5);
795 noted.postings[0].note = Some("receipt lost, own receipt to follow".into());
796 let (seq, prev) = set.chain_head(&"a".parse().unwrap());
797 let hash =
798 set.insert(Event { time: clock.now(1), seq, prev, payload: Payload::BookingRecorded { booking: noted } }).0;
799 let text = String::from_utf8(set.canonical(&hash).unwrap().to_vec()).unwrap();
800 assert!(text.contains(r#""note":"receipt lost, own receipt to follow""#));
801 let empty = text.replace("receipt lost, own receipt to follow", "");
802 assert!(EventSet::new().receive(empty.as_bytes()).is_err());
803 }
804
805 fn append(set: &mut EventSet, clock: &mut Clock, ms: u64, payload: Payload) -> EventHash {
807 let (seq, prev) = set.chain_head(&"a".parse().unwrap());
808 set.insert(Event { time: clock.now(ms), seq, prev, payload }).0
809 }
810
811 #[test]
812 fn books_belong_to_described_parties() {
813 use crate::party::tests::{organisation, two_euro_shares};
814 use crate::{Person, Role, RoleKind};
815 let mut set = EventSet::new();
816 let mut clock = Clock::new("a".parse().unwrap());
817 assert_eq!(set.review_parties(), [PartyProblem::NoOwner]);
818
819 let added = append(&mut set, &mut clock, 1, Payload::OwnerAdded { owner: party(1) });
820 assert_eq!(set.review_parties(), [PartyProblem::UnknownOwner { event: added, owner: party(1) }]);
821
822 append(&mut set, &mut clock, 2, Payload::OrganisationDescribed { organisation: organisation(1) });
823 let role = |n| Role { organisation: party(n), kind: RoleKind::Shareholder, shares: Some(two_euro_shares(257)) };
824 let person = Person { id: party(2), name: "Erika Beispiel".into(), tax_ids: vec![], roles: vec![role(1)] };
825 append(&mut set, &mut clock, 3, Payload::PersonDescribed { person: person.clone() });
826 assert_eq!(set.review_parties(), []);
827 assert_eq!(set.owners(), [&party(1)]);
828
829 let renamed = Organisation { name: "Example Trading AG".into(), ..organisation(1) };
830 append(&mut set, &mut clock, 4, Payload::OrganisationDescribed { organisation: renamed });
831 assert_eq!(set.parties().0[&party(1)].name, "Example Trading AG", "the later description replaces");
832
833 let elsewhere = Person { roles: vec![role(9)], ..person };
834 append(&mut set, &mut clock, 5, Payload::PersonDescribed { person: elsewhere });
835 assert_eq!(
836 set.review_parties(),
837 [PartyProblem::UnknownOrganisation { person: party(2), organisation: party(9) }]
838 );
839 let more = Role { shares: Some(two_euro_shares(513)), ..role(1) };
840 let second = Person { id: party(3), name: "Max Beispiel".into(), tax_ids: vec![], roles: vec![more] };
841 append(&mut set, &mut clock, 6, Payload::PersonDescribed { person: second });
842 let too_many = set.review_parties();
843 assert!(too_many.iter().any(|p| matches!(p, PartyProblem::Shares { .. })), "513 of 512: {too_many:?}");
844
845 append(&mut set, &mut clock, 7, Payload::OwnerRemoved { owner: party(1) });
846 record(&mut set, &mut clock, "a", 8, 5);
847 let problems = set.review_parties();
848 assert!(problems.contains(&PartyProblem::NoOwner));
849 assert!(problems.iter().any(|p| matches!(p, PartyProblem::NotAnOwner { owner, .. } if *owner == party(1))));
850 }
851
852 #[test]
853 fn the_state_changes_with_every_event_and_not_with_order() {
854 let (mut a, mut b) = (EventSet::new(), EventSet::new());
855 let (mut ca, mut cb) = (Clock::new("a".parse().unwrap()), Clock::new("b".parse().unwrap()));
856 let empty = a.state();
857 record(&mut a, &mut ca, "a", 1000, 1);
858 record(&mut b, &mut cb, "b", 1500, 2);
859 assert_ne!(a.state(), empty);
860 let before = a.state();
861 let mut ab = a.clone();
862 ab.merge(&b);
863 let mut ba = b.clone();
864 ba.merge(&a);
865 assert_eq!(ab.state(), ba.state());
866 assert_ne!(ab.state(), before);
867 }
868
869 #[test]
870 fn rejects_duplicate_members() {
871 let json =
872 r#"{"seq":1,"seq":2,"time":{"wall_ms":1,"counter":0,"device":"a"},"prev":null,"type":"event","kind":"x"}"#;
873 assert!(matches!(EventSet::new().receive(json.as_bytes()), Err(ObjectError::Canonical(_))));
874 }
875
876 #[test]
877 fn rejects_integers_beyond_the_canonical_range() {
878 let json = r#"{"time":{"wall_ms":9007199254740992,"counter":0,"device":"a"},"seq":1,"prev":null,"type":"event","kind":"booking_recorded","booking":{"date":"2026-10-24","text":"x","postings":[]}}"#;
879 assert!(serde_json::from_str::<Event>(json).is_err());
880 }
881
882 #[test]
883 fn merging_is_order_independent_and_idempotent() {
884 let (mut a, mut b) = (EventSet::new(), EventSet::new());
885 let (mut ca, mut cb) = (Clock::new("a".parse().unwrap()), Clock::new("b".parse().unwrap()));
886 record(&mut a, &mut ca, "a", 1000, 1);
887 record(&mut a, &mut ca, "a", 2000, 2);
888 record(&mut b, &mut cb, "b", 1500, 3);
889
890 let mut ab = a.clone();
891 assert_eq!(ab.merge(&b), 1);
892 let mut ba = b.clone();
893 assert_eq!(ba.merge(&a), 2);
894 assert_eq!(ab, ba);
895 assert_eq!(ab.merge(&b), 0);
896 let order: Vec<u64> = ab.ordered().iter().map(|(_, e)| e.time.wall_ms).collect();
897 assert_eq!(order, [1000, 1500, 2000]);
898 assert!(ab.verify().is_empty());
899 }
900
901 #[test]
902 fn finds_forks_gaps_and_broken_links() {
903 let device: DeviceId = "a".parse().unwrap();
904 let mut clock = Clock::new(device.clone());
905 let mut set = EventSet::new();
906 let first = record(&mut set, &mut clock, "a", 1000, 1);
907
908 let fork = Event {
910 time: clock.now(1001),
911 seq: 1,
912 prev: None,
913 payload: Payload::BookingRecorded { booking: booking(9) },
914 };
915 let mut forked = set.clone();
916 forked.insert(fork);
917 assert_eq!(forked.verify(), [ChainProblem::Fork { device: device.clone(), seq: 1 }]);
918
919 let late = Event {
921 time: clock.now(1002),
922 seq: 3,
923 prev: None,
924 payload: Payload::BookingRecorded { booking: booking(3) },
925 };
926 let mut gapped = set.clone();
927 gapped.insert(late);
928 assert_eq!(gapped.verify(), [ChainProblem::Gap { device: device.clone(), from: 2, to: 2 }]);
929
930 let wrong = Event {
932 time: clock.now(1003),
933 seq: 2,
934 prev: Some(format!("sha256:{}", "00".repeat(32)).parse().unwrap()),
935 payload: Payload::BookingRecorded { booking: booking(2) },
936 };
937 let hash = set.insert(wrong).0;
938 assert_ne!(Some(first), set.get(&hash).unwrap().prev);
939 assert_eq!(set.verify(), [ChainProblem::BrokenLink { device, event: hash }]);
940 }
941}