journal.gno
34.46 Kb · 983 lines
1// Package journal is a pure double-entry accounting primitive for realms
2// that need books which provably balance. It is to *bookkeeping* what
3// feeledger is to *money* and duebook is to *time*: it holds no coins,
4// performs no effects, imports no chain APIs, and owns no package-level
5// state. The importing realm owns a *Book, supplies the clock, and performs
6// its own effects.
7//
8// # The one idea
9//
10// Every financial primitive in the searched Gno ecosystem is ONE-SIDED. A
11// treasury records what moved. A pull-payment ledger records who is owed
12// what. A GRC20 ledger records who holds what. Each stores a balance and
13// mutates it.
14//
15// This package stores no balance. It stores ENTRIES, and derives balances
16// from them:
17//
18// book.MustOpen("1000", "Cash", Asset)
19// book.MustOpen("4000", "Revenue", Revenue)
20// book.MustPost(now, "invoice 17 paid", []Posting{
21// {Account: "1000", Side: Debit, Amount: 5000},
22// {Account: "4000", Side: Credit, Amount: 5000},
23// })
24//
25// An entry that does not balance is refused, and the refusal says which side
26// is short. So `sum(debits) == sum(credits)` is not a property the consumer
27// must remember to maintain — it is the precondition for the state changing
28// at all. A Book cannot be put into an unbalanced state by any sequence of
29// calls to this package.
30//
31// # No amend, no void
32//
33// Post is the only way in, and there is no Edit and no Delete. A mistake is
34// corrected the way real books correct one: with a REVERSING ENTRY that
35// mirrors every posting of the original, leaving both visible.
36//
37// book.MustReverse(entryID, now, "wrong account")
38//
39// This is why history is trustworthy here and would not be if entries were
40// mutable. An auditor reading the journal sees the error AND the correction,
41// which is the entire point of keeping a journal rather than a balance.
42// Reverse refuses a second reversal of the same entry, so a correction
43// cannot be looped.
44//
45// # Periods
46//
47// Close(through) seals everything dated at or before `through`. It only ever
48// moves forward, and after it Post refuses any entry dated into the sealed
49// range — the backdating that makes a closed period meaningless. Reverse is
50// refused there too: correcting a sealed period means opening a new entry in
51// the current one, which is the consumer's call to make explicitly.
52//
53// # What "balanced" is actually worth
54//
55// Read this before quoting IsBalanced as a guarantee.
56//
57// IsBalanced compares TWO INDEPENDENTLY MAINTAINED ACCUMULATORS: the
58// per-account debit/credit totals, summed across the chart, against the
59// Book's own running totals. They are written by different code paths, so
60// a drift between them is a real defect and IsBalanced finds it. What it
61// does NOT do is re-read the journal, so a defect that corrupted both
62// accumulators identically would survive it.
63//
64// RederiveFromJournal is the strong check: it replays every posting of every
65// entry and compares the result against both accumulators. It costs O(all
66// postings ever) and exists to be run deliberately, not on every read.
67//
68// AccountingEquation is WEAKER THAN IT LOOKS and is documented as such
69// rather than left for a reader to assume otherwise: under this package's
70// sign conventions it is implied by IsBalanced, and its boolean proves
71// EXACTLY ONE of IsBalanced's three conjuncts. It is NOT an independent
72// accounting fact. Three checks that are really two, presented as three,
73// would be the defect this package exists to avoid.
74//
75// This doc said "it is a cross-check of this package's own normal-balance
76// handling" until 2026-10-07, and that sentence was FALSE — the one place
77// the careful disclaimer around it still overclaimed. The boolean is the
78// sum of every account's RawDelta, bucketed by Kind and then added back
79// together, so the bucketing cannot change it: offsets is INVARIANT under
80// every possible Kind misclassification, which is the one thing
81// "normal-balance handling" means. See obligation 7 below, and
82// AccountingEquation's own doc for what it does prove.
83//
84// And the ceiling on all three: a Book can be internally perfect and
85// describe nothing. See obligation 1 below.
86//
87// # State growth
88//
89// Entries are never removed, because an append-only journal whose entries
90// could vanish is not a journal. Storage therefore grows with every Post and
91// is bounded only by maxEntries, set at construction. A Book that reaches
92// that cap refuses further entries rather than silently dropping history —
93// a long-lived consumer must plan for this, and the plan is a new Book with
94// opening balances carried forward, not a pruned one.
95//
96// # Consumer contract (the parts the package cannot enforce)
97//
98// 1. THE BOOKS ARE CLAIMS BY WHOEVER POSTED THEM. This package proves
99// arithmetic, never truth. With no custody there is no solvency
100// invariant tying "Cash 5000" to 5000 real ugnot anywhere. A realm that
101// presents a journal balance as a holding is asserting something this
102// package did not check. This is the single most important obligation
103// and the one most commonly got wrong.
104// 2. SUPPLY A REAL CLOCK. journal cannot verify that `at` came from
105// runtime.ChainHeight() or time.Now().Unix(). A realm that lets a caller
106// choose `at` has no period discipline at all: the caller dates entries
107// wherever they are convenient. Pass the chain's clock, never a
108// transaction parameter.
109// 3. USE ONE CLOCK CONSISTENTLY. Heights and seconds must not be mixed
110// within a Book; `at` and `through` are both in the caller's unit.
111// 4. DO NOT EXPORT THE BOOK. A *Book is a mutable handle. Returning one
112// across a realm boundary hands out the right to open accounts, post and
113// reverse. Expose your own crossing functions instead; this package
114// returns Account and Entry VALUES, never pointers into its state.
115// 5. AUTHORIZE THE POSTER. journal authenticates nobody. Who may open an
116// account, who may post, and who may close a period are the realm's
117// policy — derive the caller from cur.Previous().Address(), not from an
118// argument.
119// 6. CHOOSE THE UNIT ONCE. Amounts are int64 in whatever minor unit the
120// consumer picks. journal does not know about denominations and will
121// happily balance an entry that mixes two of them.
122// 7. GET THE KIND RIGHT AT OPEN TIME. NO CHECK IN THIS PACKAGE VALIDATES
123// AN ACCOUNT'S KIND. A Kind is accepted if it is one of the five and is
124// never revisited, so an account opened Asset that should have been
125// Liability reports a sign-flipped Balance() forever — and IsBalanced,
126// RederiveFromJournal and AccountingEquation all three stay healthy,
127// because every one of them is built on Debits and Credits, which the
128// Kind does not touch. This is the invariant tiers' blind spot, it is
129// structural rather than an omission to be fixed later, and a realm
130// that lets a caller choose the Kind has delegated a decision no
131// later check can audit.
132//
133// All failures are returned as errors and leave the Book COMPLETELY
134// UNCHANGED. Must* wrappers are the only functions here that panic.
135//
136// The Book is address-agnostic: account codes are non-empty strings. Realms
137// normally use a chart-of-accounts code, not an address.
138package journal
139
140import (
141 "errors"
142 "strconv"
143
144 "gno.land/p/nt/avl/v0"
145)
146
147// MaxAccountsLimit is the largest maxAccounts a Book may be constructed
148// with. It bounds the cost of IsBalanced and AccountingEquation, which scan
149// the chart of accounts.
150const MaxAccountsLimit = 10000
151
152// MaxEntriesLimit is the largest maxEntries a Book may be constructed with.
153const MaxEntriesLimit = 1000000
154
155// MaxPostingsPerEntry bounds a single entry's posting count. Two is the
156// minimum a balanced entry can have; the ceiling keeps one Post bounded.
157const MaxPostingsPerEntry = 64
158
159// MaxCodeLen bounds an account code.
160const MaxCodeLen = 64
161
162// MaxNameLen bounds an account's human-readable name.
163const MaxNameLen = 256
164
165// MaxMemoLen bounds an entry memo. The memo is opaque to journal — it exists
166// so a realm can record why an entry was made without keeping a parallel
167// table.
168const MaxMemoLen = 1024
169
170// Errors returned by Book operations.
171var (
172 ErrEmptyCode = errors.New("journal: empty account code")
173 ErrCodeTooLong = errors.New("journal: account code exceeds MaxCodeLen")
174 ErrNameTooLong = errors.New("journal: account name exceeds MaxNameLen")
175 ErrMemoTooLong = errors.New("journal: memo exceeds MaxMemoLen")
176 ErrInvalidKind = errors.New("journal: unknown account kind")
177 ErrInvalidSide = errors.New("journal: posting side must be Debit or Credit")
178 ErrInvalidAmount = errors.New("journal: posting amount must be positive")
179 ErrInvalidAt = errors.New("journal: at must be non-negative")
180 ErrInvalidConfig = errors.New("journal: invalid book configuration")
181 ErrAccountExists = errors.New("journal: account code already open")
182 ErrUnknownAccount = errors.New("journal: no such account")
183 ErrChartFull = errors.New("journal: account cap reached")
184 ErrBookFull = errors.New("journal: entry cap reached")
185 ErrTooFewPostings = errors.New("journal: an entry needs at least two postings")
186 ErrTooManyPostings = errors.New("journal: entry exceeds MaxPostingsPerEntry")
187 ErrNoDebit = errors.New("journal: entry has no debit posting")
188 ErrNoCredit = errors.New("journal: entry has no credit posting")
189 ErrDebitShort = errors.New("journal: entry does not balance, debits are short")
190 ErrCreditShort = errors.New("journal: entry does not balance, credits are short")
191 ErrEntryNotFound = errors.New("journal: no such entry")
192 ErrAlreadyReversed = errors.New("journal: entry has already been reversed")
193 ErrPeriodClosed = errors.New("journal: at falls in a closed period")
194 ErrCloseNotForward = errors.New("journal: close must move forward")
195 ErrOverflow = errors.New("journal: int64 overflow")
196 ErrIDExhausted = errors.New("journal: identifier space exhausted")
197)
198
199// Kind is an account's classification, which fixes its normal balance.
200// The zero value is deliberately invalid so a forgotten field is an error
201// rather than an Asset.
202type Kind uint8
203
204// The five account kinds. Asset and Expense are debit-normal; Liability,
205// Equity and Revenue are credit-normal.
206const (
207 Asset Kind = 1
208 Liability Kind = 2
209 Equity Kind = 3
210 Revenue Kind = 4
211 Expense Kind = 5
212)
213
214// Valid reports whether k is one of the five kinds.
215func (k Kind) Valid() bool { return k >= Asset && k <= Expense }
216
217// IsDebitNormal reports whether an increase in k is recorded as a debit.
218// Asset and Expense are debit-normal; the other three are credit-normal.
219// An invalid Kind reports false, which is why callers validate first.
220func (k Kind) IsDebitNormal() bool { return k == Asset || k == Expense }
221
222// String returns the kind's name, or "invalid" for the zero value and any
223// unrecognised code.
224func (k Kind) String() string {
225 switch k {
226 case Asset:
227 return "asset"
228 case Liability:
229 return "liability"
230 case Equity:
231 return "equity"
232 case Revenue:
233 return "revenue"
234 case Expense:
235 return "expense"
236 }
237 return "invalid"
238}
239
240// Side is which column a posting lands in. The zero value is deliberately
241// invalid: a posting with a forgotten Side must be refused, not silently
242// treated as a debit.
243type Side uint8
244
245// The two posting sides.
246const (
247 Debit Side = 1
248 Credit Side = 2
249)
250
251// Valid reports whether s is Debit or Credit.
252func (s Side) Valid() bool { return s == Debit || s == Credit }
253
254// String returns "debit", "credit", or "invalid".
255func (s Side) String() string {
256 switch s {
257 case Debit:
258 return "debit"
259 case Credit:
260 return "credit"
261 }
262 return "invalid"
263}
264
265// Opposite returns the other side. An invalid Side returns itself, so a
266// caller that skipped validation gets an invalid posting rather than a
267// plausible one.
268func (s Side) Opposite() Side {
269 switch s {
270 case Debit:
271 return Credit
272 case Credit:
273 return Debit
274 }
275 return s
276}
277
278// Posting is one leg of an entry: an amount in one column of one account.
279// Amount is always POSITIVE — direction is carried by Side, not by a sign.
280//
281// That is a deliberate choice over signed amounts. With a sign, "which side
282// is short" is unanswerable without knowing each account's normal balance,
283// so an unbalanced entry can only be reported as a number that does not add
284// up. With an explicit Side the refusal names the short column, which is
285// what a bookkeeper needs in order to fix it.
286type Posting struct {
287 Account string
288 Side Side
289 Amount int64
290}
291
292// Account is one line of the chart of accounts, carrying its cumulative
293// debit and credit totals. It is returned BY VALUE: holders cannot reach
294// into a Book through it.
295type Account struct {
296 Code string
297 Name string
298 Kind Kind
299 Debits int64
300 Credits int64
301}
302
303// Balance returns the account's balance in its NORMAL direction: for a
304// debit-normal account Debits-Credits, for a credit-normal account
305// Credits-Debits. A normal account therefore has a non-negative balance,
306// and a negative result is a real signal (a contra position) rather than a
307// sign convention.
308func (a Account) Balance() int64 {
309 if a.Kind.IsDebitNormal() {
310 return a.Debits - a.Credits
311 }
312 return a.Credits - a.Debits
313}
314
315// RawDelta returns Debits-Credits regardless of kind. Summed over every
316// account in a balanced book it is exactly zero, which is what makes it the
317// useful quantity for a consistency check and the wrong one for a report.
318func (a Account) RawDelta() int64 { return a.Debits - a.Credits }
319
320// Entry is one journal entry: a dated, memo'd, balanced set of postings.
321// It is returned BY VALUE, and its Postings slice is a DEFENSIVE COPY — a
322// holder can mutate the returned slice without touching the Book. Entry is
323// the one type here that contains a slice, so it is the one type where
324// returning the stored value directly would have handed out a writable
325// window into Book state.
326//
327// ReversalOf is 0 unless this entry was produced by Reverse, in which case
328// it is the ID of the entry it mirrors.
329type Entry struct {
330 ID uint64
331 At int64
332 Memo string
333 Postings []Posting
334 ReversalOf uint64
335}
336
337// IsReversal reports whether this entry was produced by Reverse.
338func (e Entry) IsReversal() bool { return e.ReversalOf != 0 }
339
340// Total returns the entry's debit total, which for any entry a Book accepted
341// equals its credit total.
342func (e Entry) Total() int64 {
343 var t int64
344 for _, p := range e.Postings {
345 if p.Side == Debit {
346 t += p.Amount
347 }
348 }
349 return t
350}
351
352// Book is the chart of accounts and the journal of one consuming realm. The
353// zero value is not usable; construct with New.
354type Book struct {
355 maxAccounts int
356 maxEntries int
357
358 accounts *avl.Tree // code -> Account
359 entries *avl.Tree // padded id -> Entry
360 // reversed stores `true`, not a struct{} sentinel, and deliberately:
361 // avl.Tree.Get cannot tell a nil-valued key from an absent one, so a
362 // nil sentinel invites a future reader to switch from Has to Get and
363 // get a silently wrong answer. Membership is the whole value here —
364 // both readers use Has and the stored value is never read.
365 reversed *avl.Tree // padded id -> true, for entries already reversed
366
367 nextID uint64 // never decreases; entry IDs are never reused
368 closedThrough int64 // entries dated at or before this are refused
369 hasClosed bool // distinguishes "closed through 0" from "never closed"
370
371 totalDebits int64 // running total, maintained independently of accounts
372 totalCredits int64
373}
374
375// New returns an empty Book.
376//
377// maxAccounts caps the chart and must be in [1, MaxAccountsLimit].
378// maxEntries caps the journal and must be in [1, MaxEntriesLimit]; because
379// entries are never removed, it is a lifetime cap rather than a concurrent
380// one, and a Book that reaches it is finished.
381func New(maxAccounts, maxEntries int) (*Book, error) {
382 if maxAccounts < 1 || maxAccounts > MaxAccountsLimit {
383 return nil, ErrInvalidConfig
384 }
385 if maxEntries < 1 || maxEntries > MaxEntriesLimit {
386 return nil, ErrInvalidConfig
387 }
388 return &Book{
389 maxAccounts: maxAccounts,
390 maxEntries: maxEntries,
391 accounts: avl.NewTree(),
392 entries: avl.NewTree(),
393 reversed: avl.NewTree(),
394 nextID: 1,
395 }, nil
396}
397
398// MustNew is New but panics on error.
399func MustNew(maxAccounts, maxEntries int) *Book {
400 b, err := New(maxAccounts, maxEntries)
401 if err != nil {
402 panic(err)
403 }
404 return b
405}
406
407// MaxAccounts returns the Book's cap on chart size.
408func (b *Book) MaxAccounts() int { return b.maxAccounts }
409
410// MaxEntries returns the Book's lifetime cap on entries.
411func (b *Book) MaxEntries() int { return b.maxEntries }
412
413// AccountCount returns how many accounts are open.
414func (b *Book) AccountCount() int { return b.accounts.Size() }
415
416// EntryCount returns how many entries the journal holds. It never decreases.
417func (b *Book) EntryCount() int { return b.entries.Size() }
418
419// NextID returns the identifier the next entry will take. It only ever
420// increases, so an entry ID identifies one entry for the life of the Book.
421func (b *Book) NextID() uint64 { return b.nextID }
422
423// TotalDebits returns the Book's running debit total across every entry.
424func (b *Book) TotalDebits() int64 { return b.totalDebits }
425
426// TotalCredits returns the Book's running credit total across every entry.
427func (b *Book) TotalCredits() int64 { return b.totalCredits }
428
429// ClosedThrough returns the period seal: entries dated at or before it are
430// refused. The second result is false if the Book has never been closed, in
431// which case the first is meaningless — a Book closed through 0 and a Book
432// never closed differ, and collapsing them would silently reject entries
433// dated at 0.
434func (b *Book) ClosedThrough() (int64, bool) { return b.closedThrough, b.hasClosed }
435
436// Open adds an account to the chart. Codes are unique and immutable: there
437// is no Rename and no Close, because an account's cumulative totals are part
438// of the derivation of every balance that cites it, and removing one would
439// make the journal unreplayable.
440//
441// Fails with ErrEmptyCode, ErrCodeTooLong, ErrNameTooLong, ErrInvalidKind,
442// ErrAccountExists, or ErrChartFull. On error nothing is modified.
443func (b *Book) Open(code, name string, kind Kind) error {
444 if code == "" {
445 return ErrEmptyCode
446 }
447 if len(code) > MaxCodeLen {
448 return ErrCodeTooLong
449 }
450 if len(name) > MaxNameLen {
451 return ErrNameTooLong
452 }
453 if !kind.Valid() {
454 return ErrInvalidKind
455 }
456 if b.accounts.Has(code) {
457 return ErrAccountExists
458 }
459 if b.accounts.Size() >= b.maxAccounts {
460 return ErrChartFull
461 }
462 b.accounts.Set(code, Account{Code: code, Name: name, Kind: kind})
463 return nil
464}
465
466// MustOpen is Open but panics on error.
467func (b *Book) MustOpen(code, name string, kind Kind) {
468 if err := b.Open(code, name, kind); err != nil {
469 panic(err)
470 }
471}
472
473// Account returns an account by code. The second result is false if no such
474// account is open.
475func (b *Book) Account(code string) (Account, bool) { return b.account(code) }
476
477// Balance returns an account's balance in its normal direction, and whether
478// the account exists. A missing account returns (0, false) rather than a
479// zero balance, because "no such account" and "balance zero" are different
480// answers and a consumer branching on the number alone would conflate them.
481func (b *Book) Balance(code string) (int64, bool) {
482 a, ok := b.account(code)
483 if !ok {
484 return 0, false
485 }
486 return a.Balance(), true
487}
488
489// Post appends a balanced entry and returns its ID.
490//
491// The postings must number at least two and at most MaxPostingsPerEntry,
492// each naming an open account, a valid Side, and a positive Amount. The
493// entry must contain at least one debit and at least one credit, and the two
494// column totals must be equal — an inequality is reported as ErrDebitShort
495// or ErrCreditShort, naming the column that needs more.
496//
497// Fails with ErrInvalidAt, ErrMemoTooLong, ErrPeriodClosed, ErrBookFull,
498// ErrIDExhausted, ErrTooFewPostings, ErrTooManyPostings, ErrInvalidSide,
499// ErrInvalidAmount, ErrUnknownAccount, ErrNoDebit, ErrNoCredit,
500// ErrDebitShort, ErrCreditShort, or ErrOverflow. On error nothing is
501// modified — every posting is validated and every total computed before the
502// first write.
503func (b *Book) Post(at int64, memo string, postings []Posting) (uint64, error) {
504 if at < 0 {
505 return 0, ErrInvalidAt
506 }
507 if len(memo) > MaxMemoLen {
508 return 0, ErrMemoTooLong
509 }
510 if b.hasClosed && at <= b.closedThrough {
511 return 0, ErrPeriodClosed
512 }
513 return b.post(at, memo, 0, postings)
514}
515
516// MustPost is Post but panics on error.
517func (b *Book) MustPost(at int64, memo string, postings []Posting) uint64 {
518 id, err := b.Post(at, memo, postings)
519 if err != nil {
520 panic(err)
521 }
522 return id
523}
524
525// Reverse appends the mirror of an existing entry — every posting repeated
526// on the opposite side, for the same amount — and returns the new entry's
527// ID. The original is left exactly as it was.
528//
529// An entry may be reversed at most once. Reversing a reversal IS permitted
530// (it re-applies the original's effect) and is itself subject to the same
531// once-only rule, so a correction cannot be looped.
532//
533// `at` dates the correction and must be in the open period: a sealed period
534// cannot be corrected in place, by construction. Correcting one means
535// posting into the current period, which is the consumer's decision to make
536// explicitly rather than this package's to make silently.
537//
538// Fails with ErrInvalidAt, ErrMemoTooLong, ErrPeriodClosed,
539// ErrEntryNotFound, ErrAlreadyReversed, ErrBookFull, ErrIDExhausted, or
540// ErrOverflow. On error nothing is modified.
541func (b *Book) Reverse(id uint64, at int64, memo string) (uint64, error) {
542 if at < 0 {
543 return 0, ErrInvalidAt
544 }
545 if len(memo) > MaxMemoLen {
546 return 0, ErrMemoTooLong
547 }
548 if b.hasClosed && at <= b.closedThrough {
549 return 0, ErrPeriodClosed
550 }
551 orig, ok := b.entry(id)
552 if !ok {
553 return 0, ErrEntryNotFound
554 }
555 if b.reversed.Has(idKey(id)) {
556 return 0, ErrAlreadyReversed
557 }
558
559 mirror := make([]Posting, len(orig.Postings))
560 for i, p := range orig.Postings {
561 mirror[i] = Posting{Account: p.Account, Side: p.Side.Opposite(), Amount: p.Amount}
562 }
563
564 newID, err := b.post(at, memo, id, mirror)
565 if err != nil {
566 return 0, err
567 }
568 // Marked only after post succeeded, so a refused reversal does not
569 // consume the original's single reversal.
570 b.reversed.Set(idKey(id), true)
571 return newID, nil
572}
573
574// MustReverse is Reverse but panics on error.
575func (b *Book) MustReverse(id uint64, at int64, memo string) uint64 {
576 newID, err := b.Reverse(id, at, memo)
577 if err != nil {
578 panic(err)
579 }
580 return newID
581}
582
583// IsReversed reports whether an entry has already been reversed. It returns
584// false for an entry that does not exist, so pair it with Entry when the
585// distinction matters.
586func (b *Book) IsReversed(id uint64) bool { return b.reversed.Has(idKey(id)) }
587
588// Close seals the period ending at `through`, after which Post and Reverse
589// refuse any entry dated at or before it.
590//
591// It only moves forward. A first close may name any non-negative instant; a
592// later one must name a strictly greater instant, so a seal can never be
593// loosened and backdating can never be re-enabled.
594//
595// Fails with ErrInvalidAt (through < 0) or ErrCloseNotForward. On error
596// nothing is modified.
597func (b *Book) Close(through int64) error {
598 if through < 0 {
599 return ErrInvalidAt
600 }
601 if b.hasClosed && through <= b.closedThrough {
602 return ErrCloseNotForward
603 }
604 b.closedThrough = through
605 b.hasClosed = true
606 return nil
607}
608
609// MustClose is Close but panics on error.
610func (b *Book) MustClose(through int64) {
611 if err := b.Close(through); err != nil {
612 panic(err)
613 }
614}
615
616// Entry returns an entry by ID, with a defensive copy of its postings. The
617// second result is false if no such entry exists — which, because entries
618// are never removed, means it was never created.
619func (b *Book) Entry(id uint64) (Entry, bool) {
620 e, ok := b.entry(id)
621 if !ok {
622 return Entry{}, false
623 }
624 return e.withCopiedPostings(), true
625}
626
627// IsBalanced reports whether the Book's two independently-maintained
628// accumulators agree with each other and with themselves.
629//
630// It scans the chart, sums every account's debit and credit totals, and
631// requires that the two sums are equal AND that each matches the Book's own
632// running total. Those totals are written by different code in Post, so a
633// disagreement is a defect in this package and IsBalanced is how a consumer
634// detects one.
635//
636// What it does not do is re-read the journal. Read the package doc's
637// "What balanced is actually worth" before quoting it, and use
638// RederiveFromJournal for the strong claim.
639//
640// An empty Book is balanced.
641func (b *Book) IsBalanced() bool {
642 d, c, ok := b.sumAccounts()
643 if !ok {
644 return false
645 }
646 return d == c && d == b.totalDebits && c == b.totalCredits
647}
648
649// RederiveFromJournal replays every posting of every entry and reports the
650// debit and credit totals it finds, together with whether they agree with
651// both the per-account accumulators and the Book's running totals.
652//
653// This is the strong check and the expensive one: its cost is proportional
654// to every posting ever made, so it is meant to be called deliberately — by
655// an auditor, or by a consumer's own invariant view — and not from a hot
656// path. `agrees` false on a Book this package built is a defect in this
657// package.
658func (b *Book) RederiveFromJournal() (debits, credits int64, agrees bool) {
659 var d, c int64
660 okMath := true
661 b.entries.Iterate("", "", func(_ string, value any) bool {
662 e := value.(Entry)
663 for _, p := range e.Postings {
664 var ok bool
665 if p.Side == Debit {
666 d, ok = checkedAdd(d, p.Amount)
667 } else {
668 c, ok = checkedAdd(c, p.Amount)
669 }
670 if !ok {
671 okMath = false
672 return true
673 }
674 }
675 return false
676 })
677 if !okMath {
678 return 0, 0, false
679 }
680 sd, sc, okSum := b.sumAccounts()
681 agrees = okSum &&
682 d == c &&
683 d == b.totalDebits && c == b.totalCredits &&
684 d == sd && c == sc
685 return d, c, agrees
686}
687
688// AccountingEquation returns the two halves of assets+expenses versus
689// liabilities+equity+revenue, in RAW debit-minus-credit terms, and whether
690// they offset.
691//
692// IT IS WEAKER THAN IT LOOKS, and weaker than this doc used to claim.
693//
694// What offsets ACTUALLY proves: sum(RawDelta) == 0 over the whole chart,
695// which is the same fact as IsBalanced's first conjunct (d == c) and
696// nothing more. IsBalanced requires three. So this is implied by
697// IsBalanced, is NOT an independent accounting fact, and citing it as a
698// second guarantee alongside IsBalanced is counting one check twice.
699//
700// What offsets DOES NOT prove, despite the Kind partition sitting right
701// there in the code: anything whatsoever about normal-balance handling.
702// dn and cn are a partition of the same addends, so dn+cn is the total
703// regardless of which bucket each account landed in. Reclassify every
704// account in the chart and BOTH subtotals change while offsets stays
705// true. A Kind set wrongly at Open time is undetectable here and
706// undetectable anywhere else in this package — obligation 7.
707//
708// It is exported because the two SUBTOTALS are correct and a consumer
709// presenting a balance sheet wants them anyway. They are the product;
710// offsets is a duplicate of a check you already have.
711func (b *Book) AccountingEquation() (debitNormal, creditNormal int64, offsets bool) {
712 var dn, cn int64
713 okMath := true
714 b.accounts.Iterate("", "", func(_ string, value any) bool {
715 a := value.(Account)
716 var ok bool
717 if a.Kind.IsDebitNormal() {
718 dn, ok = checkedAdd(dn, a.RawDelta())
719 } else {
720 cn, ok = checkedAdd(cn, a.RawDelta())
721 }
722 if !ok {
723 okMath = false
724 return true
725 }
726 return false
727 })
728 if !okMath {
729 return 0, 0, false
730 }
731 sum, ok := checkedAdd(dn, cn)
732 return dn, cn, ok && sum == 0
733}
734
735// IterateAccounts calls fn for every account in ascending code order.
736// Iteration stops early when fn returns true.
737//
738// fn receives a COPY: Account is passed by value and holds only scalars and
739// strings, so fn gets no pointer into the Book.
740//
741// Two rules for fn, and the second is the one that is easy to miss.
742//
743// - Do not Open, Post, Reverse or Close from inside fn: mutating a tree
744// while iterating it is undefined. Collect what you need first, then act
745// after IterateAccounts returns.
746//
747// - fn runs under the CALLING REALM'S STORAGE AUTHORITY. This method's
748// receiver is stamped with the importing realm's PkgID, so the borrow
749// rules leave the realm context set to that realm for the whole
750// callback, and a top-level fn has no receiver and no declaring realm to
751// anchor it elsewhere. A callback that re-enters the calling realm's own
752// mutators therefore does so with that realm's authority. Never pass a
753// caller-supplied function here from inside a permission-gated path;
754// pass only a closure this package's consumer wrote itself.
755func (b *Book) IterateAccounts(fn func(Account) bool) {
756 b.accounts.Iterate("", "", func(_ string, value any) bool {
757 return fn(value.(Account))
758 })
759}
760
761// IterateEntries calls fn for every entry in ascending ID order, which for
762// an append-only journal is also chronological by insertion. Iteration stops
763// early when fn returns true.
764//
765// fn receives an Entry whose Postings slice is a DEFENSIVE COPY, so a
766// callback cannot write through it into Book state. The two rules on
767// IterateAccounts's fn apply here unchanged, including the storage-authority
768// one.
769func (b *Book) IterateEntries(fn func(Entry) bool) {
770 b.entries.Iterate("", "", func(_ string, value any) bool {
771 return fn(value.(Entry).withCopiedPostings())
772 })
773}
774
775// Entries returns up to limit entries with ID >= from, in ascending ID
776// order, each with a defensive copy of its postings. A limit <= 0 returns
777// nothing. It is the paging read for a consumer that cannot hold the whole
778// journal in one call.
779func (b *Book) Entries(from uint64, limit int) []Entry {
780 out := []Entry{}
781 if limit <= 0 {
782 return out
783 }
784 b.entries.Iterate(idKey(from), "", func(_ string, value any) bool {
785 out = append(out, value.(Entry).withCopiedPostings())
786 return len(out) >= limit
787 })
788 return out
789}
790
791// post is the shared body of Post and Reverse. Callers have already
792// validated `at`, the memo, and the period seal; reversalOf is 0 for an
793// original entry. It validates every posting and computes both totals before
794// writing anything.
795func (b *Book) post(at int64, memo string, reversalOf uint64, postings []Posting) (uint64, error) {
796 if len(postings) < 2 {
797 return 0, ErrTooFewPostings
798 }
799 if len(postings) > MaxPostingsPerEntry {
800 return 0, ErrTooManyPostings
801 }
802 if b.entries.Size() >= b.maxEntries {
803 return 0, ErrBookFull
804 }
805 if b.nextID == 0 {
806 return 0, ErrIDExhausted
807 }
808
809 var debits, credits int64
810 sawDebit, sawCredit := false, false
811 for _, p := range postings {
812 if !p.Side.Valid() {
813 return 0, ErrInvalidSide
814 }
815 if p.Amount <= 0 {
816 return 0, ErrInvalidAmount
817 }
818 if !b.accounts.Has(p.Account) {
819 return 0, ErrUnknownAccount
820 }
821 var ok bool
822 if p.Side == Debit {
823 sawDebit = true
824 debits, ok = checkedAdd(debits, p.Amount)
825 } else {
826 sawCredit = true
827 credits, ok = checkedAdd(credits, p.Amount)
828 }
829 if !ok {
830 return 0, ErrOverflow
831 }
832 }
833 if !sawDebit {
834 return 0, ErrNoDebit
835 }
836 if !sawCredit {
837 return 0, ErrNoCredit
838 }
839 if debits < credits {
840 return 0, ErrDebitShort
841 }
842 if credits < debits {
843 return 0, ErrCreditShort
844 }
845
846 // Book-level running totals, checked before any account is touched.
847 newTotalDebits, ok := checkedAdd(b.totalDebits, debits)
848 if !ok {
849 return 0, ErrOverflow
850 }
851 newTotalCredits, ok := checkedAdd(b.totalCredits, credits)
852 if !ok {
853 return 0, ErrOverflow
854 }
855
856 // Per-account accumulators, all computed into a staging list before the
857 // first Set, so an overflow on the last posting leaves nothing written.
858 staged := make([]Account, len(postings))
859 for i, p := range postings {
860 a, found := b.account(p.Account)
861 if !found {
862 // Unreachable: Has() succeeded above and nothing has been
863 // written since. Refused rather than ignored, because a silent
864 // zero Account here would corrupt the chart.
865 return 0, ErrUnknownAccount
866 }
867 // Fold earlier stagings of the same account, so an entry that posts
868 // to one account twice accumulates both legs instead of the second
869 // staging overwriting the first.
870 for j := 0; j < i; j++ {
871 if staged[j].Code == p.Account {
872 a = staged[j]
873 }
874 }
875 var okAcc bool
876 if p.Side == Debit {
877 a.Debits, okAcc = checkedAdd(a.Debits, p.Amount)
878 } else {
879 a.Credits, okAcc = checkedAdd(a.Credits, p.Amount)
880 }
881 if !okAcc {
882 return 0, ErrOverflow
883 }
884 staged[i] = a
885 }
886
887 // Past here nothing can fail.
888 id := b.nextID
889 b.nextID++
890 stored := make([]Posting, len(postings))
891 copy(stored, postings)
892 b.entries.Set(idKey(id), Entry{
893 ID: id,
894 At: at,
895 Memo: memo,
896 Postings: stored,
897 ReversalOf: reversalOf,
898 })
899 for _, a := range staged {
900 b.accounts.Set(a.Code, a)
901 }
902 b.totalDebits = newTotalDebits
903 b.totalCredits = newTotalCredits
904 return id, nil
905}
906
907// sumAccounts totals every account's debit and credit columns. The bool is
908// false on overflow, which is why callers must not treat (0, 0) as an empty
909// chart.
910func (b *Book) sumAccounts() (debits, credits int64, ok bool) {
911 var d, c int64
912 okMath := true
913 b.accounts.Iterate("", "", func(_ string, value any) bool {
914 a := value.(Account)
915 var okAdd bool
916 if d, okAdd = checkedAdd(d, a.Debits); !okAdd {
917 okMath = false
918 return true
919 }
920 if c, okAdd = checkedAdd(c, a.Credits); !okAdd {
921 okMath = false
922 return true
923 }
924 return false
925 })
926 if !okMath {
927 return 0, 0, false
928 }
929 return d, c, true
930}
931
932// account looks up one chart line.
933func (b *Book) account(code string) (Account, bool) {
934 v := b.accounts.Get(code)
935 if v == nil {
936 return Account{}, false
937 }
938 return v.(Account), true
939}
940
941// entry looks up one journal entry, WITHOUT copying its postings. Internal
942// callers must not retain or mutate the returned slice; every exported path
943// goes through withCopiedPostings.
944func (b *Book) entry(id uint64) (Entry, bool) {
945 v := b.entries.Get(idKey(id))
946 if v == nil {
947 return Entry{}, false
948 }
949 return v.(Entry), true
950}
951
952// withCopiedPostings returns e with a fresh Postings slice, so a value
953// handed to a caller shares no backing array with the Book.
954func (e Entry) withCopiedPostings() Entry {
955 out := make([]Posting, len(e.Postings))
956 copy(out, e.Postings)
957 e.Postings = out
958 return e
959}
960
961// idKey encodes an ID so that avl's lexical key order matches numeric ID
962// order. math.MaxUint64 is 20 digits, so a fixed 20-wide zero-padded
963// decimal is both sufficient and unambiguous.
964func idKey(id uint64) string {
965 s := strconv.FormatUint(id, 10)
966 const width = 20
967 if len(s) >= width {
968 return s
969 }
970 return zeros[:width-len(s)] + s
971}
972
973const zeros = "00000000000000000000"
974
975// checkedAdd returns a+b and reports whether the addition did not overflow
976// int64.
977func checkedAdd(a, b int64) (int64, bool) {
978 sum := a + b
979 if (b > 0 && sum < a) || (b < 0 && sum > a) {
980 return 0, false
981 }
982 return sum, true
983}