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 source pure

Package journal is a pure double-entry accounting primitive for realms that need books which provably balance. It is ...

Overview

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.

The one idea

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:

Example
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.

No amend, no void

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.

Example
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.

Periods

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.

What "balanced" is actually worth

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.

State growth

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.

Consumer contract (the parts the package cannot enforce)

  1. THE BOOKS ARE CLAIMS BY WHOEVER POSTED THEM. This package proves arithmetic, never truth. With no custody there is no solvency invariant tying "Cash 5000" to 5000 real ugnot anywhere. A realm that presents a journal balance as a holding is asserting something this package did not check. This is the single most important obligation and the one most commonly got wrong.
  2. SUPPLY A REAL CLOCK. journal cannot verify that `at` came from runtime.ChainHeight() or time.Now().Unix(). A realm that lets a caller choose `at` has no period discipline at all: the caller dates entries wherever they are convenient. Pass the chain's clock, never a transaction parameter.
  3. USE ONE CLOCK CONSISTENTLY. Heights and seconds must not be mixed within a Book; `at` and `through` are both in the caller's unit.
  4. DO NOT EXPORT THE BOOK. A *Book is a mutable handle. Returning one across a realm boundary hands out the right to open accounts, post and reverse. Expose your own crossing functions instead; this package returns Account and Entry VALUES, never pointers into its state.
  5. AUTHORIZE THE POSTER. journal authenticates nobody. Who may open an account, who may post, and who may close a period are the realm's policy — derive the caller from cur.Previous().Address(), not from an argument.
  6. CHOOSE THE UNIT ONCE. Amounts are int64 in whatever minor unit the consumer picks. journal does not know about denominations and will happily balance an entry that mixes two of them.
  7. GET THE KIND RIGHT AT OPEN TIME. NO CHECK IN THIS PACKAGE VALIDATES AN ACCOUNT'S KIND. A Kind is accepted if it is one of the five and is never revisited, so an account opened Asset that should have been Liability reports a sign-flipped Balance() forever — and IsBalanced, RederiveFromJournal and AccountingEquation all three stay healthy, because every one of them is built on Debits and Credits, which the Kind does not touch. This is the invariant tiers' blind spot, it is structural rather than an omission to be fixed later, and a realm that lets a caller choose the Kind has delegated a decision no later check can audit.

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.

Constants 8

const MaxAccountsLimit

1const MaxAccountsLimit = 10000
source

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.

const MaxCodeLen

1const MaxCodeLen = 64
source

MaxCodeLen bounds an account code.

const MaxEntriesLimit

1const MaxEntriesLimit = 1000000
source

MaxEntriesLimit is the largest maxEntries a Book may be constructed with.

const MaxMemoLen

1const MaxMemoLen = 1024
source

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.

const MaxNameLen

1const MaxNameLen = 256
source

MaxNameLen bounds an account's human-readable name.

const MaxPostingsPerEntry

1const MaxPostingsPerEntry = 64
source

MaxPostingsPerEntry bounds a single entry's posting count. Two is the minimum a balanced entry can have; the ceiling keeps one Post bounded.

const Asset, Liability, Equity, Revenue, Expense

1const (
2	Asset     Kind = 1
3	Liability Kind = 2
4	Equity    Kind = 3
5	Revenue   Kind = 4
6	Expense   Kind = 5
7)
source

The five account kinds. Asset and Expense are debit-normal; Liability, Equity and Revenue are credit-normal.

Variables 1

var ErrEmptyCode, ErrCodeTooLong, ErrNameTooLong, ErrMemoTooLong, ErrInvalidKind, ErrInvalidSide, ErrInvalidAmount, ErrInvalidAt, ErrInvalidConfig, ErrAccountExists, ErrUnknownAccount, ErrChartFull, ErrBookFull, ErrTooFewPostings, ErrTooManyPostings, ErrNoDebit, ErrNoCredit, ErrDebitShort, ErrCreditShort, ErrEntryNotFound, ErrAlreadyReversed, ErrPeriodClosed, ErrCloseNotForward, ErrOverflow, ErrIDExhausted

 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)
source

Errors returned by Book operations.

Functions 2

func MustNew

1func MustNew(maxAccounts, maxEntries int) *Book
source

MustNew is New but panics on error.

func New

1func New(maxAccounts, maxEntries int) (*Book, error)
source

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.

Types 6

type Account

struct
1type Account struct {
2	Code    string
3	Name    string
4	Kind    Kind
5	Debits  int64
6	Credits int64
7}
source

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.

Methods on Account

func Balance

method on Account
1func (a Account) Balance() int64
source

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.

func RawDelta

method on Account
1func (a Account) RawDelta() int64
source

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.

type Book

struct
 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}
source

Book is the chart of accounts and the journal of one consuming realm. The zero value is not usable; construct with New.

Methods on Book

func Account

method on Book
1func (b *Book) Account(code string) (Account, bool)
source

Account returns an account by code. The second result is false if no such account is open.

func AccountCount

method on Book
1func (b *Book) AccountCount() int
source

AccountCount returns how many accounts are open.

func AccountingEquation

method on Book
1func (b *Book) AccountingEquation() (debitNormal, creditNormal int64, offsets bool)
source

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.

func Balance

method on Book
1func (b *Book) Balance(code string) (int64, bool)
source

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.

func Close

method on Book
1func (b *Book) Close(through int64) error
source

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.

func ClosedThrough

method on Book
1func (b *Book) ClosedThrough() (int64, bool)
source

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.

func Entries

method on Book
1func (b *Book) Entries(from uint64, limit int) []Entry
source

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.

func Entry

method on Book
1func (b *Book) Entry(id uint64) (Entry, bool)
source

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.

func EntryCount

method on Book
1func (b *Book) EntryCount() int
source

EntryCount returns how many entries the journal holds. It never decreases.

func IsBalanced

method on Book
1func (b *Book) IsBalanced() bool
source

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.

func IsReversed

method on Book
1func (b *Book) IsReversed(id uint64) bool
source

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.

func IterateAccounts

method on Book
1func (b *Book) IterateAccounts(fn func(Account) bool)
source

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.

func IterateEntries

method on Book
1func (b *Book) IterateEntries(fn func(Entry) bool)
source

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.

func MaxAccounts

method on Book
1func (b *Book) MaxAccounts() int
source

MaxAccounts returns the Book's cap on chart size.

func MaxEntries

method on Book
1func (b *Book) MaxEntries() int
source

MaxEntries returns the Book's lifetime cap on entries.

func MustClose

method on Book
1func (b *Book) MustClose(through int64)
source

MustClose is Close but panics on error.

func MustOpen

method on Book
1func (b *Book) MustOpen(code, name string, kind Kind)
source

MustOpen is Open but panics on error.

func MustPost

method on Book
1func (b *Book) MustPost(at int64, memo string, postings []Posting) uint64
source

MustPost is Post but panics on error.

func MustReverse

method on Book
1func (b *Book) MustReverse(id uint64, at int64, memo string) uint64
source

MustReverse is Reverse but panics on error.

func NextID

method on Book
1func (b *Book) NextID() uint64
source

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.

func Open

method on Book
1func (b *Book) Open(code, name string, kind Kind) error
source

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.

func Post

method on Book
1func (b *Book) Post(at int64, memo string, postings []Posting) (uint64, error)
source

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.

func RederiveFromJournal

method on Book
1func (b *Book) RederiveFromJournal() (debits, credits int64, agrees bool)
source

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.

func Reverse

method on Book
1func (b *Book) Reverse(id uint64, at int64, memo string) (uint64, error)
source

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.

func TotalCredits

method on Book
1func (b *Book) TotalCredits() int64
source

TotalCredits returns the Book's running credit total across every entry.

func TotalDebits

method on Book
1func (b *Book) TotalDebits() int64
source

TotalDebits returns the Book's running debit total across every entry.

type Entry

struct
1type Entry struct {
2	ID         uint64
3	At         int64
4	Memo       string
5	Postings   []Posting
6	ReversalOf uint64
7}
source

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.

Methods on Entry

func IsReversal

method on Entry
1func (e Entry) IsReversal() bool
source

IsReversal reports whether this entry was produced by Reverse.

func Total

method on Entry
1func (e Entry) Total() int64
source

Total returns the entry's debit total, which for any entry a Book accepted equals its credit total.

type Kind

ident
1type Kind uint8
source

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.

Methods on Kind

func IsDebitNormal

method on Kind
1func (k Kind) IsDebitNormal() bool
source

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.

func String

method on Kind
1func (k Kind) String() string
source

String returns the kind's name, or "invalid" for the zero value and any unrecognised code.

func Valid

method on Kind
1func (k Kind) Valid() bool
source

Valid reports whether k is one of the five kinds.

type Posting

struct
1type Posting struct {
2	Account string
3	Side    Side
4	Amount  int64
5}
source

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.

type Side

ident
1type Side uint8
source

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.

Methods on Side

func Opposite

method on Side
1func (s Side) Opposite() Side
source

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.

func String

method on Side
1func (s Side) String() string
source

String returns "debit", "credit", or "invalid".

func Valid

method on Side
1func (s Side) Valid() bool
source

Valid reports whether s is Debit or Credit.

Dependencies

Imports 3

/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal

Imported by indexer

Source Files 2