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}