Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}