Transaction

F43ED0546985BC…55380083FDA1

Block 1,316,628 · index 0 · indexed

Summary

Hash
F43ED0546985BCD744AF240AD3C4F196DEFBB7BC1138B9EC6A6F55380083FDA1
Block
1,316,628
Size
80575 bytes
Gas used
104,756,192 / 250,000,000
Fee
250000 ugnot
Status
success

Messages

Attached funds
20000000 ugnot

Arguments · 9

  1. #1journal
  2. #2gnomod.toml
  3. #3module = "gno.land/p/g1qp5pt8cdq2f6kfdamn3yuxwg6quqnfpyewzz39/journal" gno = "0.9"
  4. #4journal.gno
  5. #5// 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: // // book.MustOpen("1000", "Cash", Asset) // book.MustOpen("4000", "Revenue", Revenue) // book.MustPost(now, "invoice 17 paid", []Posting{ // {Account: "1000", Side: Debit, Amount: 5000}, // {Account: "4000", Side: Credit, Amount: 5000}, // }) // // 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. // // book.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. package journal import ( "errors" "strconv" "gno.land/p/nt/avl/v0" ) // 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 MaxAccountsLimit = 10000 // MaxEntriesLimit is the largest maxEntries a Book may be constructed with. const MaxEntriesLimit = 1000000 // MaxPostingsPerEntry bounds a single entry's posting count. Two is the // minimum a balanced entry can have; the ceiling keeps one Post bounded. const MaxPostingsPerEntry = 64 // MaxCodeLen bounds an account code. const MaxCodeLen = 64 // MaxNameLen bounds an account's human-readable name. const MaxNameLen = 256 // 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 MaxMemoLen = 1024 // Errors returned by Book operations. var ( ErrEmptyCode = errors.New("journal: empty account code") ErrCodeTooLong = errors.New("journal: account code exceeds MaxCodeLen") ErrNameTooLong = errors.New("journal: account name exceeds MaxNameLen") ErrMemoTooLong = errors.New("journal: memo exceeds MaxMemoLen") ErrInvalidKind = errors.New("journal: unknown account kind") ErrInvalidSide = errors.New("journal: posting side must be Debit or Credit") ErrInvalidAmount = errors.New("journal: posting amount must be positive") ErrInvalidAt = errors.New("journal: at must be non-negative") ErrInvalidConfig = errors.New("journal: invalid book configuration") ErrAccountExists = errors.New("journal: account code already open") ErrUnknownAccount = errors.New("journal: no such account") ErrChartFull = errors.New("journal: account cap reached") ErrBookFull = errors.New("journal: entry cap reached") ErrTooFewPostings = errors.New("journal: an entry needs at least two postings") ErrTooManyPostings = errors.New("journal: entry exceeds MaxPostingsPerEntry") ErrNoDebit = errors.New("journal: entry has no debit posting") ErrNoCredit = errors.New("journal: entry has no credit posting") ErrDebitShort = errors.New("journal: entry does not balance, debits are short") ErrCreditShort = errors.New("journal: entry does not balance, credits are short") ErrEntryNotFound = errors.New("journal: no such entry") ErrAlreadyReversed = errors.New("journal: entry has already been reversed") ErrPeriodClosed = errors.New("journal: at falls in a closed period") ErrCloseNotForward = errors.New("journal: close must move forward") ErrOverflow = errors.New("journal: int64 overflow") ErrIDExhausted = errors.New("journal: identifier space exhausted") ) // 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. type Kind uint8 // The five account kinds. Asset and Expense are debit-normal; Liability, // Equity and Revenue are credit-normal. const ( Asset Kind = 1 Liability Kind = 2 Equity Kind = 3 Revenue Kind = 4 Expense Kind = 5 ) // Valid reports whether k is one of the five kinds. func (k Kind) Valid() bool { return k >= Asset && k <= Expense } // 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 (k Kind) IsDebitNormal() bool { return k == Asset || k == Expense } // String returns the kind's name, or "invalid" for the zero value and any // unrecognised code. func (k Kind) String() string { switch k { case Asset: return "asset" case Liability: return "liability" case Equity: return "equity" case Revenue: return "revenue" case Expense: return "expense" } return "invalid" } // 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. type Side uint8 // The two posting sides. const ( Debit Side = 1 Credit Side = 2 ) // Valid reports whether s is Debit or Credit. func (s Side) Valid() bool { return s == Debit || s == Credit } // String returns "debit", "credit", or "invalid". func (s Side) String() string { switch s { case Debit: return "debit" case Credit: return "credit" } return "invalid" } // 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 (s Side) Opposite() Side { switch s { case Debit: return Credit case Credit: return Debit } return s } // 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 Posting struct { Account string Side Side Amount int64 } // 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. type Account struct { Code string Name string Kind Kind Debits int64 Credits int64 } // 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 (a Account) Balance() int64 { if a.Kind.IsDebitNormal() { return a.Debits - a.Credits } return a.Credits - a.Debits } // 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. func (a Account) RawDelta() int64 { return a.Debits - a.Credits } // 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. type Entry struct { ID uint64 At int64 Memo string Postings []Posting ReversalOf uint64 } // IsReversal reports whether this entry was produced by Reverse. func (e Entry) IsReversal() bool { return e.ReversalOf != 0 } // Total returns the entry's debit total, which for any entry a Book accepted // equals its credit total. func (e Entry) Total() int64 { var t int64 for _, p := range e.Postings { if p.Side == Debit { t += p.Amount } } return t } // Book is the chart of accounts and the journal of one consuming realm. The // zero value is not usable; construct with New. type Book struct { maxAccounts int maxEntries int accounts *avl.Tree // code -> Account entries *avl.Tree // padded id -> Entry // reversed stores `true`, not a struct{} sentinel, and deliberately: // avl.Tree.Get cannot tell a nil-valued key from an absent one, so a // nil sentinel invites a future reader to switch from Has to Get and // get a silently wrong answer. Membership is the whole value here — // both readers use Has and the stored value is never read. reversed *avl.Tree // padded id -> true, for entries already reversed nextID uint64 // never decreases; entry IDs are never reused closedThrough int64 // entries dated at or before this are refused hasClosed bool // distinguishes "closed through 0" from "never closed" totalDebits int64 // running total, maintained independently of accounts totalCredits int64 } // 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. func New(maxAccounts, maxEntries int) (*Book, error) { if maxAccounts < 1 || maxAccounts > MaxAccountsLimit { return nil, ErrInvalidConfig } if maxEntries < 1 || maxEntries > MaxEntriesLimit { return nil, ErrInvalidConfig } return &Book{ maxAccounts: maxAccounts, maxEntries: maxEntries, accounts: avl.NewTree(), entries: avl.NewTree(), reversed: avl.NewTree(), nextID: 1, }, nil } // MustNew is New but panics on error. func MustNew(maxAccounts, maxEntries int) *Book { b, err := New(maxAccounts, maxEntries) if err != nil { panic(err) } return b } // MaxAccounts returns the Book's cap on chart size. func (b *Book) MaxAccounts() int { return b.maxAccounts } // MaxEntries returns the Book's lifetime cap on entries. func (b *Book) MaxEntries() int { return b.maxEntries } // AccountCount returns how many accounts are open. func (b *Book) AccountCount() int { return b.accounts.Size() } // EntryCount returns how many entries the journal holds. It never decreases. func (b *Book) EntryCount() int { return b.entries.Size() } // 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 (b *Book) NextID() uint64 { return b.nextID } // TotalDebits returns the Book's running debit total across every entry. func (b *Book) TotalDebits() int64 { return b.totalDebits } // TotalCredits returns the Book's running credit total across every entry. func (b *Book) TotalCredits() int64 { return b.totalCredits } // 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 (b *Book) ClosedThrough() (int64, bool) { return b.closedThrough, b.hasClosed } // 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 (b *Book) Open(code, name string, kind Kind) error { if code == "" { return ErrEmptyCode } if len(code) > MaxCodeLen { return ErrCodeTooLong } if len(name) > MaxNameLen { return ErrNameTooLong } if !kind.Valid() { return ErrInvalidKind } if b.accounts.Has(code) { return ErrAccountExists } if b.accounts.Size() >= b.maxAccounts { return ErrChartFull } b.accounts.Set(code, Account{Code: code, Name: name, Kind: kind}) return nil } // MustOpen is Open but panics on error. func (b *Book) MustOpen(code, name string, kind Kind) { if err := b.Open(code, name, kind); err != nil { panic(err) } } // Account returns an account by code. The second result is false if no such // account is open. func (b *Book) Account(code string) (Account, bool) { return b.account(code) } // 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 (b *Book) Balance(code string) (int64, bool) { a, ok := b.account(code) if !ok { return 0, false } return a.Balance(), true } // 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 (b *Book) Post(at int64, memo string, postings []Posting) (uint64, error) { if at < 0 { return 0, ErrInvalidAt } if len(memo) > MaxMemoLen { return 0, ErrMemoTooLong } if b.hasClosed && at <= b.closedThrough { return 0, ErrPeriodClosed } return b.post(at, memo, 0, postings) } // MustPost is Post but panics on error. func (b *Book) MustPost(at int64, memo string, postings []Posting) uint64 { id, err := b.Post(at, memo, postings) if err != nil { panic(err) } return id } // 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 (b *Book) Reverse(id uint64, at int64, memo string) (uint64, error) { if at < 0 { return 0, ErrInvalidAt } if len(memo) > MaxMemoLen { return 0, ErrMemoTooLong } if b.hasClosed && at <= b.closedThrough { return 0, ErrPeriodClosed } orig, ok := b.entry(id) if !ok { return 0, ErrEntryNotFound } if b.reversed.Has(idKey(id)) { return 0, ErrAlreadyReversed } mirror := make([]Posting, len(orig.Postings)) for i, p := range orig.Postings { mirror[i] = Posting{Account: p.Account, Side: p.Side.Opposite(), Amount: p.Amount} } newID, err := b.post(at, memo, id, mirror) if err != nil { return 0, err } // Marked only after post succeeded, so a refused reversal does not // consume the original's single reversal. b.reversed.Set(idKey(id), true) return newID, nil } // MustReverse is Reverse but panics on error. func (b *Book) MustReverse(id uint64, at int64, memo string) uint64 { newID, err := b.Reverse(id, at, memo) if err != nil { panic(err) } return newID } // 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 (b *Book) IsReversed(id uint64) bool { return b.reversed.Has(idKey(id)) } // 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 (b *Book) Close(through int64) error { if through < 0 { return ErrInvalidAt } if b.hasClosed && through <= b.closedThrough { return ErrCloseNotForward } b.closedThrough = through b.hasClosed = true return nil } // MustClose is Close but panics on error. func (b *Book) MustClose(through int64) { if err := b.Close(through); err != nil { panic(err) } } // 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 (b *Book) Entry(id uint64) (Entry, bool) { e, ok := b.entry(id) if !ok { return Entry{}, false } return e.withCopiedPostings(), true } // 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 (b *Book) IsBalanced() bool { d, c, ok := b.sumAccounts() if !ok { return false } return d == c && d == b.totalDebits && c == b.totalCredits } // 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 (b *Book) RederiveFromJournal() (debits, credits int64, agrees bool) { var d, c int64 okMath := true b.entries.Iterate("", "", func(_ string, value any) bool { e := value.(Entry) for _, p := range e.Postings { var ok bool if p.Side == Debit { d, ok = checkedAdd(d, p.Amount) } else { c, ok = checkedAdd(c, p.Amount) } if !ok { okMath = false return true } } return false }) if !okMath { return 0, 0, false } sd, sc, okSum := b.sumAccounts() agrees = okSum && d == c && d == b.totalDebits && c == b.totalCredits && d == sd && c == sc return d, c, agrees } // 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 (b *Book) AccountingEquation() (debitNormal, creditNormal int64, offsets bool) { var dn, cn int64 okMath := true b.accounts.Iterate("", "", func(_ string, value any) bool { a := value.(Account) var ok bool if a.Kind.IsDebitNormal() { dn, ok = checkedAdd(dn, a.RawDelta()) } else { cn, ok = checkedAdd(cn, a.RawDelta()) } if !ok { okMath = false return true } return false }) if !okMath { return 0, 0, false } sum, ok := checkedAdd(dn, cn) return dn, cn, ok && sum == 0 } // 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 (b *Book) IterateAccounts(fn func(Account) bool) { b.accounts.Iterate("", "", func(_ string, value any) bool { return fn(value.(Account)) }) } // 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 (b *Book) IterateEntries(fn func(Entry) bool) { b.entries.Iterate("", "", func(_ string, value any) bool { return fn(value.(Entry).withCopiedPostings()) }) } // 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 (b *Book) Entries(from uint64, limit int) []Entry { out := []Entry{} if limit <= 0 { return out } b.entries.Iterate(idKey(from), "", func(_ string, value any) bool { out = append(out, value.(Entry).withCopiedPostings()) return len(out) >= limit }) return out } // post is the shared body of Post and Reverse. Callers have already // validated `at`, the memo, and the period seal; reversalOf is 0 for an // original entry. It validates every posting and computes both totals before // writing anything. func (b *Book) post(at int64, memo string, reversalOf uint64, postings []Posting) (uint64, error) { if len(postings) < 2 { return 0, ErrTooFewPostings } if len(postings) > MaxPostingsPerEntry { return 0, ErrTooManyPostings } if b.entries.Size() >= b.maxEntries { return 0, ErrBookFull } if b.nextID == 0 { return 0, ErrIDExhausted } var debits, credits int64 sawDebit, sawCredit := false, false for _, p := range postings { if !p.Side.Valid() { return 0, ErrInvalidSide } if p.Amount <= 0 { return 0, ErrInvalidAmount } if !b.accounts.Has(p.Account) { return 0, ErrUnknownAccount } var ok bool if p.Side == Debit { sawDebit = true debits, ok = checkedAdd(debits, p.Amount) } else { sawCredit = true credits, ok = checkedAdd(credits, p.Amount) } if !ok { return 0, ErrOverflow } } if !sawDebit { return 0, ErrNoDebit } if !sawCredit { return 0, ErrNoCredit } if debits < credits { return 0, ErrDebitShort } if credits < debits { return 0, ErrCreditShort } // Book-level running totals, checked before any account is touched. newTotalDebits, ok := checkedAdd(b.totalDebits, debits) if !ok { return 0, ErrOverflow } newTotalCredits, ok := checkedAdd(b.totalCredits, credits) if !ok { return 0, ErrOverflow } // Per-account accumulators, all computed into a staging list before the // first Set, so an overflow on the last posting leaves nothing written. staged := make([]Account, len(postings)) for i, p := range postings { a, found := b.account(p.Account) if !found { // Unreachable: Has() succeeded above and nothing has been // written since. Refused rather than ignored, because a silent // zero Account here would corrupt the chart. return 0, ErrUnknownAccount } // Fold earlier stagings of the same account, so an entry that posts // to one account twice accumulates both legs instead of the second // staging overwriting the first. for j := 0; j < i; j++ { if staged[j].Code == p.Account { a = staged[j] } } var okAcc bool if p.Side == Debit { a.Debits, okAcc = checkedAdd(a.Debits, p.Amount) } else { a.Credits, okAcc = checkedAdd(a.Credits, p.Amount) } if !okAcc { return 0, ErrOverflow } staged[i] = a } // Past here nothing can fail. id := b.nextID b.nextID++ stored := make([]Posting, len(postings)) copy(stored, postings) b.entries.Set(idKey(id), Entry{ ID: id, At: at, Memo: memo, Postings: stored, ReversalOf: reversalOf, }) for _, a := range staged { b.accounts.Set(a.Code, a) } b.totalDebits = newTotalDebits b.totalCredits = newTotalCredits return id, nil } // sumAccounts totals every account's debit and credit columns. The bool is // false on overflow, which is why callers must not treat (0, 0) as an empty // chart. func (b *Book) sumAccounts() (debits, credits int64, ok bool) { var d, c int64 okMath := true b.accounts.Iterate("", "", func(_ string, value any) bool { a := value.(Account) var okAdd bool if d, okAdd = checkedAdd(d, a.Debits); !okAdd { okMath = false return true } if c, okAdd = checkedAdd(c, a.Credits); !okAdd { okMath = false return true } return false }) if !okMath { return 0, 0, false } return d, c, true } // account looks up one chart line. func (b *Book) account(code string) (Account, bool) { v := b.accounts.Get(code) if v == nil { return Account{}, false } return v.(Account), true } // entry looks up one journal entry, WITHOUT copying its postings. Internal // callers must not retain or mutate the returned slice; every exported path // goes through withCopiedPostings. func (b *Book) entry(id uint64) (Entry, bool) { v := b.entries.Get(idKey(id)) if v == nil { return Entry{}, false } return v.(Entry), true } // withCopiedPostings returns e with a fresh Postings slice, so a value // handed to a caller shares no backing array with the Book. func (e Entry) withCopiedPostings() Entry { out := make([]Posting, len(e.Postings)) copy(out, e.Postings) e.Postings = out return e } // idKey encodes an ID so that avl's lexical key order matches numeric ID // order. math.MaxUint64 is 20 digits, so a fixed 20-wide zero-padded // decimal is both sufficient and unambiguous. func idKey(id uint64) string { s := strconv.FormatUint(id, 10) const width = 20 if len(s) >= width { return s } return zeros[:width-len(s)] + s } const zeros = "00000000000000000000" // checkedAdd returns a+b and reports whether the addition did not overflow // int64. func checkedAdd(a, b int64) (int64, bool) { sum := a + b if (b > 0 && sum < a) || (b < 0 && sum > a) { return 0, false } return sum, true }
  6. #6journal_test.gno
  7. #7package journal import ( "testing" ) const ( cash = "1000" ar = "1100" payable = "2000" capital = "3000" revenue = "4000" rent = "5000" ) // newBook is the default fixture: a six-line chart covering all five kinds, // a generous entry cap. func newBook(t *testing.T) *Book { t.Helper() b, err := New(32, 1000) if err != nil { t.Fatalf("New: %v", err) } b.MustOpen(cash, "Cash", Asset) b.MustOpen(ar, "Accounts receivable", Asset) b.MustOpen(payable, "Accounts payable", Liability) b.MustOpen(capital, "Owner capital", Equity) b.MustOpen(revenue, "Sales", Revenue) b.MustOpen(rent, "Rent expense", Expense) return b } // mustPanicWith asserts fn panics with exactly want. A /p/ function called // from a /p/ test crosses no realm boundary, so the panic is LOCAL and // recover() is the right family -- Aborts* would not see it at all. func mustPanicWith(t *testing.T, want string, fn func()) { t.Helper() defer func() { r := recover() if r == nil { t.Fatalf("expected panic %q, got none", want) } err, ok := r.(error) if !ok || err.Error() != want { t.Fatalf("panic = %v, want %q", r, want) } }() fn() } // pair is the simplest balanced entry: debit one account, credit another. func pair(dr, cr string, amount int64) []Posting { return []Posting{ {Account: dr, Side: Debit, Amount: amount}, {Account: cr, Side: Credit, Amount: amount}, } } // --- construction --------------------------------------------------------- func TestNewRejectsBadConfig(t *testing.T) { cases := []struct { name string maxAccounts, maxEntries int }{ {"zero accounts", 0, 10}, {"negative accounts", -1, 10}, {"accounts over limit", MaxAccountsLimit + 1, 10}, {"zero entries", 10, 0}, {"negative entries", 10, -1}, {"entries over limit", 10, MaxEntriesLimit + 1}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if _, err := New(tc.maxAccounts, tc.maxEntries); err != ErrInvalidConfig { t.Fatalf("New = %v, want ErrInvalidConfig", err) } }) } } func TestNewAtLimitsIsAccepted(t *testing.T) { // The positive control for the bounds above: a sabotage that rejected // everything would pass those cases and fail this one. if _, err := New(1, 1); err != nil { t.Fatalf("New(1,1) = %v, want nil", err) } if _, err := New(MaxAccountsLimit, MaxEntriesLimit); err != nil { t.Fatalf("New at limits = %v, want nil", err) } } func TestNewBookIsEmptyAndBalanced(t *testing.T) { b := MustNew(4, 4) if b.AccountCount() != 0 || b.EntryCount() != 0 { t.Fatalf("counts = %d/%d, want 0/0", b.AccountCount(), b.EntryCount()) } if b.NextID() != 1 { t.Fatalf("NextID = %d, want 1", b.NextID()) } if !b.IsBalanced() { t.Fatal("an empty book must be balanced") } if _, _, agrees := b.RederiveFromJournal(); !agrees { t.Fatal("an empty book must rederive") } if _, closed := b.ClosedThrough(); closed { t.Fatal("a new book must not report a closed period") } } func TestMustNewPanics(t *testing.T) { mustPanicWith(t, "journal: invalid book configuration", func() { MustNew(0, 1) }) } // --- kinds and sides ------------------------------------------------------ func TestKindNormalBalance(t *testing.T) { cases := []struct { k Kind valid bool debitNormal bool name string }{ {Asset, true, true, "asset"}, {Expense, true, true, "expense"}, {Liability, true, false, "liability"}, {Equity, true, false, "equity"}, {Revenue, true, false, "revenue"}, {Kind(0), false, false, "invalid"}, {Kind(6), false, false, "invalid"}, {Kind(255), false, false, "invalid"}, } for _, tc := range cases { if got := tc.k.Valid(); got != tc.valid { t.Fatalf("Kind(%d).Valid = %v, want %v", uint8(tc.k), got, tc.valid) } if got := tc.k.IsDebitNormal(); got != tc.debitNormal { t.Fatalf("Kind(%d).IsDebitNormal = %v, want %v", uint8(tc.k), got, tc.debitNormal) } if got := tc.k.String(); got != tc.name { t.Fatalf("Kind(%d).String = %q, want %q", uint8(tc.k), got, tc.name) } } } func TestSideValidityAndOpposite(t *testing.T) { if !Debit.Valid() || !Credit.Valid() { t.Fatal("Debit and Credit must be valid") } if Side(0).Valid() || Side(3).Valid() { t.Fatal("only Debit and Credit are valid sides") } if Debit.Opposite() != Credit || Credit.Opposite() != Debit { t.Fatal("Opposite must swap the two sides") } // An invalid side returns itself rather than a plausible default, so a // caller that skipped validation gets an invalid posting. if Side(0).Opposite() != Side(0) { t.Fatal("Opposite of an invalid side must stay invalid") } if Debit.String() != "debit" || Credit.String() != "credit" || Side(9).String() != "invalid" { t.Fatal("Side.String") } } // --- opening accounts ----------------------------------------------------- func TestOpenValidation(t *testing.T) { long := "" for i := 0; i < MaxCodeLen+1; i++ { long += "x" } longName := "" for i := 0; i < MaxNameLen+1; i++ { longName += "y" } cases := []struct { name string code string acct string kind Kind want error }{ {"empty code", "", "Cash", Asset, ErrEmptyCode}, {"code too long", long, "Cash", Asset, ErrCodeTooLong}, {"name too long", "1", longName, Asset, ErrNameTooLong}, {"zero kind", "1", "Cash", Kind(0), ErrInvalidKind}, {"unknown kind", "1", "Cash", Kind(6), ErrInvalidKind}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { b := MustNew(4, 4) if err := b.Open(tc.code, tc.acct, tc.kind); err != tc.want { t.Fatalf("Open = %v, want %v", err, tc.want) } if b.AccountCount() != 0 { t.Fatal("a refused Open must leave the chart unchanged") } }) } } func TestOpenAtExactLengthLimitsIsAccepted(t *testing.T) { // Positive control for the two length guards: off-by-one in the wrong // direction would reject these. code := "" for i := 0; i < MaxCodeLen; i++ { code += "x" } name := "" for i := 0; i < MaxNameLen; i++ { name += "y" } b := MustNew(4, 4) if err := b.Open(code, name, Asset); err != nil { t.Fatalf("Open at exact limits = %v, want nil", err) } } func TestOpenRejectsDuplicateAndRespectsCap(t *testing.T) { b := MustNew(2, 4) b.MustOpen("1", "One", Asset) if err := b.Open("1", "Different name", Revenue); err != ErrAccountExists { t.Fatalf("duplicate Open = %v, want ErrAccountExists", err) } // The duplicate must not have overwritten the original. a, _ := b.Account("1") if a.Name != "One" || a.Kind != Asset { t.Fatalf("account mutated by refused Open: %+v", a) } b.MustOpen("2", "Two", Liability) if err := b.Open("3", "Three", Asset); err != ErrChartFull { t.Fatalf("Open past cap = %v, want ErrChartFull", err) } if b.AccountCount() != 2 { t.Fatalf("AccountCount = %d, want 2", b.AccountCount()) } } func TestMustOpenPanics(t *testing.T) { b := MustNew(4, 4) mustPanicWith(t, "journal: empty account code", func() { b.MustOpen("", "x", Asset) }) } func TestAccountAndBalanceDistinguishMissingFromZero(t *testing.T) { b := newBook(t) if _, ok := b.Account("nope"); ok { t.Fatal("Account must report a missing code") } // The distinction that matters: a missing account is not a zero balance. if v, ok := b.Balance("nope"); ok || v != 0 { t.Fatalf("Balance(missing) = %d,%v want 0,false", v, ok) } if v, ok := b.Balance(cash); !ok || v != 0 { t.Fatalf("Balance(open, unposted) = %d,%v want 0,true", v, ok) } } // --- posting -------------------------------------------------------------- func TestPostBalancedEntryUpdatesBothSides(t *testing.T) { b := newBook(t) id, err := b.Post(100, "owner funds the business", pair(cash, capital, 5000)) if err != nil { t.Fatalf("Post: %v", err) } if id != 1 || b.NextID() != 2 { t.Fatalf("id=%d nextID=%d, want 1 and 2", id, b.NextID()) } if got, _ := b.Balance(cash); got != 5000 { t.Fatalf("cash balance = %d, want 5000", got) } // Equity is credit-normal, so a credit is a POSITIVE balance. A // debit-normal reading would report -5000 here. if got, _ := b.Balance(capital); got != 5000 { t.Fatalf("capital balance = %d, want +5000 (credit-normal)", got) } a, _ := b.Account(cash) if a.Debits != 5000 || a.Credits != 0 || a.RawDelta() != 5000 { t.Fatalf("cash account = %+v", a) } e, _ := b.Account(capital) if e.Debits != 0 || e.Credits != 5000 || e.RawDelta() != -5000 { t.Fatalf("capital account = %+v", e) } if b.TotalDebits() != 5000 || b.TotalCredits() != 5000 { t.Fatalf("totals = %d/%d, want 5000/5000", b.TotalDebits(), b.TotalCredits()) } if !b.IsBalanced() { t.Fatal("book must be balanced after a balanced entry") } } func TestPostRefusesUnbalancedAndNamesTheShortSide(t *testing.T) { b := newBook(t) // Debits short: 100 debit against 150 credit. short := []Posting{ {Account: cash, Side: Debit, Amount: 100}, {Account: revenue, Side: Credit, Amount: 150}, } if _, err := b.Post(1, "", short); err != ErrDebitShort { t.Fatalf("Post = %v, want ErrDebitShort", err) } // Credits short: the mirror case must report the OTHER error, or the // refusal is not actually naming the short side. other := []Posting{ {Account: cash, Side: Debit, Amount: 150}, {Account: revenue, Side: Credit, Amount: 100}, } if _, err := b.Post(1, "", other); err != ErrCreditShort { t.Fatalf("Post = %v, want ErrCreditShort", err) } if b.EntryCount() != 0 || b.TotalDebits() != 0 { t.Fatal("a refused Post must leave the book completely unchanged") } if got, _ := b.Balance(cash); got != 0 { t.Fatalf("cash moved on a refused Post: %d", got) } } func TestPostValidation(t *testing.T) { long := "" for i := 0; i < MaxMemoLen+1; i++ { long += "m" } many := []Posting{} for i := 0; i < MaxPostingsPerEntry+1; i++ { side := Debit if i%2 == 1 { side = Credit } many = append(many, Posting{Account: cash, Side: side, Amount: 1}) } cases := []struct { name string at int64 memo string postings []Posting want error }{ {"negative at", -1, "", pair(cash, revenue, 1), ErrInvalidAt}, {"memo too long", 1, long, pair(cash, revenue, 1), ErrMemoTooLong}, {"no postings", 1, "", []Posting{}, ErrTooFewPostings}, {"one posting", 1, "", []Posting{{Account: cash, Side: Debit, Amount: 1}}, ErrTooFewPostings}, {"too many postings", 1, "", many, ErrTooManyPostings}, {"zero side", 1, "", []Posting{ {Account: cash, Side: Side(0), Amount: 1}, {Account: revenue, Side: Credit, Amount: 1}, }, ErrInvalidSide}, {"zero amount", 1, "", []Posting{ {Account: cash, Side: Debit, Amount: 0}, {Account: revenue, Side: Credit, Amount: 0}, }, ErrInvalidAmount}, {"negative amount", 1, "", []Posting{ {Account: cash, Side: Debit, Amount: -5}, {Account: revenue, Side: Credit, Amount: -5}, }, ErrInvalidAmount}, {"unknown account", 1, "", pair(cash, "9999", 1), ErrUnknownAccount}, {"all debits", 1, "", []Posting{ {Account: cash, Side: Debit, Amount: 1}, {Account: ar, Side: Debit, Amount: 1}, }, ErrNoCredit}, {"all credits", 1, "", []Posting{ {Account: revenue, Side: Credit, Amount: 1}, {Account: payable, Side: Credit, Amount: 1}, }, ErrNoDebit}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { b := newBook(t) if _, err := b.Post(tc.at, tc.memo, tc.postings); err != tc.want { t.Fatalf("Post = %v, want %v", err, tc.want) } if b.EntryCount() != 0 { t.Fatal("a refused Post must write nothing") } if !b.IsBalanced() { t.Fatal("a refused Post must leave the book balanced") } }) } } func TestPostAtExactPostingLimitIsAccepted(t *testing.T) { // Positive control for ErrTooManyPostings. b := newBook(t) ps := []Posting{} for i := 0; i < MaxPostingsPerEntry; i++ { side := Debit if i%2 == 1 { side = Credit } ps = append(ps, Posting{Account: cash, Side: side, Amount: 1}) } if _, err := b.Post(1, "", ps); err != nil { t.Fatalf("Post at exactly MaxPostingsPerEntry = %v, want nil", err) } } func TestPostAtZeroIsAcceptedOnAnUnclosedBook(t *testing.T) { // at == 0 is a legitimate instant. It is only refused once a period has // actually been closed through it -- which is why hasClosed exists. b := newBook(t) if _, err := b.Post(0, "genesis", pair(cash, capital, 1)); err != nil { t.Fatalf("Post(at=0) = %v, want nil", err) } } func TestPostMultiLegEntry(t *testing.T) { b := newBook(t) // One debit split across two credits. id := b.MustPost(10, "part cash, part credit", []Posting{ {Account: cash, Side: Debit, Amount: 300}, {Account: ar, Side: Debit, Amount: 700}, {Account: revenue, Side: Credit, Amount: 1000}, }) e, ok := b.Entry(id) if !ok { t.Fatal("entry missing") } if len(e.Postings) != 3 { t.Fatalf("postings = %d, want 3", len(e.Postings)) } if e.Total() != 1000 { t.Fatalf("Total = %d, want 1000", e.Total()) } if got, _ := b.Balance(revenue); got != 1000 { t.Fatalf("revenue = %d, want 1000", got) } if !b.IsBalanced() { t.Fatal("multi-leg entry must balance") } } func TestPostSameAccountTwiceInOneEntryAccumulatesBothLegs(t *testing.T) { // The staging loop folds earlier stagings of the same account. Without // that fold the second leg would overwrite the first and the account // would silently lose a posting while the book-level totals kept both -- // which IsBalanced is built to catch, so assert both. b := newBook(t) b.MustPost(1, "net settlement through one account", []Posting{ {Account: cash, Side: Debit, Amount: 500}, {Account: cash, Side: Credit, Amount: 200}, {Account: revenue, Side: Credit, Amount: 400}, {Account: rent, Side: Debit, Amount: 100}, }) a, _ := b.Account(cash) if a.Debits != 500 || a.Credits != 200 { t.Fatalf("cash = %+v, want debits 500 credits 200", a) } if a.Balance() != 300 { t.Fatalf("cash balance = %d, want 300", a.Balance()) } if !b.IsBalanced() { t.Fatal("per-account totals drifted from the running totals") } if _, _, agrees := b.RederiveFromJournal(); !agrees { t.Fatal("journal replay disagrees with the accumulators") } } func TestPostThreeLegsToOneAccountFoldsAllOfThem(t *testing.T) { // THREE legs, not two, and that is the entire point of the case. // // The staging fold scans every earlier staging and reassigns without // breaking, so the LAST match wins -- which is correct, because each // staging already carries the folds up to it. A `break` after the // first match would be the bug, and with only TWO legs the first // match IS the last, so the two-leg case above passes either way. // It takes a third leg to tell the implementation from the sabotage, // and neither IsBalanced nor RederiveFromJournal would notice: the // dropped middle leg would be absent from the account AND from the // stored entry the replay reads. b := newBook(t) b.MustPost(1, "three legs through one account", []Posting{ {Account: cash, Side: Debit, Amount: 500}, {Account: cash, Side: Credit, Amount: 200}, {Account: cash, Side: Debit, Amount: 100}, {Account: revenue, Side: Credit, Amount: 400}, }) a, _ := b.Account(cash) if a.Debits != 600 || a.Credits != 200 { t.Fatalf("cash = %+v, want debits 600 credits 200 (all three legs)", a) } if a.Balance() != 400 { t.Fatalf("cash balance = %d, want 400", a.Balance()) } if b.TotalDebits() != 600 || b.TotalCredits() != 600 { t.Fatalf("book totals = %d/%d, want 600/600", b.TotalDebits(), b.TotalCredits()) } if !b.IsBalanced() { t.Fatal("per-account totals drifted from the running totals") } if _, _, agrees := b.RederiveFromJournal(); !agrees { t.Fatal("journal replay disagrees with the accumulators") } // And the stored entry kept all four postings, not a deduplicated three. e, ok := b.Entry(1) if !ok { t.Fatal("entry 1 missing") } if len(e.Postings) != 4 { t.Fatalf("stored postings = %d, want 4", len(e.Postings)) } } func TestAccountingEquationIsBlindToAKindMisclassification(t *testing.T) { // The sabotage that proves the NEGATIVE claim in the docs, because an // unenforced doc claim is the defect this package keeps being audited // for. Obligation 7 and AccountingEquation's own doc both say a wrong // Kind is undetectable by every check here. Assert it rather than // asking a reader to believe it. // // If this test ever fails, some check learned to validate a Kind -- // update the docs, do not relax the test. b := newBook(t) b.MustPost(1, "", pair(cash, revenue, 100)) dn, cn, offsets := b.AccountingEquation() if !offsets { t.Fatal("precondition: a clean book must offset") } if dn != 100 || cn != -100 { t.Fatalf("subtotals = %d/%d, want 100/-100", dn, cn) } // Reclassify Cash from Asset to Liability: a credit-normal bucket for // an account holding a debit balance. Nothing about Debits or Credits // changes, so nothing built on them can see it. a, _ := b.Account(cash) a.Kind = Liability b.accounts.Set(a.Code, a) dn2, cn2, offsets2 := b.AccountingEquation() if !offsets2 { t.Fatal("offsets must STAY true -- it is invariant under the partition") } // Both subtotals moved, which is how we know the misclassification // really took effect and the invariance is not a vacuous pass. if dn2 != 0 || cn2 != 0 { t.Fatalf("subtotals = %d/%d, want 0/0 after reclassification", dn2, cn2) } if dn2 == dn && cn2 == cn { t.Fatal("subtotals did not move; the sabotage did not take") } // The other two tiers are equally blind. if !b.IsBalanced() { t.Fatal("IsBalanced must stay true: Kind is not one of its inputs") } if _, _, agrees := b.RederiveFromJournal(); !agrees { t.Fatal("RederiveFromJournal must stay true for the same reason") } // And the visible consequence: a sign-flipped balance, reported // confidently by the only accessor a consumer is likely to quote. a2, _ := b.Account(cash) if a2.Balance() != -100 { t.Fatalf("balance = %d, want -100 (the sign flip, silently)", a2.Balance()) } if a2.RawDelta() != 100 { t.Fatalf("RawDelta = %d, want 100 -- unchanged, which is the point", a2.RawDelta()) } } func TestPostRespectsEntryCap(t *testing.T) { b, err := New(8, 2) if err != nil { t.Fatalf("New: %v", err) } b.MustOpen(cash, "Cash", Asset) b.MustOpen(revenue, "Sales", Revenue) b.MustPost(1, "", pair(cash, revenue, 1)) b.MustPost(2, "", pair(cash, revenue, 1)) if _, err := b.Post(3, "", pair(cash, revenue, 1)); err != ErrBookFull { t.Fatalf("Post past cap = %v, want ErrBookFull", err) } if b.EntryCount() != 2 { t.Fatalf("EntryCount = %d, want 2", b.EntryCount()) } // A refused Post must not consume an ID either. if b.NextID() != 3 { t.Fatalf("NextID = %d, want 3", b.NextID()) } } func TestMustPostPanics(t *testing.T) { b := newBook(t) mustPanicWith(t, "journal: entry does not balance, debits are short", func() { b.MustPost(1, "", []Posting{ {Account: cash, Side: Debit, Amount: 1}, {Account: revenue, Side: Credit, Amount: 2}, }) }) } // --- overflow ------------------------------------------------------------- func TestPostOverflowLeavesBookUnchanged(t *testing.T) { const maxInt64 = int64(9223372036854775807) b := newBook(t) b.MustPost(1, "", pair(cash, revenue, maxInt64)) // A second entry of any positive size overflows the running totals. if _, err := b.Post(2, "", pair(cash, revenue, 1)); err != ErrOverflow { t.Fatalf("Post = %v, want ErrOverflow", err) } if b.EntryCount() != 1 || b.TotalDebits() != maxInt64 { t.Fatal("an overflowing Post must write nothing") } if !b.IsBalanced() { t.Fatal("book must remain balanced after a refused overflow") } } func TestPostOverflowWithinOneEntry(t *testing.T) { const maxInt64 = int64(9223372036854775807) b := newBook(t) if _, err := b.Post(1, "", []Posting{ {Account: cash, Side: Debit, Amount: maxInt64}, {Account: ar, Side: Debit, Amount: 1}, {Account: revenue, Side: Credit, Amount: maxInt64}, {Account: payable, Side: Credit, Amount: 1}, }); err != ErrOverflow { t.Fatalf("Post = %v, want ErrOverflow", err) } if b.EntryCount() != 0 { t.Fatal("must write nothing") } } func TestPostRefusesWhenIDsAreExhausted(t *testing.T) { // nextID starts at 1 and only ever increases, so the wrap is 2^64 // entries away and unreachable through the API. The guard is still // load-bearing -- a reused id makes one id name two entries -- so it // is proven by reaching past the API to the single state that fires // it, which is the move TestIsBalancedCatchesADriftedAccumulator makes // and for the same reason. b := newBook(t) b.nextID = 0 if _, err := b.Post(1, "", pair(cash, revenue, 100)); err != ErrIDExhausted { t.Fatalf("Post = %v, want ErrIDExhausted", err) } if b.EntryCount() != 0 { t.Fatal("a refused Post must write nothing") } } func TestPostCopiesTheCallerSlice(t *testing.T) { // The write-side twin of TestEntryPostingsAreADefensiveCopy, which // mutates the slice Entry RETURNS. This one mutates the slice the // CALLER passed in: if post stored that slice by reference, the caller // keeps a writable window into a sealed journal entry, and no // read-side copy can take it back. b := newBook(t) legs := pair(cash, revenue, 100) id := b.MustPost(1, "", legs) legs[0].Amount = 999 legs[0].Account = "hacked" legs[1].Side = Debit e, found := b.Entry(id) if !found { t.Fatal("the entry vanished") } if e.Postings[0].Amount != 100 || e.Postings[0].Account != cash { t.Fatalf("post stored the caller's slice: %+v", e.Postings[0]) } if e.Postings[1].Side != Credit { t.Fatal("post stored the caller's slice: the side was mutated through it") } if _, _, agrees := b.RederiveFromJournal(); !agrees { t.Fatal("the journal must still agree with both accumulators") } } func TestPostChecksBothRunningTotalsSeparately(t *testing.T) { // A posted entry advances both running totals by the same amount, so // through the API either check alone would catch every overflow the // other one would -- which makes neither of them provable. Drifting // one accumulator first is what makes the debit check and the credit // check distinguishable at all. const maxInt64 = int64(9223372036854775807) b := newBook(t) b.totalDebits = maxInt64 if _, err := b.Post(1, "", pair(cash, revenue, 1)); err != ErrOverflow { t.Fatalf("debit side: Post = %v, want ErrOverflow", err) } if b.EntryCount() != 0 { t.Fatal("debit side: a refused Post must write nothing") } b = newBook(t) b.totalCredits = maxInt64 if _, err := b.Post(1, "", pair(cash, revenue, 1)); err != ErrOverflow { t.Fatalf("credit side: Post = %v, want ErrOverflow", err) } if b.EntryCount() != 0 { t.Fatal("credit side: a refused Post must write nothing") } } func TestPostRefusesAPerAccountOverflow(t *testing.T) { // The book-level totals are checked before the chart is touched, and // here they are at zero and pass -- so this reaches the per-account // staging check and nothing else does. Without it a chart line wraps // negative while the book still reads balanced at the top level. const maxInt64 = int64(9223372036854775807) b := newBook(t) a, _ := b.Account(cash) a.Debits = maxInt64 b.accounts.Set(a.Code, a) if b.TotalDebits() != 0 { t.Fatal("precondition: the book-level check must not be the one that fires") } if _, err := b.Post(1, "", pair(cash, revenue, 1)); err != ErrOverflow { t.Fatalf("Post = %v, want ErrOverflow", err) } if b.EntryCount() != 0 { t.Fatal("a refused Post must write nothing") } again, _ := b.Account(cash) if again.Debits != maxInt64 { t.Fatalf("cash.Debits = %d, want the whole staging list discarded", again.Debits) } } func TestCheckedAddBranches(t *testing.T) { const maxInt64 = int64(9223372036854775807) const minInt64 = -maxInt64 - 1 if v, ok := checkedAdd(2, 3); !ok || v != 5 { t.Fatalf("checkedAdd(2,3) = %d,%v", v, ok) } if _, ok := checkedAdd(maxInt64, 1); ok { t.Fatal("positive overflow not caught") } if _, ok := checkedAdd(minInt64, -1); ok { t.Fatal("negative overflow not caught") } if v, ok := checkedAdd(maxInt64, -1); !ok || v != maxInt64-1 { t.Fatalf("checkedAdd(max,-1) = %d,%v", v, ok) } if v, ok := checkedAdd(5, 0); !ok || v != 5 { t.Fatalf("checkedAdd(5,0) = %d,%v", v, ok) } } // --- reversal ------------------------------------------------------------- func TestReverseMirrorsEveryPostingAndNetsToZero(t *testing.T) { b := newBook(t) id := b.MustPost(10, "wrong account", []Posting{ {Account: cash, Side: Debit, Amount: 300}, {Account: ar, Side: Debit, Amount: 700}, {Account: revenue, Side: Credit, Amount: 1000}, }) revID := b.MustReverse(id, 20, "fixing it") if revID != id+1 { t.Fatalf("reversal id = %d, want %d", revID, id+1) } rev, _ := b.Entry(revID) if !rev.IsReversal() || rev.ReversalOf != id { t.Fatalf("reversal metadata = %+v", rev) } if rev.At != 20 || rev.Memo != "fixing it" { t.Fatalf("reversal dating/memo = %+v", rev) } orig, _ := b.Entry(id) if orig.IsReversal() { t.Fatal("the original must not be marked a reversal") } if len(rev.Postings) != len(orig.Postings) { t.Fatalf("postings = %d, want %d", len(rev.Postings), len(orig.Postings)) } for i, p := range rev.Postings { o := orig.Postings[i] if p.Account != o.Account || p.Amount != o.Amount || p.Side != o.Side.Opposite() { t.Fatalf("posting %d = %+v, want mirror of %+v", i, p, o) } } // The whole point: every balance is back to zero, and BOTH entries are // still visible. for _, code := range []string{cash, ar, revenue} { if got, _ := b.Balance(code); got != 0 { t.Fatalf("%s = %d, want 0 after reversal", code, got) } } if b.EntryCount() != 2 { t.Fatalf("EntryCount = %d, want 2 -- a reversal must not delete", b.EntryCount()) } if !b.IsBalanced() { t.Fatal("book must be balanced after reversal") } if _, _, agrees := b.RederiveFromJournal(); !agrees { t.Fatal("journal replay must agree after reversal") } } func TestReverseIsOnceOnly(t *testing.T) { b := newBook(t) id := b.MustPost(1, "", pair(cash, revenue, 100)) if b.IsReversed(id) { t.Fatal("a fresh entry must not read as reversed") } b.MustReverse(id, 2, "") if !b.IsReversed(id) { t.Fatal("IsReversed must report the reversal") } if _, err := b.Reverse(id, 3, ""); err != ErrAlreadyReversed { t.Fatalf("second Reverse = %v, want ErrAlreadyReversed", err) } if b.EntryCount() != 2 { t.Fatalf("EntryCount = %d, want 2", b.EntryCount()) } } func TestReverseAReversalIsPermittedAndAlsoOnceOnly(t *testing.T) { // Documented behaviour: reversing a reversal re-applies the original. b := newBook(t) id := b.MustPost(1, "", pair(cash, revenue, 100)) revID := b.MustReverse(id, 2, "") if got, _ := b.Balance(cash); got != 0 { t.Fatalf("cash = %d, want 0", got) } reRevID := b.MustReverse(revID, 3, "undo the undo") if got, _ := b.Balance(cash); got != 100 { t.Fatalf("cash = %d, want 100 -- re-reversal must re-apply", got) } if _, err := b.Reverse(revID, 4, ""); err != ErrAlreadyReversed { t.Fatalf("second Reverse of the reversal = %v, want ErrAlreadyReversed", err) } if reRevID != 3 || b.EntryCount() != 3 { t.Fatalf("ids/count = %d/%d, want 3/3", reRevID, b.EntryCount()) } if !b.IsBalanced() { t.Fatal("must stay balanced") } } func TestReverseValidation(t *testing.T) { long := "" for i := 0; i < MaxMemoLen+1; i++ { long += "m" } b := newBook(t) id := b.MustPost(10, "", pair(cash, revenue, 100)) cases := []struct { name string id uint64 at int64 memo string want error }{ {"no such entry", 999, 20, "", ErrEntryNotFound}, {"entry id zero", 0, 20, "", ErrEntryNotFound}, {"negative at", id, -1, "", ErrInvalidAt}, {"memo too long", id, 20, long, ErrMemoTooLong}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { if _, err := b.Reverse(tc.id, tc.at, tc.memo); err != tc.want { t.Fatalf("Reverse = %v, want %v", err, tc.want) } if b.EntryCount() != 1 { t.Fatal("a refused Reverse must write nothing") } if b.IsReversed(id) { t.Fatal("a refused Reverse must not consume the single reversal") } }) } } func TestRefusedReverseDoesNotConsumeTheReversal(t *testing.T) { // The ordering that matters: `reversed` is marked only AFTER post // succeeds. A book at its entry cap refuses the reversal; the original // must still be reversible once there is room. b, err := New(8, 1) if err != nil { t.Fatalf("New: %v", err) } b.MustOpen(cash, "Cash", Asset) b.MustOpen(revenue, "Sales", Revenue) id := b.MustPost(1, "", pair(cash, revenue, 100)) if _, err := b.Reverse(id, 2, ""); err != ErrBookFull { t.Fatalf("Reverse = %v, want ErrBookFull", err) } if b.IsReversed(id) { t.Fatal("a reversal refused at the cap must not mark the entry reversed") } // Same book, with room: the reversal now succeeds. b2, _ := New(8, 2) b2.MustOpen(cash, "Cash", Asset) b2.MustOpen(revenue, "Sales", Revenue) id2 := b2.MustPost(1, "", pair(cash, revenue, 100)) if _, err := b2.Reverse(id2, 2, ""); err != nil { t.Fatalf("Reverse with room = %v, want nil", err) } } func TestMustReversePanics(t *testing.T) { b := newBook(t) mustPanicWith(t, "journal: no such entry", func() { b.MustReverse(42, 1, "") }) } // --- period close --------------------------------------------------------- func TestCloseSealsBackdatingForBothPostAndReverse(t *testing.T) { b := newBook(t) id := b.MustPost(10, "", pair(cash, revenue, 100)) if err := b.Close(100); err != nil { t.Fatalf("Close: %v", err) } through, closed := b.ClosedThrough() if !closed || through != 100 { t.Fatalf("ClosedThrough = %d,%v want 100,true", through, closed) } // At the boundary and below are sealed; the instant after is open. if _, err := b.Post(100, "", pair(cash, revenue, 1)); err != ErrPeriodClosed { t.Fatalf("Post at the seal = %v, want ErrPeriodClosed", err) } if _, err := b.Post(50, "", pair(cash, revenue, 1)); err != ErrPeriodClosed { t.Fatalf("Post before the seal = %v, want ErrPeriodClosed", err) } if _, err := b.Post(101, "", pair(cash, revenue, 1)); err != nil { t.Fatalf("Post after the seal = %v, want nil", err) } // Reverse is sealed by the same rule: a closed period cannot be // corrected in place. if _, err := b.Reverse(id, 100, ""); err != ErrPeriodClosed { t.Fatalf("Reverse into the seal = %v, want ErrPeriodClosed", err) } if b.IsReversed(id) { t.Fatal("a period-refused Reverse must not consume the reversal") } if _, err := b.Reverse(id, 200, "corrected in the open period"); err != nil { t.Fatalf("Reverse in the open period = %v, want nil", err) } } func TestCloseOnlyMovesForward(t *testing.T) { b := newBook(t) if err := b.Close(100); err != nil { t.Fatalf("Close: %v", err) } if err := b.Close(100); err != ErrCloseNotForward { t.Fatalf("re-close at the same instant = %v, want ErrCloseNotForward", err) } if err := b.Close(99); err != ErrCloseNotForward { t.Fatalf("close backwards = %v, want ErrCloseNotForward", err) } if err := b.Close(-1); err != ErrInvalidAt { t.Fatalf("Close(-1) = %v, want ErrInvalidAt", err) } // The seal must be exactly where it was after three refusals. through, _ := b.ClosedThrough() if through != 100 { t.Fatalf("ClosedThrough = %d, want 100", through) } if err := b.Close(101); err != nil { t.Fatalf("Close forward = %v, want nil", err) } } func TestCloseThroughZeroIsDistinctFromNeverClosed(t *testing.T) { // hasClosed exists for exactly this: without it, "closed through 0" is // indistinguishable from a new book, and a new book would silently // reject every entry dated at 0. b := newBook(t) if _, err := b.Post(0, "accepted before any close", pair(cash, capital, 1)); err != nil { t.Fatalf("Post(0) on a new book = %v, want nil", err) } if err := b.Close(0); err != nil { t.Fatalf("Close(0) = %v, want nil", err) } through, closed := b.ClosedThrough() if !closed || through != 0 { t.Fatalf("ClosedThrough = %d,%v want 0,true", through, closed) } if _, err := b.Post(0, "", pair(cash, capital, 1)); err != ErrPeriodClosed { t.Fatalf("Post(0) after Close(0) = %v, want ErrPeriodClosed", err) } } func TestMustClosePanics(t *testing.T) { b := newBook(t) b.MustClose(5) mustPanicWith(t, "journal: close must move forward", func() { b.MustClose(5) }) } // --- invariants ----------------------------------------------------------- func TestInvariantsHoldAcrossALongSequence(t *testing.T) { b := newBook(t) b.MustPost(1, "capital", pair(cash, capital, 10000)) b.MustPost(2, "sale on credit", pair(ar, revenue, 2500)) b.MustPost(3, "collect", pair(cash, ar, 2500)) b.MustPost(4, "rent accrued", pair(rent, payable, 800)) b.MustPost(5, "rent paid", pair(payable, cash, 800)) bad := b.MustPost(6, "mistake", pair(rent, cash, 999)) b.MustReverse(bad, 7, "mistake reversed") if !b.IsBalanced() { t.Fatal("IsBalanced") } d, c, agrees := b.RederiveFromJournal() if !agrees { t.Fatal("RederiveFromJournal must agree") } if d != c || d != b.TotalDebits() { t.Fatalf("rederived %d/%d vs totals %d/%d", d, c, b.TotalDebits(), b.TotalCredits()) } if _, _, offsets := b.AccountingEquation(); !offsets { t.Fatal("AccountingEquation must offset") } // Spot-check the economics, so this is a test of the ledger and not only // of its self-checks: 10000 in, 2500 collected, 800 rent paid, and the // 999 mistake reversed back out. if got, _ := b.Balance(cash); got != 10000+2500-800 { t.Fatalf("cash = %d, want 11700", got) } if got, _ := b.Balance(ar); got != 0 { t.Fatalf("AR = %d, want 0 after collection", got) } if got, _ := b.Balance(payable); got != 0 { t.Fatalf("payable = %d, want 0 after payment", got) } if got, _ := b.Balance(revenue); got != 2500 { t.Fatalf("revenue = %d, want 2500", got) } if got, _ := b.Balance(rent); got != 800 { t.Fatalf("rent = %d, want 800 -- the 999 error must be reversed out", got) } } func TestAccountingEquationHalvesAreReported(t *testing.T) { b := newBook(t) b.MustPost(1, "", pair(cash, capital, 10000)) b.MustPost(2, "", pair(rent, payable, 400)) // debit-normal raw delta: cash +10000, rent +400 = 10400 // credit-normal raw delta: capital -10000, payable -400 = -10400 dn, cn, offsets := b.AccountingEquation() if dn != 10400 || cn != -10400 || !offsets { t.Fatalf("AccountingEquation = %d,%d,%v want 10400,-10400,true", dn, cn, offsets) } } func TestIsBalancedCatchesADriftedAccumulator(t *testing.T) { // The sabotage that proves IsBalanced can fail. Reaching past the API // into the running total is exactly the drift it exists to detect; if // this book still read balanced, the check would be decorative. b := newBook(t) b.MustPost(1, "", pair(cash, revenue, 100)) if !b.IsBalanced() { t.Fatal("precondition") } b.totalDebits += 1 if b.IsBalanced() { t.Fatal("IsBalanced must fail when the running total drifts") } if _, _, agrees := b.RederiveFromJournal(); agrees { t.Fatal("RederiveFromJournal must fail on the same drift") } b.totalDebits -= 1 // And the other representation: drift a per-account accumulator. a, _ := b.Account(cash) a.Debits += 7 b.accounts.Set(a.Code, a) if b.IsBalanced() { t.Fatal("IsBalanced must fail when an account total drifts") } if _, _, offsets := b.AccountingEquation(); offsets { t.Fatal("AccountingEquation must fail on an account drift") } } func TestRederiveFromJournalCatchesACorruptedEntry(t *testing.T) { // The drift the cheap check cannot see: corrupt the JOURNAL while both // accumulators stay consistent with each other. IsBalanced passes -- // which is the documented ceiling -- and only the replay catches it. b := newBook(t) id := b.MustPost(1, "", pair(cash, revenue, 100)) e, _ := b.entry(id) e.Postings = []Posting{ {Account: cash, Side: Debit, Amount: 999}, {Account: revenue, Side: Credit, Amount: 999}, } b.entries.Set(idKey(id), e) if !b.IsBalanced() { t.Fatal("IsBalanced is documented as blind to this; if it now catches it, update the doc") } d, c, agrees := b.RederiveFromJournal() if agrees { t.Fatal("RederiveFromJournal must catch a journal that disagrees with the accumulators") } if d != 999 || c != 999 { t.Fatalf("rederived = %d/%d, want 999/999 from the journal itself", d, c) } } func TestIsBalancedComparesTheTwoColumnsToEachOther(t *testing.T) { // d == b.totalDebits and c == b.totalCredits together do NOT imply // d == c: a book whose two representations agree with each other can // still have a debit column that does not equal its credit column. // Drifting an account and its matching running total by the SAME // amount leaves the d == c conjunct as the only one that can notice, // which is what makes that conjunct provable rather than redundant. b := newBook(t) b.MustPost(1, "", pair(cash, revenue, 100)) if !b.IsBalanced() { t.Fatal("precondition") } a, _ := b.Account(cash) a.Debits += 7 b.accounts.Set(a.Code, a) b.totalDebits += 7 d, c, ok := b.sumAccounts() if !ok { t.Fatal("precondition: the chart must still sum") } if d != b.totalDebits || c != b.totalCredits { t.Fatalf("precondition: both representations must agree: chart %d/%d, totals %d/%d", d, c, b.totalDebits, b.totalCredits) } if d == c { t.Fatal("precondition: the two columns must differ") } if b.IsBalanced() { t.Fatal("IsBalanced must fail when the debit column does not equal the credit column") } } func TestIsBalancedRefusesAnOverflowedChartSum(t *testing.T) { // sumAccounts documents that callers must not read its (0, 0) on // overflow as an empty chart, and this is the reason: a fresh Book's // running totals are also 0/0, so discarding the ok makes an // overflowed chart read exactly like a book with nothing posted. const maxInt64 = int64(9223372036854775807) b := newBook(t) for _, code := range []string{cash, ar} { a, _ := b.Account(code) a.Debits = maxInt64 b.accounts.Set(a.Code, a) } if _, _, ok := b.sumAccounts(); ok { t.Fatal("precondition: the chart sum must overflow") } if b.TotalDebits() != 0 || b.TotalCredits() != 0 { t.Fatal("precondition: the running totals must still be zero, or the test proves something else") } if b.IsBalanced() { t.Fatal("IsBalanced must refuse a chart sum it could not compute") } if _, _, agrees := b.RederiveFromJournal(); agrees { t.Fatal("RederiveFromJournal must refuse the same chart") } } func TestRederiveFromJournalComparesTheReplayToTheChart(t *testing.T) { // The chart is the third representation and the one the running totals // cannot stand in for. Drift one account only: the replay still agrees // with both running totals, so the d == sd && c == sc conjunct is the // only thing left that can catch it. b := newBook(t) b.MustPost(1, "", pair(cash, revenue, 100)) a, _ := b.Account(cash) a.Debits += 7 b.accounts.Set(a.Code, a) d, c, agrees := b.RederiveFromJournal() if d != 100 || c != 100 { t.Fatalf("replay = %d/%d, want 100/100 read back from the journal", d, c) } if d != b.totalDebits || c != b.totalCredits { t.Fatalf("precondition: the replay must still agree with both running totals (%d/%d)", b.totalDebits, b.totalCredits) } sd, sc, okSum := b.sumAccounts() if !okSum { t.Fatal("precondition: the chart must still sum") } if sd == d { t.Fatalf("precondition: the chart must disagree with the replay: chart %d/%d", sd, sc) } if agrees { t.Fatal("RederiveFromJournal must fail when the chart disagrees with the journal") } } // --- reads and encapsulation ---------------------------------------------- func TestEntryPostingsAreADefensiveCopy(t *testing.T) { b := newBook(t) id := b.MustPost(1, "", pair(cash, revenue, 100)) e, _ := b.Entry(id) e.Postings[0].Amount = 1 e.Postings[0].Account = "hacked" e.Postings[1].Side = Debit again, _ := b.Entry(id) if again.Postings[0].Amount != 100 || again.Postings[0].Account != cash { t.Fatalf("Entry returned a window into Book state: %+v", again.Postings[0]) } if again.Postings[1].Side != Credit { t.Fatal("Entry's postings were mutated through the returned slice") } if !b.IsBalanced() { t.Fatal("book must be unaffected") } } func TestIterateEntriesAlsoCopies(t *testing.T) { b := newBook(t) b.MustPost(1, "", pair(cash, revenue, 100)) b.IterateEntries(func(e Entry) bool { e.Postings[0].Amount = 42 return false }) again := b.Entries(1, 1) if len(again) != 1 || again[0].Postings[0].Amount != 100 { t.Fatal("IterateEntries handed out a writable window") } } func TestEntryMissingIsReportedNotZeroed(t *testing.T) { b := newBook(t) if _, ok := b.Entry(1); ok { t.Fatal("Entry on an empty journal must report false") } if _, ok := b.Entry(0); ok { t.Fatal("id 0 is never allocated") } if b.IsReversed(1) { t.Fatal("IsReversed on a missing entry must be false") } } func TestIterationIsInAscendingIDOrderPastTenEntries(t *testing.T) { // The padded-key test: with unpadded decimal keys, "10" sorts before // "2" and this fails at the tenth entry. b := newBook(t) for i := 0; i < 12; i++ { b.MustPost(int64(i+1), "", pair(cash, revenue, int64(i+1))) } var seen []uint64 b.IterateEntries(func(e Entry) bool { seen = append(seen, e.ID) return false }) if len(seen) != 12 { t.Fatalf("iterated %d, want 12", len(seen)) } for i, id := range seen { if id != uint64(i+1) { t.Fatalf("order = %v, want 1..12 ascending", seen) } } } func TestIterationStopsEarly(t *testing.T) { b := newBook(t) for i := 0; i < 5; i++ { b.MustPost(int64(i+1), "", pair(cash, revenue, 1)) } n := 0 b.IterateEntries(func(Entry) bool { n++ return n >= 2 }) if n != 2 { t.Fatalf("IterateEntries visited %d, want 2", n) } m := 0 b.IterateAccounts(func(Account) bool { m++ return m >= 3 }) if m != 3 { t.Fatalf("IterateAccounts visited %d, want 3", m) } } func TestIterateAccountsCoversTheChartInCodeOrder(t *testing.T) { b := newBook(t) var codes []string b.IterateAccounts(func(a Account) bool { codes = append(codes, a.Code) return false }) want := []string{cash, ar, payable, capital, revenue, rent} if len(codes) != len(want) { t.Fatalf("iterated %d accounts, want %d", len(codes), len(want)) } for i := range want { if codes[i] != want[i] { t.Fatalf("order = %v, want %v", codes, want) } } } func TestEntriesPagesFromAnID(t *testing.T) { b := newBook(t) for i := 0; i < 6; i++ { b.MustPost(int64(i+1), "", pair(cash, revenue, 1)) } if got := b.Entries(0, 0); len(got) != 0 { t.Fatalf("limit 0 returned %d", len(got)) } if got := b.Entries(0, -1); len(got) != 0 { t.Fatalf("negative limit returned %d", len(got)) } page := b.Entries(3, 2) if len(page) != 2 || page[0].ID != 3 || page[1].ID != 4 { t.Fatalf("Entries(3,2) = %v", page) } tail := b.Entries(5, 10) if len(tail) != 2 || tail[0].ID != 5 { t.Fatalf("Entries(5,10) returned %d entries", len(tail)) } if got := b.Entries(99, 5); len(got) != 0 { t.Fatalf("Entries past the end returned %d", len(got)) } } func TestIDKeyPadsAndOrders(t *testing.T) { if got := idKey(1); got != "00000000000000000001" { t.Fatalf("idKey(1) = %q", got) } if len(idKey(1)) != len(idKey(1234567)) { t.Fatal("keys must be fixed width") } if !(idKey(2) < idKey(10)) { t.Fatal("lexical order must match numeric order") } // The 20-digit claim: math.MaxUint64 is exactly 20 digits, so it is the // one input that uses the full width with no padding. max := idKey(18446744073709551615) if len(max) != 20 || max != "18446744073709551615" { t.Fatalf("idKey(maxUint64) = %q", max) } } func TestEntryTotalCountsDebitsOnly(t *testing.T) { e := Entry{Postings: []Posting{ {Account: cash, Side: Debit, Amount: 30}, {Account: ar, Side: Debit, Amount: 70}, {Account: revenue, Side: Credit, Amount: 100}, }} if e.Total() != 100 { t.Fatalf("Total = %d, want 100", e.Total()) } if (Entry{}).Total() != 0 { t.Fatal("empty entry Total must be 0") } } func TestAccountBalanceSignByKind(t *testing.T) { // A contra position is a real signal, not a sign convention: an asset // with more credits than debits reports NEGATIVE. a := Account{Kind: Asset, Debits: 10, Credits: 40} if a.Balance() != -30 || a.RawDelta() != -30 { t.Fatalf("contra asset = %d/%d, want -30/-30", a.Balance(), a.RawDelta()) } l := Account{Kind: Liability, Debits: 10, Credits: 40} if l.Balance() != 30 || l.RawDelta() != -30 { t.Fatalf("liability = %d/%d, want 30/-30", l.Balance(), l.RawDelta()) } // An invalid kind reports false from IsDebitNormal, so Balance reads it // as credit-normal. Asserted rather than left implicit, because a book // can never hold one -- Open refuses it. bad := Account{Kind: Kind(0), Debits: 10, Credits: 40} if bad.Balance() != 30 { t.Fatalf("invalid kind Balance = %d, want 30 (credit-normal fallback)", bad.Balance()) } }
  8. #8/gno.MemPackageType
  9. #9 MPUserAll

Result log

msg:0,success:true,log:,events:[]