const MaxAccountsLimit
MaxAccountsLimit is the largest maxAccounts a Book may be constructed with. It bounds the cost of IsBalanced and AccountingEquation, which scan the chart of accounts.
Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.
Package journal is a pure double-entry accounting primitive for realms that need books which provably balance. It is ...
Package journal is a pure double-entry accounting primitive for realms that need books which provably balance. It is to *bookkeeping* what feeledger is to *money* and duebook is to *time*: it holds no coins, performs no effects, imports no chain APIs, and owns no package-level state. The importing realm owns a *Book, supplies the clock, and performs its own effects.
Every financial primitive in the searched Gno ecosystem is ONE-SIDED. A treasury records what moved. A pull-payment ledger records who is owed what. A GRC20 ledger records who holds what. Each stores a balance and mutates it.
This package stores no balance. It stores ENTRIES, and derives balances from them:
1book.MustOpen("1000", "Cash", Asset)
2book.MustOpen("4000", "Revenue", Revenue)
3book.MustPost(now, "invoice 17 paid", []Posting{
4 {Account: "1000", Side: Debit, Amount: 5000},
5 {Account: "4000", Side: Credit, Amount: 5000},
6})
An entry that does not balance is refused, and the refusal says which side is short. So `sum(debits) == sum(credits)` is not a property the consumer must remember to maintain — it is the precondition for the state changing at all. A Book cannot be put into an unbalanced state by any sequence of calls to this package.
Post is the only way in, and there is no Edit and no Delete. A mistake is corrected the way real books correct one: with a REVERSING ENTRY that mirrors every posting of the original, leaving both visible.
1book.MustReverse(entryID, now, "wrong account")
This is why history is trustworthy here and would not be if entries were mutable. An auditor reading the journal sees the error AND the correction, which is the entire point of keeping a journal rather than a balance. Reverse refuses a second reversal of the same entry, so a correction cannot be looped.
Close(through) seals everything dated at or before `through`. It only ever moves forward, and after it Post refuses any entry dated into the sealed range — the backdating that makes a closed period meaningless. Reverse is refused there too: correcting a sealed period means opening a new entry in the current one, which is the consumer's call to make explicitly.
Read this before quoting IsBalanced as a guarantee.
IsBalanced compares TWO INDEPENDENTLY MAINTAINED ACCUMULATORS: the per-account debit/credit totals, summed across the chart, against the Book's own running totals. They are written by different code paths, so a drift between them is a real defect and IsBalanced finds it. What it does NOT do is re-read the journal, so a defect that corrupted both accumulators identically would survive it.
RederiveFromJournal is the strong check: it replays every posting of every entry and compares the result against both accumulators. It costs O(all postings ever) and exists to be run deliberately, not on every read.
AccountingEquation is WEAKER THAN IT LOOKS and is documented as such rather than left for a reader to assume otherwise: under this package's sign conventions it is implied by IsBalanced, and its boolean proves EXACTLY ONE of IsBalanced's three conjuncts. It is NOT an independent accounting fact. Three checks that are really two, presented as three, would be the defect this package exists to avoid.
This doc said "it is a cross-check of this package's own normal-balance handling" until 2026-10-07, and that sentence was FALSE — the one place the careful disclaimer around it still overclaimed. The boolean is the sum of every account's RawDelta, bucketed by Kind and then added back together, so the bucketing cannot change it: offsets is INVARIANT under every possible Kind misclassification, which is the one thing "normal-balance handling" means. See obligation 7 below, and AccountingEquation's own doc for what it does prove.
And the ceiling on all three: a Book can be internally perfect and describe nothing. See obligation 1 below.
Entries are never removed, because an append-only journal whose entries could vanish is not a journal. Storage therefore grows with every Post and is bounded only by maxEntries, set at construction. A Book that reaches that cap refuses further entries rather than silently dropping history — a long-lived consumer must plan for this, and the plan is a new Book with opening balances carried forward, not a pruned one.
All failures are returned as errors and leave the Book COMPLETELY UNCHANGED. Must* wrappers are the only functions here that panic.
The Book is address-agnostic: account codes are non-empty strings. Realms normally use a chart-of-accounts code, not an address.
MaxAccountsLimit is the largest maxAccounts a Book may be constructed with. It bounds the cost of IsBalanced and AccountingEquation, which scan the chart of accounts.
MaxCodeLen bounds an account code.
MaxEntriesLimit is the largest maxEntries a Book may be constructed with.
MaxMemoLen bounds an entry memo. The memo is opaque to journal — it exists so a realm can record why an entry was made without keeping a parallel table.
MaxNameLen bounds an account's human-readable name.
MaxPostingsPerEntry bounds a single entry's posting count. Two is the minimum a balanced entry can have; the ceiling keeps one Post bounded.
The five account kinds. Asset and Expense are debit-normal; Liability, Equity and Revenue are credit-normal.
The two posting sides.
1var (
2 ErrEmptyCode = errors.New("journal: empty account code")
3 ErrCodeTooLong = errors.New("journal: account code exceeds MaxCodeLen")
4 ErrNameTooLong = errors.New("journal: account name exceeds MaxNameLen")
5 ErrMemoTooLong = errors.New("journal: memo exceeds MaxMemoLen")
6 ErrInvalidKind = errors.New("journal: unknown account kind")
7 ErrInvalidSide = errors.New("journal: posting side must be Debit or Credit")
8 ErrInvalidAmount = errors.New("journal: posting amount must be positive")
9 ErrInvalidAt = errors.New("journal: at must be non-negative")
10 ErrInvalidConfig = errors.New("journal: invalid book configuration")
11 ErrAccountExists = errors.New("journal: account code already open")
12 ErrUnknownAccount = errors.New("journal: no such account")
13 ErrChartFull = errors.New("journal: account cap reached")
14 ErrBookFull = errors.New("journal: entry cap reached")
15 ErrTooFewPostings = errors.New("journal: an entry needs at least two postings")
16 ErrTooManyPostings = errors.New("journal: entry exceeds MaxPostingsPerEntry")
17 ErrNoDebit = errors.New("journal: entry has no debit posting")
18 ErrNoCredit = errors.New("journal: entry has no credit posting")
19 ErrDebitShort = errors.New("journal: entry does not balance, debits are short")
20 ErrCreditShort = errors.New("journal: entry does not balance, credits are short")
21 ErrEntryNotFound = errors.New("journal: no such entry")
22 ErrAlreadyReversed = errors.New("journal: entry has already been reversed")
23 ErrPeriodClosed = errors.New("journal: at falls in a closed period")
24 ErrCloseNotForward = errors.New("journal: close must move forward")
25 ErrOverflow = errors.New("journal: int64 overflow")
26 ErrIDExhausted = errors.New("journal: identifier space exhausted")
27)Errors returned by Book operations.
MustNew is New but panics on error.
New returns an empty Book.
maxAccounts caps the chart and must be in [1, MaxAccountsLimit]. maxEntries caps the journal and must be in [1, MaxEntriesLimit]; because entries are never removed, it is a lifetime cap rather than a concurrent one, and a Book that reaches it is finished.
Account is one line of the chart of accounts, carrying its cumulative debit and credit totals. It is returned BY VALUE: holders cannot reach into a Book through it.
Balance returns the account's balance in its NORMAL direction: for a debit-normal account Debits-Credits, for a credit-normal account Credits-Debits. A normal account therefore has a non-negative balance, and a negative result is a real signal (a contra position) rather than a sign convention.
RawDelta returns Debits-Credits regardless of kind. Summed over every account in a balanced book it is exactly zero, which is what makes it the useful quantity for a consistency check and the wrong one for a report.
1type Book struct {
2 maxAccounts int
3 maxEntries int
4
5 accounts *avl.Tree // code -> Account
6 entries *avl.Tree // padded id -> Entry
7 // reversed stores `true`, not a struct{} sentinel, and deliberately:
8 // avl.Tree.Get cannot tell a nil-valued key from an absent one, so a
9 // nil sentinel invites a future reader to switch from Has to Get and
10 // get a silently wrong answer. Membership is the whole value here —
11 // both readers use Has and the stored value is never read.
12 reversed *avl.Tree // padded id -> true, for entries already reversed
13
14 nextID uint64 // never decreases; entry IDs are never reused
15 closedThrough int64 // entries dated at or before this are refused
16 hasClosed bool // distinguishes "closed through 0" from "never closed"
17
18 totalDebits int64 // running total, maintained independently of accounts
19 totalCredits int64
20}Book is the chart of accounts and the journal of one consuming realm. The zero value is not usable; construct with New.
Account returns an account by code. The second result is false if no such account is open.
AccountCount returns how many accounts are open.
AccountingEquation returns the two halves of assets+expenses versus liabilities+equity+revenue, in RAW debit-minus-credit terms, and whether they offset.
IT IS WEAKER THAN IT LOOKS, and weaker than this doc used to claim.
What offsets ACTUALLY proves: sum(RawDelta) == 0 over the whole chart, which is the same fact as IsBalanced's first conjunct (d == c) and nothing more. IsBalanced requires three. So this is implied by IsBalanced, is NOT an independent accounting fact, and citing it as a second guarantee alongside IsBalanced is counting one check twice.
What offsets DOES NOT prove, despite the Kind partition sitting right there in the code: anything whatsoever about normal-balance handling. dn and cn are a partition of the same addends, so dn+cn is the total regardless of which bucket each account landed in. Reclassify every account in the chart and BOTH subtotals change while offsets stays true. A Kind set wrongly at Open time is undetectable here and undetectable anywhere else in this package — obligation 7.
It is exported because the two SUBTOTALS are correct and a consumer presenting a balance sheet wants them anyway. They are the product; offsets is a duplicate of a check you already have.
Balance returns an account's balance in its normal direction, and whether the account exists. A missing account returns (0, false) rather than a zero balance, because "no such account" and "balance zero" are different answers and a consumer branching on the number alone would conflate them.
Close seals the period ending at `through`, after which Post and Reverse refuse any entry dated at or before it.
It only moves forward. A first close may name any non-negative instant; a later one must name a strictly greater instant, so a seal can never be loosened and backdating can never be re-enabled.
Fails with ErrInvalidAt (through < 0) or ErrCloseNotForward. On error nothing is modified.
ClosedThrough returns the period seal: entries dated at or before it are refused. The second result is false if the Book has never been closed, in which case the first is meaningless — a Book closed through 0 and a Book never closed differ, and collapsing them would silently reject entries dated at 0.
Entries returns up to limit entries with ID >= from, in ascending ID order, each with a defensive copy of its postings. A limit <= 0 returns nothing. It is the paging read for a consumer that cannot hold the whole journal in one call.
Entry returns an entry by ID, with a defensive copy of its postings. The second result is false if no such entry exists — which, because entries are never removed, means it was never created.
EntryCount returns how many entries the journal holds. It never decreases.
IsBalanced reports whether the Book's two independently-maintained accumulators agree with each other and with themselves.
It scans the chart, sums every account's debit and credit totals, and requires that the two sums are equal AND that each matches the Book's own running total. Those totals are written by different code in Post, so a disagreement is a defect in this package and IsBalanced is how a consumer detects one.
What it does not do is re-read the journal. Read the package doc's "What balanced is actually worth" before quoting it, and use RederiveFromJournal for the strong claim.
An empty Book is balanced.
IsReversed reports whether an entry has already been reversed. It returns false for an entry that does not exist, so pair it with Entry when the distinction matters.
IterateAccounts calls fn for every account in ascending code order. Iteration stops early when fn returns true.
fn receives a COPY: Account is passed by value and holds only scalars and strings, so fn gets no pointer into the Book.
Two rules for fn, and the second is the one that is easy to miss.
Do not Open, Post, Reverse or Close from inside fn: mutating a tree while iterating it is undefined. Collect what you need first, then act after IterateAccounts returns.
fn runs under the CALLING REALM'S STORAGE AUTHORITY. This method's receiver is stamped with the importing realm's PkgID, so the borrow rules leave the realm context set to that realm for the whole callback, and a top-level fn has no receiver and no declaring realm to anchor it elsewhere. A callback that re-enters the calling realm's own mutators therefore does so with that realm's authority. Never pass a caller-supplied function here from inside a permission-gated path; pass only a closure this package's consumer wrote itself.
IterateEntries calls fn for every entry in ascending ID order, which for an append-only journal is also chronological by insertion. Iteration stops early when fn returns true.
fn receives an Entry whose Postings slice is a DEFENSIVE COPY, so a callback cannot write through it into Book state. The two rules on IterateAccounts's fn apply here unchanged, including the storage-authority one.
MaxAccounts returns the Book's cap on chart size.
MaxEntries returns the Book's lifetime cap on entries.
MustClose is Close but panics on error.
MustOpen is Open but panics on error.
MustPost is Post but panics on error.
MustReverse is Reverse but panics on error.
NextID returns the identifier the next entry will take. It only ever increases, so an entry ID identifies one entry for the life of the Book.
Open adds an account to the chart. Codes are unique and immutable: there is no Rename and no Close, because an account's cumulative totals are part of the derivation of every balance that cites it, and removing one would make the journal unreplayable.
Fails with ErrEmptyCode, ErrCodeTooLong, ErrNameTooLong, ErrInvalidKind, ErrAccountExists, or ErrChartFull. On error nothing is modified.
Post appends a balanced entry and returns its ID.
The postings must number at least two and at most MaxPostingsPerEntry, each naming an open account, a valid Side, and a positive Amount. The entry must contain at least one debit and at least one credit, and the two column totals must be equal — an inequality is reported as ErrDebitShort or ErrCreditShort, naming the column that needs more.
Fails with ErrInvalidAt, ErrMemoTooLong, ErrPeriodClosed, ErrBookFull, ErrIDExhausted, ErrTooFewPostings, ErrTooManyPostings, ErrInvalidSide, ErrInvalidAmount, ErrUnknownAccount, ErrNoDebit, ErrNoCredit, ErrDebitShort, ErrCreditShort, or ErrOverflow. On error nothing is modified — every posting is validated and every total computed before the first write.
RederiveFromJournal replays every posting of every entry and reports the debit and credit totals it finds, together with whether they agree with both the per-account accumulators and the Book's running totals.
This is the strong check and the expensive one: its cost is proportional to every posting ever made, so it is meant to be called deliberately — by an auditor, or by a consumer's own invariant view — and not from a hot path. `agrees` false on a Book this package built is a defect in this package.
Reverse appends the mirror of an existing entry — every posting repeated on the opposite side, for the same amount — and returns the new entry's ID. The original is left exactly as it was.
An entry may be reversed at most once. Reversing a reversal IS permitted (it re-applies the original's effect) and is itself subject to the same once-only rule, so a correction cannot be looped.
`at` dates the correction and must be in the open period: a sealed period cannot be corrected in place, by construction. Correcting one means posting into the current period, which is the consumer's decision to make explicitly rather than this package's to make silently.
Fails with ErrInvalidAt, ErrMemoTooLong, ErrPeriodClosed, ErrEntryNotFound, ErrAlreadyReversed, ErrBookFull, ErrIDExhausted, or ErrOverflow. On error nothing is modified.
TotalCredits returns the Book's running credit total across every entry.
TotalDebits returns the Book's running debit total across every entry.
Entry is one journal entry: a dated, memo'd, balanced set of postings. It is returned BY VALUE, and its Postings slice is a DEFENSIVE COPY — a holder can mutate the returned slice without touching the Book. Entry is the one type here that contains a slice, so it is the one type where returning the stored value directly would have handed out a writable window into Book state.
ReversalOf is 0 unless this entry was produced by Reverse, in which case it is the ID of the entry it mirrors.
IsReversal reports whether this entry was produced by Reverse.
Total returns the entry's debit total, which for any entry a Book accepted equals its credit total.
Kind is an account's classification, which fixes its normal balance. The zero value is deliberately invalid so a forgotten field is an error rather than an Asset.
IsDebitNormal reports whether an increase in k is recorded as a debit. Asset and Expense are debit-normal; the other three are credit-normal. An invalid Kind reports false, which is why callers validate first.
String returns the kind's name, or "invalid" for the zero value and any unrecognised code.
Valid reports whether k is one of the five kinds.
Posting is one leg of an entry: an amount in one column of one account. Amount is always POSITIVE — direction is carried by Side, not by a sign.
That is a deliberate choice over signed amounts. With a sign, "which side is short" is unanswerable without knowing each account's normal balance, so an unbalanced entry can only be reported as a number that does not add up. With an explicit Side the refusal names the short column, which is what a bookkeeper needs in order to fix it.
Side is which column a posting lands in. The zero value is deliberately invalid: a posting with a forgotten Side must be refused, not silently treated as a debit.
Opposite returns the other side. An invalid Side returns itself, so a caller that skipped validation gets an invalid posting rather than a plausible one.
String returns "debit", "credit", or "invalid".
Valid reports whether s is Debit or Credit.