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.

statements.gno

17.93 Kb · 452 lines
  1// Package statements turns a chart of accounts into classified financial
  2// statements, where the section, the sign and every total are functions of
  3// each account's kind and none of them is supplied by the caller.
  4//
  5// A realm holds the chart — [p/journal] keeps one, and a realm wrapping it
  6// publishes one — and asks this package what the statements say. Nothing here
  7// reads the chain, holds state, or decides what an account is: it takes
  8// [journal.Account] rows and returns figures.
  9//
 10//	rows := []journal.Account{ /* ascending by Code */ }
 11//	st, err := Derive(rows)
 12//	// st.Assets, st.Claims, st.CumulativeNet, st.Abnormal
 13//
 14// # The three-bucket partition
 15//
 16// [journal] partitions the five kinds in two, by normal balance. A statement
 17// partitions them in three, by where the figure is presented:
 18//
 19//	SectionAssets      : {Asset}                 balance sheet, debit side
 20//	SectionClaims      : {Liability, Equity}     balance sheet, credit side
 21//	SectionPerformance : {Revenue, Expense}      income statement
 22//
 23// [SectionOf] is the whole mapping and it is fail-closed: a Kind outside 1..5
 24// is [ErrInvalidKind], never a bucket. The zero Kind matters here because
 25// [journal.Kind.IsDebitNormal] reports it as credit-normal, so a silent
 26// fallthrough would not drop the row — it would file it under claims and
 27// produce a statement that balances and is wrong.
 28//
 29// # There is no Balances method, and the reason is the product's own arithmetic
 30//
 31// Write A, L, Q, R and X for the natural-sign totals of the five kinds, each
 32// the sum of [journal.Account.Balance] over its kind, and NI = R - X. For any
 33// single account, Balance() is RawDelta() for a debit-normal kind and its
 34// negation otherwise, so
 35//
 36//	sum over every account of RawDelta  =  A - L - Q - R + X
 37//
 38// and in a balanced book that sum is totalDebits - totalCredits = 0. Hence
 39//
 40//	A = L + Q + NI
 41//
 42// which is the balance sheet identity. The consequence is uncomfortable and is
 43// stated here rather than discovered by a reader: **over a chart drawn from a
 44// [journal.Book] the identity is a constant true**, algebraically identical to
 45// `totalDebits == totalCredits`, which the Book enforces on every Post. It is
 46// true of a chart whose every account is classified backwards. A `Balances()`
 47// returning that comparison would be a check that cannot fail, which this
 48// project has shipped before and does not ship again — see DISCOVERY.md § 4.
 49//
 50// The identity is NOT vacuous over this package's own input domain, because
 51// rows may come from anywhere and a hand-built set need not sum to zero. That
 52// scope is the whole content of the claim, and dropping a scope is the
 53// documentation defect this project has recorded most often. So:
 54//
 55//   - [Statement.Assets] and [Statement.Claims] are both reported, as figures.
 56//   - Their equality is asserted in the tests, in both directions: held over a
 57//     Book-derived chart, and shown to survive a chart miscoded on purpose,
 58//     which is what makes it vacuous there rather than reassuring.
 59//   - Nothing in the API offers that comparison as a verdict.
 60//
 61// # What ships instead: the abnormality census
 62//
 63// [journal.Account.Balance] is natural-sign and journal's own doc says a
 64// negative result is "a real signal (a contra position)". What did not exist
 65// is the roll-up: finding an abnormal account meant already knowing to look at
 66// it. [Statement.Abnormal] lists every row whose balance runs against its
 67// kind, and [Statement.AbnormalIn] counts them per section. It survives a
 68// balanced book, which the identity does not.
 69//
 70// It is the only partial answer in this portfolio to journal's obligation 7 —
 71// "a Kind set wrongly at Open time is undetectable here and undetectable
 72// anywhere else in this package" — and it is sound but incomplete, with the
 73// incompleteness exactly characterisable:
 74//
 75//   - It CATCHES a misclassification that crosses the debit/credit-normal
 76//     boundary and whose account has a non-zero balance. A payables account
 77//     coded as an asset accumulates credits and reads negative.
 78//   - It CANNOT CATCH a misclassification that stays on the same side:
 79//     Liability <-> Equity <-> Revenue, or Asset <-> Expense. Both subtotals
 80//     move, no balance goes negative, and nothing anywhere detects it.
 81//     Obligation 7 is narrowed, not closed.
 82//   - It has a FALSE-POSITIVE MODE that is not a defect: a genuine contra
 83//     account — accumulated depreciation under assets, a sales return under
 84//     revenue — is legitimately abnormal-signed. The census is a finding to
 85//     read, never a verdict.
 86//
 87// # The income statement is cumulative and can be nothing else
 88//
 89// [journal.Book.Close] is a backdating seal: it refuses entries dated at or
 90// before a cutoff and does not sweep nominal accounts into equity. There is no
 91// retained-earnings account and no closing entry, so revenue and expense
 92// accumulate from the book's first posting forever. The field names carry the
 93// word — [Statement.CumulativeRevenue], [Statement.CumulativeExpenses],
 94// [Statement.CumulativeNet] — because a reader who takes them for a period
 95// figure misreads every statement this package produces.
 96//
 97// [Statement.Claims] therefore includes CumulativeNet as an explicit
 98// reconciling line. A reader who checks Liabilities + Equity against Assets
 99// and finds it short by exactly CumulativeNet is not looking at a bug. A
100// period income statement needs entry-level data and is out of scope; so is a
101// cash-flow statement, which needs a cash-vs-accrual classification
102// [journal.Posting] does not carry.
103//
104// # What this package does not claim
105//
106//   - **Nothing about money.** A statement is a report about a book, and a book
107//     is a set of claims by its owner with no custody behind it. This package
108//     never sees a coin. A realm publishing its output must say so loudly: a
109//     published statement adds apparent authority to a self-report, and that
110//     is the one way this product can mislead someone.
111//   - **No audit.** Every figure here is derived from rows the caller supplied.
112//     Deriving them faithfully is the guarantee; that they describe anything
113//     real is not.
114//   - **Two statements and a census**, not a statement set. No statement of
115//     changes in equity, no notes, no comparatives.
116//
117// [p/journal]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal
118package statements
119
120import (
121	"errors"
122
123	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
124)
125
126// MaxRows bounds the chart [Derive] will classify, and so bounds its only
127// loop. It is journal's own account cap, because that is the largest chart
128// this ecosystem's bookkeeping primitive can produce, and a row set larger
129// than that did not come from a chart.
130//
131// What the bound costs, stated rather than waved at: Derive is one pass with
132// a checked add and a comparison per row, so a chart at the limit is 10,000
133// iterations in one call. A realm that classifies several charts in one
134// transaction, or renders them, multiplies that. The realm wrapping this
135// package will have a much smaller cap of its own — r/books allows 128 — and
136// should: this bound is the ceiling a caller may ask for, not a size worth
137// reaching.
138const MaxRows = journal.MaxAccountsLimit
139
140// Errors returned by this package.
141var (
142	// ErrInvalidKind is returned for a row whose Kind is outside journal's
143	// five. It is fail-closed by choice: the alternative is a row filed
144	// under claims by [journal.Kind.IsDebitNormal]'s treatment of the zero
145	// value, which yields a statement that balances and misstates.
146	ErrInvalidKind = errors.New("statements: unknown account kind")
147
148	// ErrRowsNotOrdered is returned when rows are not in strictly ascending
149	// Code order. The precondition does three jobs at once and that is why
150	// it is a precondition rather than an internal sort: it rules out a
151	// duplicate code silently double-counting a balance, it keeps
152	// [Statement.Abnormal] in a deterministic order a Render can rely on,
153	// and it costs one string comparison per row instead of an allocation
154	// and a sort.
155	//
156	// A row with an empty Code fails this check, since no string sorts
157	// before "". journal refuses an empty code at Open, so a Book-derived
158	// chart cannot contain one.
159	//
160	// Every producer in this portfolio already satisfies it:
161	// [journal.Book.IterateAccounts] walks an avl tree keyed by code, and
162	// r/books' Codes pages that same order.
163	ErrRowsNotOrdered = errors.New("statements: rows must be in strictly ascending code order")
164
165	// ErrTooManyRows is returned when len(rows) exceeds MaxRows.
166	ErrTooManyRows = errors.New("statements: row count exceeds MaxRows")
167
168	// ErrNegativeTotals is returned for a row whose Debits or Credits column
169	// is negative. Such a row cannot have come from a [journal.Book]:
170	// postings carry positive amounts only and an account's two columns are
171	// monotone non-decreasing.
172	//
173	// It is checked for a reason beyond tidiness, and the reason is what
174	// makes this package's overflow story complete.
175	// [journal.Account.Balance] subtracts one column from the other with no
176	// check of its own, so a row carrying Debits near max and a NEGATIVE
177	// Credits yields a WRAPPED balance before any arithmetic here runs — and
178	// no amount of checked adding downstream can recover a figure that was
179	// already wrong on arrival. With both columns non-negative that
180	// subtraction lands in [-maxInt64, maxInt64] and cannot wrap, which is
181	// the precondition the checked adds in [Derive] are sufficient under.
182	ErrNegativeTotals = errors.New("statements: account column totals must not be negative")
183
184	// ErrOverflow is returned when a section total, the cumulative net, or
185	// the claims subtotal would wrap int64. Nothing is reported partially:
186	// an overflowing statement is an error, not a wrapped number, because a
187	// wrapped total is a figure a reader cannot tell from a real one.
188	ErrOverflow = errors.New("statements: int64 overflow")
189
190	// ErrInvalidSection is returned by [Statement.SectionTotal] for a
191	// section outside the declared three.
192	ErrInvalidSection = errors.New("statements: unknown section")
193)
194
195// Section is where an account's figure is presented. The zero value is
196// deliberately invalid, so a forgotten field is an error rather than assets.
197//
198// The constants carry a Section prefix on purpose: [Statement] has fields
199// named Assets and Claims, and bare constants of the same words would read as
200// the same thing in two namespaces.
201type Section uint8
202
203// The three statement sections.
204const (
205	SectionAssets      Section = 1 // balance sheet, debit side
206	SectionClaims      Section = 2 // balance sheet, credit side
207	SectionPerformance Section = 3 // income statement
208)
209
210// Valid reports whether s is one of the three sections.
211func (s Section) Valid() bool { return s >= SectionAssets && s <= SectionPerformance }
212
213// String returns the section's name, or "invalid" for the zero value and any
214// unrecognised code.
215func (s Section) String() string {
216	switch s {
217	case SectionAssets:
218		return "assets"
219	case SectionClaims:
220		return "claims"
221	case SectionPerformance:
222		return "performance"
223	}
224	return "invalid"
225}
226
227// SectionOf maps an account kind to the section it presents in. An invalid
228// kind is [ErrInvalidKind]; there is no default bucket.
229//
230// This is the only place in the package that knows the partition, and it is
231// exported so a consumer grouping rows for presentation uses the same mapping
232// the totals were built from rather than a second copy of it.
233func SectionOf(k journal.Kind) (Section, error) {
234	switch k {
235	case journal.Asset:
236		return SectionAssets, nil
237	case journal.Liability, journal.Equity:
238		return SectionClaims, nil
239	case journal.Revenue, journal.Expense:
240		return SectionPerformance, nil
241	}
242	return 0, ErrInvalidKind
243}
244
245// Abnormal is one account whose balance runs against its kind's normal
246// direction. Balance is in the account's normal direction and is therefore
247// always negative here.
248//
249// [Derive] is its only producer in this package.
250type Abnormal struct {
251	Code    string
252	Name    string
253	Kind    journal.Kind
254	Balance int64
255}
256
257// Statement is the classified figures for one chart at one instant.
258//
259// Every int64 field is a natural-sign total: a normal chart has them all
260// non-negative. They are values, not pointers, so a holder cannot reach back
261// into anything — except through Abnormal, which is a slice and is freshly
262// allocated by [Derive] on every call, so the caller owns it.
263//
264// Assets and Claims are both reported and their equality is deliberately not
265// offered as a method; see the package doc on why that comparison cannot fail
266// over a Book-derived chart.
267type Statement struct {
268	// Rows is how many accounts were classified. Zero is a legitimate
269	// statement of nothing, not an error.
270	Rows int
271
272	// Assets is the balance-sheet debit side.
273	Assets int64
274
275	// Liabilities and Equity are the two credit-side components, reported
276	// separately because a reader needs them apart and Claims does not
277	// recover them.
278	Liabilities int64
279	Equity      int64
280
281	// CumulativeRevenue and CumulativeExpenses accumulate from the book's
282	// first posting, not over a period. See the package doc.
283	CumulativeRevenue  int64
284	CumulativeExpenses int64
285
286	// CumulativeNet is CumulativeRevenue - CumulativeExpenses. It appears on
287	// the balance sheet as a reconciling line because journal closes no
288	// nominal account into equity.
289	CumulativeNet int64
290
291	// Claims is Liabilities + Equity + CumulativeNet: the credit side of the
292	// balance sheet as presented.
293	Claims int64
294
295	// Abnormal lists every row whose balance runs against its kind, in the
296	// row order Derive received, which ErrRowsNotOrdered forces to be
297	// ascending by code. It is never nil for a Statement from Derive.
298	Abnormal []Abnormal
299}
300
301// AbnormalIn counts the abnormal rows presented in sec.
302//
303// It recomputes each row's section from its Kind through [SectionOf] rather
304// than storing one, so there is one definition of the partition and no
305// second field that could disagree with it. A Statement built by hand can
306// hold a row with an invalid Kind, and such a row is counted in no section;
307// Derive cannot produce one.
308func (s Statement) AbnormalIn(sec Section) int {
309	n := 0
310	for _, a := range s.Abnormal {
311		got, err := SectionOf(a.Kind)
312		if err != nil {
313			continue
314		}
315		if got == sec {
316			n++
317		}
318	}
319	return n
320}
321
322// SectionTotal returns the figure presented for sec: Assets, Claims, or
323// CumulativeNet. A section outside the three is [ErrInvalidSection].
324//
325// Note what the performance total is: the income statement's bottom line, not
326// the sum of its two components. Revenue and expenses are read from their own
327// fields.
328func (s Statement) SectionTotal(sec Section) (int64, error) {
329	switch sec {
330	case SectionAssets:
331		return s.Assets, nil
332	case SectionClaims:
333		return s.Claims, nil
334	case SectionPerformance:
335		return s.CumulativeNet, nil
336	}
337	return 0, ErrInvalidSection
338}
339
340// Derive classifies rows and returns the statement figures.
341//
342// rows must be in strictly ascending Code order ([ErrRowsNotOrdered]) and no
343// longer than [MaxRows]. Every Kind must be one of journal's five
344// ([ErrInvalidKind]) and neither column may be negative
345// ([ErrNegativeTotals], which is what makes the checked adds here
346// sufficient). Every accumulation is checked ([ErrOverflow]).
347//
348// It is a pure function of rows: the same rows always give the same Statement,
349// and nothing about the caller, the chain or the time enters it. On any error
350// the returned Statement is the zero value, so a caller that ignores the error
351// reads zeros rather than a half-summed chart.
352func Derive(rows []journal.Account) (Statement, error) {
353	if len(rows) > MaxRows {
354		return Statement{}, ErrTooManyRows
355	}
356
357	st := Statement{Abnormal: []Abnormal{}}
358	prev := ""
359	for _, a := range rows {
360		if a.Code <= prev {
361			return Statement{}, ErrRowsNotOrdered
362		}
363		prev = a.Code
364
365		if !a.Kind.Valid() {
366			return Statement{}, ErrInvalidKind
367		}
368		if a.Debits < 0 || a.Credits < 0 {
369			return Statement{}, ErrNegativeTotals
370		}
371
372		bal := a.Balance()
373		ok := true
374		switch a.Kind {
375		case journal.Asset:
376			st.Assets, ok = checkedAdd(st.Assets, bal)
377		case journal.Liability:
378			st.Liabilities, ok = checkedAdd(st.Liabilities, bal)
379		case journal.Equity:
380			st.Equity, ok = checkedAdd(st.Equity, bal)
381		case journal.Revenue:
382			st.CumulativeRevenue, ok = checkedAdd(st.CumulativeRevenue, bal)
383		case journal.Expense:
384			st.CumulativeExpenses, ok = checkedAdd(st.CumulativeExpenses, bal)
385		}
386		if !ok {
387			return Statement{}, ErrOverflow
388		}
389
390		if bal < 0 {
391			st.Abnormal = append(st.Abnormal, Abnormal{
392				Code:    a.Code,
393				Name:    a.Name,
394				Kind:    a.Kind,
395				Balance: bal,
396			})
397		}
398		st.Rows++
399	}
400
401	net, ok := checkedSub(st.CumulativeRevenue, st.CumulativeExpenses)
402	if !ok {
403		return Statement{}, ErrOverflow
404	}
405	st.CumulativeNet = net
406
407	claims, ok := checkedAdd(st.Liabilities, st.Equity)
408	if !ok {
409		return Statement{}, ErrOverflow
410	}
411	claims, ok = checkedAdd(claims, net)
412	if !ok {
413		return Statement{}, ErrOverflow
414	}
415	st.Claims = claims
416
417	return st, nil
418}
419
420// MustDerive is [Derive] and panics instead of returning an error. It is for
421// a realm's mutating path, where an error return does not revert the
422// transaction and only a panic does.
423func MustDerive(rows []journal.Account) Statement {
424	st, err := Derive(rows)
425	if err != nil {
426		panic(err)
427	}
428	return st
429}
430
431// checkedAdd returns a+b and reports whether the addition did not overflow
432// int64. journal's copy is unexported, so this is a deliberate three-line
433// duplicate of a trivial helper — a different thing from the second Kind enum
434// DISCOVERY.md § 3 refused, which would have duplicated a domain decision.
435func checkedAdd(a, b int64) (int64, bool) {
436	sum := a + b
437	if (b > 0 && sum < a) || (b < 0 && sum > a) {
438		return 0, false
439	}
440	return sum, true
441}
442
443// checkedSub returns a-b and reports whether the subtraction did not overflow
444// int64. It is not checkedAdd(a, -b): negating math.MinInt64 overflows on its
445// own, so that form would wrap before the check ran.
446func checkedSub(a, b int64) (int64, bool) {
447	diff := a - b
448	if (b < 0 && diff < a) || (b > 0 && diff > a) {
449		return 0, false
450	}
451	return diff, true
452}