doubleentry 0.7.0

An immutable, tamper-evident double-entry bookkeeping engine. Balanced-by-construction entries, exact integer money, deterministic identity, and an append-only Merkle log with inclusion and consistency proofs. Zero I/O. No async. No chart of accounts.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
//! Checkpoints and balance assertions.
//!
//! Both are *claims about balances that the journal can check*, and they exist
//! for opposite reasons.
//!
//! A [`Checkpoint`] is an optimisation. A balance is defined as a fold over the
//! journal, which is correct and linear in history; a checkpoint records the
//! fold up to a point so later reads start from there. Because that trades a
//! definition for a cache, a checkpoint carries the tree head it was taken
//! against, which does double duty: it *names* the prefix the balance covers and
//! *pins* the history that prefix belongs to. So a checkpoint cannot be quietly
//! reused against a log that has changed, and the journal can always re-derive
//! it.
//!
//! A [`BalanceAssertion`] is the opposite: a claim from *outside* the ledger — a
//! bank statement, a counterparty confirmation, an ERP export — checked against
//! the fold. It catches the divergence that reconciliation exists to find, and
//! it is the cheapest such mechanism there is.

use time::Date;

use crate::balance::{Balance, BalanceKey, BalanceQuery, DateBasis};
use crate::merkle::TreeHead;
use crate::money::{Amount, MoneyError};
use crate::posting::Direction;

/// A recorded balance over a known prefix of the log.
///
/// The prefix is the tree head's [`size`](TreeHead::size) — there is no separate
/// position field, because two fields that must agree are two fields that can
/// disagree. A checkpoint says: *after these `size` entries, whose Merkle root
/// was this, the account held this balance.*
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct Checkpoint<const P: u8> {
    /// What the balance is for.
    pub key: BalanceKey,
    /// The balance after the first [`Self::size`] entries.
    pub balance: Balance<P>,
    /// The tree head the checkpoint was taken against.
    ///
    /// Pins the checkpoint to one specific history *and* names the prefix it
    /// covers. A checkpoint whose head no longer matches the log it is being
    /// used with is stale by construction, not by convention.
    pub tree_head: TreeHead,
}

impl<const P: u8> Checkpoint<P> {
    /// Creates a checkpoint over the prefix `tree_head` commits to.
    #[must_use]
    pub fn new(key: BalanceKey, balance: Balance<P>, tree_head: TreeHead) -> Self {
        Self {
            key,
            balance,
            tree_head,
        }
    }

    /// Number of entries folded into the balance.
    ///
    /// Zero for a checkpoint taken over an empty log, which is a perfectly
    /// ordinary checkpoint: it says the account had not moved yet, and it stays
    /// true however far the log grows afterwards.
    #[must_use]
    pub const fn size(&self) -> u64 {
        self.tree_head.size
    }

    /// True when this checkpoint was taken against `head`.
    #[must_use]
    pub fn matches_head(&self, head: &TreeHead) -> bool {
        self.tree_head == *head
    }
}

/// Why a checkpoint failed verification.
#[derive(Debug, Clone, Copy, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum CheckpointError {
    /// The recorded balance disagrees with a fold over the journal.
    #[error("checkpoint balance does not match the journal")]
    BalanceMismatch,
    /// The checkpoint covers more entries than the journal holds.
    #[error("checkpoint covers {size} entries, beyond the journal")]
    SizeOutOfRange {
        /// The prefix size claimed.
        size: u64,
    },
    /// The checkpoint was taken against a different history.
    #[error("checkpoint tree head does not match the journal at that size")]
    HeadMismatch,
    /// Folding the journal overflowed.
    #[error(transparent)]
    Money(#[from] MoneyError),
}

/// Which state of the books an assertion is evaluated against.
///
/// These are genuinely different questions, and picking the wrong one is the
/// classic reconciliation mistake. A log position is exact and reproducible but
/// only meaningful inside this system. A date is what every external source
/// speaks — a bank statement says "as at 31 March", not "after 4,812 entries" —
/// and it folds by date regardless of when an entry was *recorded*, so a
/// backdated entry lands where it economically belongs.
///
/// Which date is the second half of the same question. The books are kept on
/// booking dates; a bank is not. See [`DateBasis`], and
/// [`BalanceAssertion::on_value_date`] for the form to check an external
/// statement with.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub enum AssertAt {
    /// Everything recorded so far.
    Now,
    /// The first `size` entries in log order.
    ///
    /// Zero asserts against an empty ledger.
    Prefix {
        /// Number of entries folded in.
        size: u64,
    },
    /// Every entry dated on or before `date`, whenever it was recorded.
    OnDate {
        /// The last date folded in, inclusive.
        date: Date,
        /// Which of the entry's two dates the bound applies to.
        ///
        /// A bank statement is value-dated and a trial balance is booking-dated,
        /// and reconciling one against the other is the mistake this field
        /// exists to make impossible to write by accident. See [`DateBasis`].
        basis: DateBasis,
    },
}

impl AssertAt {
    /// The [`BalanceQuery`] this evaluates against.
    ///
    /// `AssertAt` is the serialisable form — an assertion travels, and a
    /// borrowed dimension filter cannot — so this is the bridge to the query
    /// every balance read actually takes.
    #[must_use]
    pub fn query(self) -> BalanceQuery<'static> {
        match self {
            Self::Now => BalanceQuery::all(),
            Self::Prefix { size } => BalanceQuery::over_prefix(size),
            Self::OnDate { date, basis } => match basis {
                DateBasis::Booking => BalanceQuery::through(date),
                DateBasis::Value => BalanceQuery::through(date).by_value_date(),
            },
        }
    }
}

/// A claim that an account holds a particular balance at a point in the books.
///
/// The expected value is a **signed net**, debit positive, because that is the
/// form an external source reports: a statement says what the balance is, not
/// how much moved in each direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
pub struct BalanceAssertion<const P: u8> {
    /// What is being asserted about.
    pub key: BalanceKey,
    /// Which state of the books to evaluate against.
    pub at: AssertAt,
    /// The expected signed net, debit positive.
    pub expected: Amount<P>,
}

impl<const P: u8> BalanceAssertion<P> {
    /// Asserts a signed net, debit positive, against the current state.
    #[must_use]
    pub fn net(key: BalanceKey, expected: Amount<P>) -> Self {
        Self {
            key,
            at: AssertAt::Now,
            expected,
        }
    }

    /// Asserts a magnitude on a given side.
    pub fn on_side(
        key: BalanceKey,
        direction: Direction,
        magnitude: Amount<P>,
    ) -> Result<Self, MoneyError> {
        let expected = match direction {
            Direction::Debit => magnitude,
            Direction::Credit => magnitude.checked_neg()?,
        };
        Ok(Self::net(key, expected))
    }

    /// Evaluates the assertion over the first `size` entries.
    #[must_use]
    pub fn over_prefix(mut self, size: u64) -> Self {
        self.at = AssertAt::Prefix { size };
        self
    }

    /// Evaluates the assertion over everything **booked** on or before `date`.
    ///
    /// The form to reconcile against a figure taken from the books — a trial
    /// balance, a period close, a prior-year comparative.
    #[must_use]
    pub fn on_date(mut self, date: Date) -> Self {
        self.at = AssertAt::OnDate {
            date,
            basis: DateBasis::Booking,
        };
        self
    }

    /// Evaluates the assertion over everything **valued** on or before `date`.
    ///
    /// The form to reconcile against a figure taken from outside — a bank
    /// statement, a custodian position, a counterparty confirmation. Those are
    /// value-dated, and checking one against a booking-dated fold is the classic
    /// reconciliation mistake: the two differ by the entries booked in one month
    /// and settling in the next.
    #[must_use]
    pub fn on_value_date(mut self, date: Date) -> Self {
        self.at = AssertAt::OnDate {
            date,
            basis: DateBasis::Value,
        };
        self
    }

    /// Compares the assertion against an actual balance.
    pub fn check(&self, actual: &Balance<P>) -> Result<AssertionOutcome<P>, MoneyError> {
        let actual_net = actual.signed_net()?;
        if actual_net == self.expected {
            Ok(AssertionOutcome::Held { actual: *actual })
        } else {
            Ok(AssertionOutcome::Failed {
                expected: self.expected,
                actual: actual_net,
                difference: actual_net.checked_sub(self.expected)?,
            })
        }
    }
}

/// The result of checking a [`BalanceAssertion`].
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum AssertionOutcome<const P: u8> {
    /// The ledger agrees with the claim.
    Held {
        /// The balance found, with both gross totals.
        actual: Balance<P>,
    },
    /// The ledger disagrees.
    Failed {
        /// What was claimed, as a signed net.
        expected: Amount<P>,
        /// What the ledger holds, as a signed net.
        actual: Amount<P>,
        /// `actual - expected`; the amount unaccounted for.
        difference: Amount<P>,
    },
}

impl<const P: u8> AssertionOutcome<P> {
    /// True when the ledger agreed.
    #[must_use]
    pub const fn held(&self) -> bool {
        matches!(self, Self::Held { .. })
    }
}

impl<const P: u8> std::fmt::Display for AssertionOutcome<P> {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            Self::Held { actual } => {
                write!(
                    f,
                    "held (debits {}, credits {})",
                    actual.debits, actual.credits
                )
            }
            Self::Failed {
                expected,
                actual,
                difference,
            } => write!(
                f,
                "failed: expected {expected}, found {actual}, off by {difference}"
            ),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::account::AccountId;
    use crate::hash::Hash;
    use crate::money::Currency;
    use crate::posting::Layer;

    type Eur = Amount<2>;

    fn key() -> BalanceKey {
        BalanceKey {
            account: AccountId::from_index(0),
            currency: Currency::EUR,
            layer: Layer::Settled,
        }
    }

    fn balance(debits: i64, credits: i64) -> Balance<2> {
        let mut b = Balance::<2>::ZERO;
        b.add(Direction::Debit, Eur::from_minor(debits))
            .expect("ok");
        b.add(Direction::Credit, Eur::from_minor(credits))
            .expect("ok");
        b
    }

    fn head(size: u64, byte: u8) -> TreeHead {
        TreeHead {
            size,
            root: Hash::from_bytes([byte; 32]),
        }
    }

    #[test]
    fn an_assertion_that_matches_holds() {
        let a = BalanceAssertion::net(key(), Eur::from_minor(100));
        let outcome = a.check(&balance(100, 0)).expect("ok");
        assert!(outcome.held());
    }

    #[test]
    fn an_assertion_reports_the_difference_when_it_fails() {
        let a = BalanceAssertion::net(key(), Eur::from_minor(100));
        let outcome = a.check(&balance(130, 0)).expect("ok");
        assert_eq!(
            outcome,
            AssertionOutcome::Failed {
                expected: Eur::from_minor(100),
                actual: Eur::from_minor(130),
                difference: Eur::from_minor(30),
            }
        );
        assert!(!outcome.held());
    }

    #[test]
    fn an_assertion_matches_the_net_not_the_gross() {
        // 500 in each direction nets to zero, which is what a statement reports.
        let a = BalanceAssertion::net(key(), Eur::ZERO);
        assert!(a.check(&balance(500, 500)).expect("ok").held());
    }

    #[test]
    fn a_credit_side_assertion_is_a_negative_net() {
        let a =
            BalanceAssertion::on_side(key(), Direction::Credit, Eur::from_minor(250)).expect("ok");
        assert_eq!(a.expected, Eur::from_minor(-250));
        assert!(a.check(&balance(0, 250)).expect("ok").held());
    }

    #[test]
    fn a_debit_side_assertion_is_a_positive_net() {
        let a =
            BalanceAssertion::on_side(key(), Direction::Debit, Eur::from_minor(250)).expect("ok");
        assert_eq!(a.expected, Eur::from_minor(250));
        assert!(a.check(&balance(250, 0)).expect("ok").held());
    }

    #[test]
    fn a_checkpoint_knows_which_history_it_belongs_to() {
        let cp = Checkpoint::new(key(), balance(100, 0), head(10, 1));
        assert_eq!(
            cp.size(),
            10,
            "the head names the prefix; nothing else does"
        );
        assert!(cp.matches_head(&head(10, 1)));
        // Same size, different contents: a rewritten history.
        assert!(!cp.matches_head(&head(10, 2)));
        // Same contents, different size: the log has grown.
        assert!(!cp.matches_head(&head(11, 1)));
    }

    #[test]
    fn an_assertion_carries_the_point_it_is_about() {
        use time::macros::date;
        let a = BalanceAssertion::net(key(), Eur::from_minor(100));
        assert_eq!(a.at, AssertAt::Now);
        assert_eq!(a.over_prefix(0).at, AssertAt::Prefix { size: 0 });
        assert_eq!(
            a.on_date(date!(2026 - 03 - 31)).at,
            AssertAt::OnDate {
                date: date!(2026 - 03 - 31),
                basis: DateBasis::Booking,
            }
        );
        assert_eq!(
            a.on_value_date(date!(2026 - 03 - 31)).at,
            AssertAt::OnDate {
                date: date!(2026 - 03 - 31),
                basis: DateBasis::Value,
            }
        );
    }

    #[test]
    fn assertion_display_is_operator_readable() {
        let a = BalanceAssertion::net(key(), Eur::from_minor(100));
        let failed = a.check(&balance(130, 0)).expect("ok");
        assert_eq!(
            failed.to_string(),
            "failed: expected 1.00, found 1.30, off by 0.30"
        );
    }
}