Skip to main content

librotenado/
event.rs

1// SPDX-License-Identifier: MIT
2//! Events: the immutable facts a set of books consists of, chained per device and merged by union.
3
4use 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/// The SHA-256 hash of an event's canonical encoding – its identity. Written as `sha256:` and 64 lower-case
20/// hexadecimal digits, like every hash in the protocol.
21#[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/// What happened – the event's `kind` and the members that come with it, written at the level of the event.
51#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum Payload {
54    /// An organisation was described, or its description changed (spec part 0008).
55    OrganisationDescribed {
56        /// The organisation; a later description with the same identity replaces an earlier one.
57        organisation: Organisation,
58    },
59    /// A person was described, or their description changed (spec part 0008).
60    PersonDescribed {
61        /// The person; a later description with the same identity replaces an earlier one.
62        person: Person,
63    },
64    /// The books belong to this organisation or person, too.
65    OwnerAdded {
66        /// The organisation or person.
67        owner: PartyId,
68    },
69    /// The books no longer belong to this organisation or person.
70    OwnerRemoved {
71        /// The organisation or person.
72        owner: PartyId,
73    },
74    /// The books use this harmonised chart of accounts (spec part 0004).
75    ChartSelected {
76        /// The chart, as `name/version`, e.g. `librotenado/0.1`.
77        chart: String,
78    },
79    /// An account of the books' own was opened, refining an account of the chart or another opened account.
80    AccountOpened {
81        /// The account; its parent is required, its kind is the parent's.
82        account: Account,
83    },
84    /// A booking was recorded.
85    BookingRecorded {
86        /// The booking.
87        booking: Booking,
88    },
89    /// An earlier booking was cancelled by a counter-booking; nothing is ever changed or deleted.
90    BookingReversed {
91        /// The event that recorded the booking.
92        reverses: EventHash,
93        /// The counter-booking, with the opposite amounts.
94        booking: Booking,
95    },
96    /// A document was recorded, with its data and its original; the original's bytes are stored by their hash,
97    /// outside the events.
98    DocumentRecorded {
99        /// The document.
100        document: Document,
101    },
102    /// A kind of a later version of the protocol. The event is kept and synchronised as received, but not
103    /// interpreted; it cannot be written by this version.
104    #[serde(other, skip_serializing)]
105    Unknown,
106}
107
108/// One event: who wrote it when, its place in the device's chain, and what happened. An event is always a root
109/// object: `{"type": "event", "kind": …, "time": …, "seq": …, "prev": …, …}`.
110#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
111pub struct Event {
112    /// The timestamp; its device is the device that wrote the event.
113    pub time: Hlc,
114    /// Position in the device's chain, starting at 1.
115    #[serde(deserialize_with = "crate::safe_u64")]
116    pub seq: u64,
117    /// The hash of the device's previous event; none for `seq` 1.
118    #[serde(deserialize_with = "written")]
119    pub prev: Option<EventHash>,
120    /// What happened: `kind` and its members.
121    #[serde(flatten)]
122    pub payload: Payload,
123}
124
125impl Object for Event {
126    const TYPE: &'static str = "event";
127}
128
129impl Event {
130    /// The device that wrote the event.
131    pub fn device(&self) -> &DeviceId {
132        &self.time.device
133    }
134
135    /// The identity of an event written on this device: SHA-256 over its canonical encoding (spec part 0003).
136    ///
137    /// A received event is identified by what was received, which may hold members this version does not know –
138    /// use the hash [`EventSet::receive`] returns. An event built in code must keep its integers at or below
139    /// 2^53 − 1.
140    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
146/// Deserialises `prev`, which is always written – `null` for the first event of a device, never left out.
147fn written<'de, D: Deserializer<'de>>(deserializer: D) -> Result<Option<EventHash>, D::Error> {
148    Option::deserialize(deserializer)
149}
150
151/// Something about the accounts of a set of books that needs review (spec part 0002): never resolved silently.
152#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
153pub enum AccountProblem {
154    /// A posting on an account that is neither in the chart nor opened – perhaps not arrived yet.
155    #[error("event {event} posts on account {account}, which is neither in the chart nor opened")]
156    UnknownAccount {
157        /// The event with the booking.
158        event: EventHash,
159        /// The account.
160        account: AccountId,
161    },
162    /// An opened account whose parent is missing, or neither in the chart nor opened.
163    #[error("account {account}, opened in event {event}, refines no known account")]
164    UnknownParent {
165        /// The event that opened the account.
166        event: EventHash,
167        /// The account.
168        account: AccountId,
169    },
170    /// An opened account of another kind than its parent.
171    #[error("account {account}, opened in event {event}, is of another kind than its parent")]
172    KindMismatch {
173        /// The event that opened the account.
174        event: EventHash,
175        /// The account.
176        account: AccountId,
177    },
178    /// An account opened again, or opened with the identity of an account of the chart.
179    #[error("account {account} is opened again in event {event}")]
180    Duplicate {
181        /// The later event.
182        event: EventHash,
183        /// The account.
184        account: AccountId,
185    },
186}
187
188/// Something about the parties of a set of books that needs review: never resolved silently.
189#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
190pub enum PartyProblem {
191    /// The books belong to no organisation and no person.
192    #[error("the books belong to no organisation and no person")]
193    NoOwner,
194    /// An owner that is not described – perhaps not arrived yet.
195    #[error("event {event} names owner {owner}, who is not described")]
196    UnknownOwner {
197        /// The event that added the owner.
198        event: EventHash,
199        /// The owner.
200        owner: PartyId,
201    },
202    /// A person's role in an organisation that is not described.
203    #[error("person {person} has a role in {organisation}, which is not described")]
204    UnknownOrganisation {
205        /// The person.
206        person: PartyId,
207        /// The organisation.
208        organisation: PartyId,
209    },
210    /// Shares held of a nominal amount the organisation does not have, or more of it than it has.
211    #[error("the shares held in {organisation} do not fit its shares of {nominal}")]
212    Shares {
213        /// The organisation.
214        organisation: PartyId,
215        /// The nominal amount.
216        nominal: Money,
217    },
218    /// A posting in the books of someone who is not an owner of the books.
219    #[error("event {event} posts in the books of {owner}, who is not an owner")]
220    NotAnOwner {
221        /// The event with the booking.
222        event: EventHash,
223        /// The owner named in the posting.
224        owner: PartyId,
225    },
226}
227
228/// Something about the documents of a set of books that needs review: never resolved silently.
229#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
230pub enum DocumentProblem {
231    /// A booking based on a document that has not been recorded – perhaps not arrived yet.
232    #[error("event {event} is based on document {document}, which has not been recorded")]
233    UnknownDocument {
234        /// The event with the booking.
235        event: EventHash,
236        /// The document.
237        document: DocumentId,
238    },
239    /// A document recorded again under the same identity.
240    #[error("document {document} is recorded again in event {event}")]
241    Duplicate {
242        /// The later event.
243        event: EventHash,
244        /// The document.
245        document: DocumentId,
246    },
247}
248
249/// Something in a set of events that does not fit a clean chain per device.
250#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
251pub enum ChainProblem {
252    /// Two different events of one device with the same position – the device's history was forked.
253    #[error("device {device} has two events at position {seq}")]
254    Fork {
255        /// The device.
256        device: DeviceId,
257        /// The position.
258        seq: u64,
259    },
260    /// Positions of a device are missing – events have not arrived (yet).
261    #[error("device {device} is missing events {from} to {to}")]
262    Gap {
263        /// The device.
264        device: DeviceId,
265        /// First missing position.
266        from: u64,
267        /// Last missing position.
268        to: u64,
269    },
270    /// An event does not point to the device's previous event.
271    #[error("event {event} of device {device} does not follow the device's previous event")]
272    BrokenLink {
273        /// The device.
274        device: DeviceId,
275        /// The event.
276        event: EventHash,
277    },
278}
279
280/// A set of events – the books as they are known on one device. Merging is set union, in any order, any number
281/// of times, with the same result.
282///
283/// Every event is kept in its canonical encoding as well as read: the encoding is what is hashed and what is passed
284/// on, so members a later version adds survive this one unchanged.
285#[derive(Debug, Clone, Default, PartialEq, Eq)]
286pub struct EventSet {
287    events: BTreeMap<EventHash, Stored>,
288}
289
290/// An event as read, and the canonical bytes it was read from.
291#[derive(Debug, Clone, PartialEq, Eq)]
292struct Stored {
293    event: Event,
294    canonical: Arc<[u8]>,
295}
296
297impl EventSet {
298    /// An empty set.
299    pub fn new() -> Self {
300        Self::default()
301    }
302
303    /// Adds an event written on this device; returns its hash and whether it was new.
304    ///
305    /// # Panics
306    ///
307    /// If the event has no canonical encoding: an integer above 2^53 − 1, or a [`Payload::Unknown`] (which can only
308    /// be received).
309    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    /// Adds an event received as JSON, in any formatting; returns its hash and whether it was new. The hash is taken
315    /// over the canonical encoding of what was received, members this version does not know included.
316    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    /// Adds all events of another set; returns how many were new.
334    pub fn merge(&mut self, other: &EventSet) -> usize {
335        other.events.values().filter(|stored| self.add((*stored).clone()).1).count()
336    }
337
338    /// The event with this hash.
339    pub fn get(&self, hash: &EventHash) -> Option<&Event> {
340        self.events.get(hash).map(|stored| &stored.event)
341    }
342
343    /// The canonical encoding of the event with this hash – what is passed on to other devices.
344    pub fn canonical(&self, hash: &EventHash) -> Option<&[u8]> {
345        self.events.get(hash).map(|stored| &*stored.canonical)
346    }
347
348    /// The number of events.
349    pub fn len(&self) -> usize {
350        self.events.len()
351    }
352
353    /// Whether there are no events.
354    pub fn is_empty(&self) -> bool {
355        self.events.is_empty()
356    }
357
358    /// All events in the order every device agrees on: by timestamp, then by hash.
359    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    /// The next position and previous hash for a new event of `device`, if its chain is clean.
366    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    /// The charts the books selected, in the agreed order of events, each once. More than one is for review.
376    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    /// Checks the accounts against `chart` and the accounts opened in the books: every opened account refines a
389    /// known account of the same kind and is opened once; every posting is on a known account.
390    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    /// The organisations and persons as last described, in the agreed order of events.
427    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    /// The owners of the books – organisations and persons – after all additions and removals, in order.
444    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    /// Checks the parties: the books belong to at least one organisation or person, every owner is described,
457    /// every organisation a person has a role in is described, the shares held fit the shares an organisation has,
458    /// and every posting is in the books of an owner.
459    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        // Per organisation and nominal amount (by currency and base units): how many shares persons hold.
476        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    /// The state of the books: the SHA-256 hash of the canonical encoding of the array of all event hashes in the
511    /// agreed order. Whatever is derived from the books – a filing, a report – records the state it was derived from.
512    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    /// Checks the documents: each recorded once, and every document a booking is based on recorded. Whether the
518    /// bytes of the originals are at hand is a question of the store, not of the events.
519    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    /// Checks every device's chain: one event per position, no gaps, each event pointing to its predecessor.
540    pub fn verify(&self) -> Vec<ChainProblem> {
541        // Per device, per position: the events found there (more than one is a fork).
542        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        // The same event, written with its members in reverse order and with whitespace.
623        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    /// The canonical encoding of the one event in `set` as a JSON value, to be changed by a test.
638    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    /// A chart with 1 (asset) and 2 (expense), the accounts of `booking`.
702    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    /// Appends an event of `payload` to device a's chain.
806    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        // Same position twice: a fork.
909        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        // Position 3 without 2: a gap.
920        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        // Position 2 pointing elsewhere: a broken link.
931        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}