periods.gno
31.26 Kb · 769 lines
1// Package periods turns a cumulative double-entry book into a period report:
2// per-account movement over a date window, the period income statement, and
3// the closing entry that sweeps nominal accounts into equity.
4//
5// [p/journal] is cumulative by construction and says so. Its Close is a
6// backdating seal — it refuses entries dated at or before a cutoff and does
7// not sweep anything — so revenue and expense accumulate from the book's first
8// posting forever, and [p/statements] names its three figures
9// CumulativeRevenue, CumulativeExpenses and CumulativeNet for that reason. Its
10// package doc states the gap in one sentence: "A period income statement needs
11// entry-level data and is out of scope." This package is that entry-level
12// consumer. It reads no chain, holds no state, and writes nothing.
13//
14// w := Window{From: q1Start, Until: q2Start}
15// rep, err := Derive(chart, entries, w)
16// // rep.Activity, rep.PeriodNet, rep.CrossWindowReversals
17// post, err := rep.ClosingEntry("3100") // a PROPOSAL, not a write
18//
19// # Why the chart is a parameter
20//
21// [journal.Posting] is {Account string, Side, Amount int64}: it carries the
22// account's CODE and never its Kind. So revenue cannot be told from expense
23// from postings alone, and no income statement is derivable without the chart.
24// That single fact fixes this package's signature. Only Code, Name and Kind
25// are read; Debits and Credits are the book's CUMULATIVE columns and are
26// deliberately neither read nor validated here, because period movement comes
27// from the entries.
28//
29// # The window is closed-open
30//
31// [Window] is [From, Until): From is included, Until is not. Consecutive
32// periods therefore TILE — [Window.Tiles] is the predicate — with no overlap
33// and no gap, which is what makes a sequence of period reports add up to the
34// cumulative figures. A closed-closed window double-counts exactly one
35// timestamp per boundary, and that error is invisible in any single report.
36//
37// # Two inputs, two ordering decisions, opposite answers
38//
39// This package imposes an ordering precondition on one input and refuses one
40// on the other, and the rule separating them is what journal GUARANTEES:
41//
42// - chart must be strictly ascending by Code ([ErrChartNotOrdered]).
43// [journal.Book.IterateAccounts] is "ascending code order", so every
44// producer meets it. It buys duplicate-code detection, chart-order output
45// with no sort, and a binary-search code lookup instead of a map.
46// - entries must be strictly ascending by ID ([ErrEntriesNotOrdered]).
47// [journal.Book.IterateEntries] and [journal.Book.Entries] both guarantee
48// it. It buys duplicate-ID detection, the binary search that resolves
49// ReversalOf, and ID >= 1 for free, 0 being ReversalOf's sentinel.
50// - entries must NOT be required in ascending At order, and this package
51// must never require it. At is caller-supplied and backdating is legal
52// until Close seals it, so journal guarantees nothing about it. Such a
53// precondition would be one no producer in this portfolio can meet — a
54// gate that can never pass, in an API signature rather than in a checker.
55//
56// # The reversal census has three buckets, and the third is the point
57//
58// A reversal dated inside the window whose original is dated outside it
59// changes what the period means: the window carries a correction to activity
60// it never contained. The census reports three things and never two:
61//
62// - [Report.Reversals] — in-window reversal, original also in-window:
63// ordinary, classified.
64// - [Report.CrossWindowReversals] — in-window reversal, original dated
65// outside: classified, and material.
66// - [Report.UnresolvedReversals] — in-window reversal whose original is
67// ABSENT from the entries slice, so the classification COULD NOT BE MADE.
68//
69// Collapsing the third into either of the first two would read "could not
70// evaluate" as assent. It is a census, not a verdict — the same posture as
71// [statements.Statement.Abnormal] — and it is a function of what the caller
72// passed, not of the book: pass a window-filtered slice and every reversal
73// reaching back before it is unresolved by construction.
74//
75// # What this package does not do
76//
77// - **It never writes.** [Report.ClosingEntry] returns postings for the
78// caller to Post. It sweeps PERIOD MOVEMENT, not cumulative balance, so
79// running it twice over one window and posting both results
80// double-sweeps — and this package never sees the Book, so it cannot
81// detect that. The caller owns idempotence.
82// - **No paging, no Merge.** [MaxEntries] is the hard bound. Merging reports
83// across pages would make UnresolvedReversals an artifact of the paging
84// rather than of the data; fixing that needs a two-pass design over the
85// whole entry set and is a different package.
86// - **No cash-flow statement.** The indirect method needs a
87// cash-vs-accrual classification [journal.Posting] does not carry.
88// [Report.Activity] is the balance-sheet delta half it would need.
89// - **No audit.** Every figure is derived from what the caller supplied. A
90// chart from one book and entries from another produce a plausible report
91// and nothing here detects it.
92//
93// [p/journal]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal
94// [p/statements]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements
95package periods
96
97import (
98 "errors"
99
100 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
101 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements"
102)
103
104// MaxAccounts bounds the chart [Derive] will accept. It is
105// [statements.MaxRows] — journal's own account cap, reached through the
106// package that already made this decision rather than restated — because a
107// row set larger than the largest chart this ecosystem's bookkeeping
108// primitive can hold did not come from a chart.
109const MaxAccounts = statements.MaxRows
110
111// MaxEntries bounds the entry slice [Derive] will walk, and it is set far
112// below [journal.MaxEntriesLimit] on purpose: the journal holds a million
113// entries and a million-entry loop is not a transaction.
114//
115// What the bound costs, stated rather than waved at. Derive makes one pass
116// over the entries and, per entry, one pass over its postings, each posting
117// costing a binary search over the chart. [journal.MaxPostingsPerEntry] is 64
118// and is re-checked here ([ErrTooManyPostings]) so the product is a PROVEN
119// ceiling and not an assumed one:
120//
121// 4096 entries x 64 postings = 262 144 posting visits, each a
122// log2(10 000) ~ 14-step search
123//
124// which is the same order as the 10 000-row walk [statements.Derive]
125// documents. A realm wanting period reports caps its own journal at or below
126// this; r/books allows 128 accounts and should be read the same way — this is
127// the ceiling a caller may ask for, not a size worth reaching.
128const MaxEntries = 4096
129
130// Errors returned by this package.
131var (
132 // ErrInvalidWindow is a window failing [Window.Valid]: From negative, or
133 // Until not strictly after From. An empty window is refused rather than
134 // reported as a period with no activity, because the two are different
135 // claims and only one of them is a report.
136 ErrInvalidWindow = errors.New("periods: window must satisfy 0 <= From < Until")
137
138 // ErrTooManyAccounts is a chart longer than [MaxAccounts].
139 ErrTooManyAccounts = errors.New("periods: chart length exceeds MaxAccounts")
140
141 // ErrTooManyEntries is an entry slice longer than [MaxEntries].
142 ErrTooManyEntries = errors.New("periods: entry count exceeds MaxEntries")
143
144 // ErrChartNotOrdered is a chart not in strictly ascending Code order.
145 // journal's own enumerator is, so this also catches a duplicated code.
146 ErrChartNotOrdered = errors.New("periods: chart must be in strictly ascending code order")
147
148 // ErrEntriesNotOrdered is an entry slice not in strictly ascending ID
149 // order, which includes an ID of zero. Both of journal's producers are
150 // ordered; see the package doc on why At carries no such requirement.
151 ErrEntriesNotOrdered = errors.New("periods: entries must be in strictly ascending ID order")
152
153 // ErrInvalidKind is an account whose Kind is outside journal's five. The
154 // zero Kind is the case that matters: journal reports it as
155 // credit-normal, so a silent fallthrough would file it rather than refuse
156 // it.
157 //
158 // The two producers are not symmetrical, and saying so is the point.
159 // [Derive] raises it from the CHART PRE-PASS, which proves every Kind
160 // [journal.Kind.Valid] before anything is summed; its later
161 // [statements.SectionOf] branch is therefore UNREACHABLE, because
162 // SectionOf's switch covers exactly the five kinds Valid admits. That
163 // branch ships anyway, as fail-closed defence against the two definitions
164 // drifting apart in a later version of either package — but it is not a
165 // path this package can reach today and must not be cited as one.
166 // [Report.ClosingEntry] is the reachable SectionOf case: it is handed a
167 // Report rather than a chart, runs no pre-pass, and so a hand-built row
168 // with an invalid Kind reaches SectionOf directly.
169 ErrInvalidKind = errors.New("periods: unknown account kind")
170
171 // ErrInvalidPosting is a posting with an invalid Side or a non-positive
172 // Amount. journal cannot produce one; a hand-built entry can.
173 ErrInvalidPosting = errors.New("periods: posting needs a valid side and a positive amount")
174
175 // ErrTooManyPostings is an entry with more than
176 // [journal.MaxPostingsPerEntry] postings. journal cannot produce one, and
177 // re-checking is what makes [MaxEntries]'s cost bound a proven ceiling.
178 ErrTooManyPostings = errors.New("periods: entry exceeds journal.MaxPostingsPerEntry")
179
180 // ErrUnknownAccount is a posting naming a code absent from the chart.
181 // Dropping it silently would UNDERSTATE the period, which is worse than
182 // refusing to report it.
183 ErrUnknownAccount = errors.New("periods: posting names a code absent from the chart")
184
185 // ErrNegativeColumns is an [Activity] row whose Debits or Credits is
186 // negative. [Derive] cannot produce one — it accumulates positive posting
187 // amounts from zero — so this is only reachable through a hand-built
188 // Report, and it is checked for the reason [statements.ErrNegativeTotals]
189 // is checked over the identical hazard on the identical type.
190 //
191 // [Activity.Movement] delegates to [journal.Account.Balance], which
192 // subtracts one column from the other with no check of its own. A row
193 // carrying Credits near max and a NEGATIVE Debits therefore yields a
194 // WRAPPED movement before any arithmetic here runs, and the wrap survives
195 // every downstream guard: it is nonzero, so the m == 0 skip does not
196 // fire, and it is not minInt64, so magnitude reports success. The result
197 // was a closing posting with an INVERTED SIGN, balanced, both sides
198 // present, within the posting cap, and acceptable to [journal.Book.Post]
199 // — a wrong figure with no error anywhere, which is strictly worse than
200 // [ErrOverflow] because nothing downstream can tell it from a real one.
201 // With both columns non-negative the subtraction lands in
202 // [-maxInt64, maxInt64] and cannot wrap, which is the precondition the
203 // checked arithmetic in [Report.ClosingEntry] is sufficient under.
204 ErrNegativeColumns = errors.New("periods: activity column totals must not be negative")
205
206 // ErrInvalidEquityCode is an empty equity code, or one longer than
207 // [journal.MaxCodeLen].
208 ErrInvalidEquityCode = errors.New("periods: equity code must be non-empty and within journal.MaxCodeLen")
209
210 // ErrEquityCodeIsNominal is an equity code naming one of the nominal
211 // accounts being swept. The resulting entry would balance and mean
212 // nothing. See [Report.ClosingEntry] on what this check cannot cover.
213 ErrEquityCodeIsNominal = errors.New("periods: equity code names a nominal account being closed")
214
215 // ErrNothingToClose is a window with no nominal movement at all. The
216 // alternative is returning an empty posting slice that journal would
217 // refuse with ErrTooFewPostings, which moves the failure one call later
218 // where it is harder to read.
219 ErrNothingToClose = errors.New("periods: no nominal movement in the window")
220
221 // ErrTooManyNominalAccounts is a sweep that would exceed
222 // [journal.MaxPostingsPerEntry]: at most 63 nominal accounts plus the
223 // equity posting.
224 ErrTooManyNominalAccounts = errors.New("periods: closing entry exceeds journal.MaxPostingsPerEntry")
225
226 // ErrOverflow is an int64 overflow in any accumulation.
227 ErrOverflow = errors.New("periods: int64 overflow")
228)
229
230// Window is a half-open date range [From, Until): From is included, Until is
231// not. The unit is whatever the book's At values are — journal treats At as an
232// opaque non-negative int64, so a caller holding seconds passes seconds and a
233// caller holding block heights passes heights. This package imposes no
234// calendar, which is why it needs none.
235type Window struct {
236 From int64
237 Until int64
238}
239
240// Valid reports whether w is a usable window: From non-negative and Until
241// strictly after it. An empty or inverted window is not a period.
242func (w Window) Valid() bool { return w.From >= 0 && w.From < w.Until }
243
244// Contains reports whether at falls in w. Half-open: From is in, Until is out.
245func (w Window) Contains(at int64) bool { return at >= w.From && at < w.Until }
246
247// Tiles reports whether next begins exactly where w ends, so that the two
248// cover [w.From, next.Until) with no overlap and no gap.
249//
250// It exists as API rather than as a comment because the tiling property is
251// what makes a sequence of period reports sum to the cumulative figures, and a
252// caller generating a sequence should be able to assert it.
253func (w Window) Tiles(next Window) bool {
254 return w.Valid() && next.Valid() && w.Until == next.From
255}
256
257// Activity is one account's movement WITHIN the window. Debits and Credits are
258// period sums, not the book's cumulative columns, and both are non-negative AS
259// PRODUCED BY [Derive] — postings carry positive amounts and Derive refuses
260// anything else.
261//
262// A Report assembled by hand is under no such constraint, so
263// [Report.ClosingEntry] checks it: a negative column there is
264// [ErrNegativeColumns]. The check is not tidiness. [Movement] delegates to
265// [journal.Account.Balance], an unchecked subtraction, so a negative column
266// wraps the movement BEFORE any guard here runs and the wrap passes all of
267// them — the earlier version of this comment claimed a hand-built Report's
268// only consequence was [ErrOverflow], and an audit disproved it by producing a
269// sign-inverted closing entry that [journal.Book.Post] accepts with no error
270// at all. See [ErrNegativeColumns] for the mechanism.
271//
272// [Derive] emits a row only for a code the window actually touched: an account
273// with no movement is ABSENT, not a zero row. Over a chart of 10 000 with
274// three active accounts that is three rows, and the alternative is paying
275// statements' full MaxRows cost to report nothing.
276type Activity struct {
277 Code string
278 Name string
279 Kind journal.Kind
280 Debits int64
281 Credits int64
282}
283
284// Movement returns the period movement in the account's NORMAL direction, so
285// a revenue account that earned reads positive and a contra movement reads
286// negative.
287//
288// It is journal's own definition, reached by handing journal the two columns
289// rather than re-deriving the normal-balance rule here. One definition of
290// which kinds are debit-normal, in the package that owns it.
291//
292// Inheriting that definition means inheriting its arithmetic: Balance
293// subtracts without a check, so on a row with a NEGATIVE column — which only a
294// hand-built Report can hold — this wraps and returns a plausible wrong
295// number. It is left unchecked rather than given an error return, because a
296// two-value Movement would have to be handled at four call sites to defend one
297// input class that [Report.ClosingEntry] refuses outright. The guard is at the
298// boundary; see [ErrNegativeColumns].
299func (a Activity) Movement() int64 {
300 return journal.Account{Kind: a.Kind, Debits: a.Debits, Credits: a.Credits}.Balance()
301}
302
303// RawDelta returns Debits-Credits regardless of kind. Summed over every row of
304// a report derived from balanced entries it is exactly zero, which is what
305// makes it useful for a consistency check and wrong for a report.
306func (a Activity) RawDelta() int64 { return a.Debits - a.Credits }
307
308// CrossReversal is an in-window reversal whose original entry is dated outside
309// the window: the period carries a correction to activity it never contained.
310//
311// Total is the reversal's debit total ([journal.Entry.Total]), which is its
312// magnitude on both sides because journal only accepts balanced entries.
313type CrossReversal struct {
314 ID uint64
315 ReversalOf uint64
316 Total int64
317}
318
319// Report is one window's activity. Every slice is freshly allocated by
320// [Derive] on every call and is never nil, so the caller owns it and an empty
321// result is an empty slice rather than a nil to be distinguished from one.
322type Report struct {
323 // Window is the window the figures are for, echoed back so a stored
324 // report cannot be read without it.
325 Window Window
326
327 // Activity is per-account period movement, in CHART order — which the
328 // chart precondition makes ascending by Code — for codes the window
329 // touched.
330 Activity []Activity
331
332 // Entries is how many entries fell in the window. Zero is a legitimate
333 // report of a quiet period, not an error.
334 Entries int
335
336 // FirstID and LastID are the lowest and highest IDs IN THE WINDOW, or
337 // both zero when Entries is zero. They are min and max over the
338 // in-window SUBSET and not the ends of the supplied slice: over entries
339 // 1, 2, 3 with only 2 in the window, both read 2.
340 FirstID uint64
341 LastID uint64
342
343 // PeriodRevenue and PeriodExpenses are the natural-sign period totals of
344 // the two nominal kinds. The names carry the word PERIOD for the same
345 // reason statements' carry CUMULATIVE: a reader who takes one for the
346 // other misreads every figure.
347 PeriodRevenue int64
348 PeriodExpenses int64
349
350 // PeriodNet is PeriodRevenue - PeriodExpenses: the period income
351 // statement's bottom line, which no cumulative report can produce.
352 PeriodNet int64
353
354 // Reversals counts in-window reversals whose original is also in-window.
355 Reversals int
356
357 // CrossWindowReversals lists in-window reversals whose original is dated
358 // outside the window, in the order the entries were walked.
359 CrossWindowReversals []CrossReversal
360
361 // UnresolvedReversals lists the IDs of in-window reversals whose original
362 // is absent from the supplied entries, so they could be classified
363 // neither way. See the package doc: this is the bucket that must not be
364 // collapsed.
365 UnresolvedReversals []uint64
366}
367
368// ActivityIn counts the rows presented in sec.
369//
370// The section is recomputed from each row's Kind through
371// [statements.SectionOf] rather than stored, so there is one definition of the
372// three-bucket partition and no second field that could disagree with it. A
373// Report built by hand can hold a row with an invalid Kind; such a row is
374// counted in no section, and [Derive] cannot produce one.
375//
376// It deliberately does NOT check [ErrNegativeColumns], unlike
377// [Report.ClosingEntry], and has no error return to do it with. The reason is
378// that it reads no column: the count depends on Kind alone, so a negative
379// column cannot make this answer wrong. A guard added here for symmetry would
380// be a check that can never change an outcome — the vacuous-true shape this
381// portfolio refuses elsewhere — and the honest version is this sentence.
382func (r Report) ActivityIn(sec statements.Section) int {
383 n := 0
384 for _, a := range r.Activity {
385 got, err := statements.SectionOf(a.Kind)
386 if err != nil {
387 continue
388 }
389 if got == sec {
390 n++
391 }
392 }
393 return n
394}
395
396// Derive computes the period report.
397//
398// It is a pure function of its three arguments: the same inputs always give
399// the same Report, and nothing about the caller, the chain or the time enters
400// it. On any error the returned Report is the zero value, so a caller that
401// ignores the error reads zeros rather than a half-summed period.
402//
403// Preconditions, each for a reason given in the package doc: w must be valid;
404// chart must be strictly ascending by Code and no longer than [MaxAccounts],
405// with every Kind one of journal's five; entries must be strictly ascending by
406// ID and no longer than [MaxEntries], with at most
407// [journal.MaxPostingsPerEntry] postings each.
408//
409// Entries outside w are skipped, not refused — a caller passing the whole book
410// is the expected case — and their postings are therefore NOT content-checked,
411// because no figure in the report depends on them. [ErrInvalidPosting] and
412// [ErrUnknownAccount] are scoped to the window for that reason; the
413// postings-per-entry cap is not, since it is what makes [MaxEntries]' cost
414// ceiling a bound rather than a hope.
415func Derive(chart []journal.Account, entries []journal.Entry, w Window) (Report, error) {
416 if !w.Valid() {
417 return Report{}, ErrInvalidWindow
418 }
419 if len(chart) > MaxAccounts {
420 return Report{}, ErrTooManyAccounts
421 }
422 if len(entries) > MaxEntries {
423 return Report{}, ErrTooManyEntries
424 }
425
426 // Both preconditions are established in full BEFORE any figure is
427 // computed, because the lookups below are binary searches and a search
428 // over a half-validated slice is only sound by argument. An empty first
429 // code fails the ascending test on its own, prev starting at "".
430 prevCode := ""
431 for _, a := range chart {
432 if a.Code <= prevCode {
433 return Report{}, ErrChartNotOrdered
434 }
435 prevCode = a.Code
436 if !a.Kind.Valid() {
437 return Report{}, ErrInvalidKind
438 }
439 }
440
441 var prevID uint64
442 for _, e := range entries {
443 if e.ID <= prevID {
444 return Report{}, ErrEntriesNotOrdered
445 }
446 prevID = e.ID
447 // Checked for every entry, in or out of window, because this is the
448 // bound MaxEntries' cost ceiling is stated against. The posting
449 // CONTENT checks below are scoped to the window instead, since they
450 // are about the figures rather than about the cost.
451 if len(e.Postings) > journal.MaxPostingsPerEntry {
452 return Report{}, ErrTooManyPostings
453 }
454 }
455
456 // Period columns, parallel to chart by index. A map would be the obvious
457 // structure and no /p/ in this portfolio uses one; the chart precondition
458 // makes a binary search enough.
459 debits := make([]int64, len(chart))
460 credits := make([]int64, len(chart))
461
462 rep := Report{
463 Window: w,
464 Activity: []Activity{},
465 CrossWindowReversals: []CrossReversal{},
466 UnresolvedReversals: []uint64{},
467 }
468
469 for _, e := range entries {
470 if !w.Contains(e.At) {
471 continue
472 }
473
474 for _, p := range e.Postings {
475 if !p.Side.Valid() || p.Amount <= 0 {
476 return Report{}, ErrInvalidPosting
477 }
478 at := indexOfCode(chart, p.Account)
479 if at < 0 {
480 return Report{}, ErrUnknownAccount
481 }
482 var ok bool
483 if p.Side == journal.Debit {
484 debits[at], ok = checkedAdd(debits[at], p.Amount)
485 } else {
486 credits[at], ok = checkedAdd(credits[at], p.Amount)
487 }
488 if !ok {
489 return Report{}, ErrOverflow
490 }
491 }
492
493 if rep.Entries == 0 || e.ID < rep.FirstID {
494 rep.FirstID = e.ID
495 }
496 if e.ID > rep.LastID {
497 rep.LastID = e.ID
498 }
499 rep.Entries++
500
501 if !e.IsReversal() {
502 continue
503 }
504 orig := indexOfID(entries, e.ReversalOf)
505 switch {
506 case orig < 0:
507 rep.UnresolvedReversals = append(rep.UnresolvedReversals, e.ID)
508 case w.Contains(entries[orig].At):
509 rep.Reversals++
510 default:
511 rep.CrossWindowReversals = append(rep.CrossWindowReversals, CrossReversal{
512 ID: e.ID,
513 ReversalOf: e.ReversalOf,
514 Total: e.Total(),
515 })
516 }
517 }
518
519 for i, a := range chart {
520 if debits[i] == 0 && credits[i] == 0 {
521 continue
522 }
523 row := Activity{
524 Code: a.Code,
525 Name: a.Name,
526 Kind: a.Kind,
527 Debits: debits[i],
528 Credits: credits[i],
529 }
530 rep.Activity = append(rep.Activity, row)
531
532 sec, err := statements.SectionOf(a.Kind)
533 if err != nil {
534 return Report{}, ErrInvalidKind
535 }
536 if sec != statements.SectionPerformance {
537 continue
538 }
539 var ok bool
540 if a.Kind == journal.Revenue {
541 rep.PeriodRevenue, ok = checkedAdd(rep.PeriodRevenue, row.Movement())
542 } else {
543 rep.PeriodExpenses, ok = checkedAdd(rep.PeriodExpenses, row.Movement())
544 }
545 if !ok {
546 return Report{}, ErrOverflow
547 }
548 }
549
550 net, ok := checkedSub(rep.PeriodRevenue, rep.PeriodExpenses)
551 if !ok {
552 return Report{}, ErrOverflow
553 }
554 rep.PeriodNet = net
555
556 return rep, nil
557}
558
559// MustDerive is [Derive] and panics instead of returning an error. It is for a
560// realm's mutating path, where an error return does not revert the transaction
561// and only a panic does.
562func MustDerive(chart []journal.Account, entries []journal.Entry, w Window) Report {
563 rep, err := Derive(chart, entries, w)
564 if err != nil {
565 panic(err)
566 }
567 return rep
568}
569
570// ClosingEntry returns the postings that zero every nominal account's PERIOD
571// MOVEMENT and carry the net to equityCode — the entry [journal.Book.Close]
572// deliberately does not make, Close being a backdating seal and nothing else.
573//
574// Nominal means [statements.SectionPerformance]: the partition is read through
575// [statements.SectionOf] rather than restated as a second list of kinds.
576//
577// The result is a PROPOSAL. This package holds no Book and posts nothing, and
578// three consequences follow that a caller must own:
579//
580// - It sweeps period movement, not cumulative balance. Posting it twice for
581// one window double-sweeps the period, and nothing here can detect that.
582// - A window the book has already sealed is still reportable — Close refuses
583// writes, not reads — so these postings may come back
584// [journal.ErrPeriodClosed]. That is journal's call; this package cannot
585// see the seal and does not pre-empt it.
586// - equityCode is checked for being empty, over-long, or one of the nominal
587// codes being swept ([ErrEquityCodeIsNominal]). It CANNOT be checked for
588// naming an Equity-kind account, because Report does not retain the chart:
589// a code naming a known liability yields an entry journal will accept and
590// that files net income in the wrong place. journal rejects an unknown
591// code on its own; the wrong-kind case is the caller's.
592//
593// The postings always balance and always carry both a debit and a credit when
594// there are any at all, which is why a non-error return is always postable on
595// those grounds. Net income is credited to equity and a net loss is debited,
596// equity being credit-normal. A window with no nominal movement is
597// [ErrNothingToClose] rather than an empty slice.
598//
599// That postability claim rests on a precondition [Derive] supplies for free
600// and a hand-built Report does not, so this function establishes it itself:
601// every row's columns must be non-negative, or [ErrNegativeColumns]. Without
602// the check the claim is false in the worst available way — not an error, but
603// a balanced, postable, SIGN-INVERTED entry. See [ErrNegativeColumns].
604func (r Report) ClosingEntry(equityCode string) ([]journal.Posting, error) {
605 if equityCode == "" || len(equityCode) > journal.MaxCodeLen {
606 return nil, ErrInvalidEquityCode
607 }
608
609 out := []journal.Posting{}
610 var net int64
611 for _, a := range r.Activity {
612 // Before the partition, not after it. A negative column on a
613 // non-nominal row cannot affect this result, so checking only the
614 // nominal rows would be sufficient for the arithmetic and useless as a
615 // claim: the guarantee worth having is "ClosingEntry refuses any
616 // Report carrying a negative column", which a reader can state and a
617 // test can prove, rather than "refuses one in the rows it happened to
618 // reach".
619 if a.Debits < 0 || a.Credits < 0 {
620 return nil, ErrNegativeColumns
621 }
622
623 sec, err := statements.SectionOf(a.Kind)
624 if err != nil {
625 return nil, ErrInvalidKind
626 }
627 if sec != statements.SectionPerformance {
628 continue
629 }
630 if a.Code == equityCode {
631 return nil, ErrEquityCodeIsNominal
632 }
633
634 m := a.Movement()
635 if m == 0 {
636 continue
637 }
638 // This branch is UNREACHABLE as the function now stands, and shipping
639 // it is deliberate rather than an oversight. magnitude only refuses
640 // minInt64, and with both columns proven non-negative above no single
641 // row can produce it: Credits-Debits = minInt64 would need a Debits of
642 // 2^63, which no int64 holds. It was reachable before
643 // ErrNegativeColumns existed. It stays because it is the fail-closed
644 // backstop if that guard is ever weakened, and because deleting it
645 // would make this the only magnitude call site without one. The
646 // REACHABLE overflow is the net accumulator below, which can reach
647 // minInt64 by accumulation; the test names which door it uses.
648 amt, ok := magnitude(m)
649 if !ok {
650 return nil, ErrOverflow
651 }
652
653 // Close the account by posting against its movement: the opposite of
654 // its normal side when it moved normally, its normal side when it
655 // moved contra.
656 side := journal.Debit
657 if a.Kind.IsDebitNormal() == (m > 0) {
658 side = journal.Credit
659 }
660 out = append(out, journal.Posting{Account: a.Code, Side: side, Amount: amt})
661
662 if a.Kind == journal.Revenue {
663 net, ok = checkedAdd(net, m)
664 } else {
665 net, ok = checkedSub(net, m)
666 }
667 if !ok {
668 return nil, ErrOverflow
669 }
670 }
671
672 if net != 0 {
673 amt, ok := magnitude(net)
674 if !ok {
675 return nil, ErrOverflow
676 }
677 side := journal.Credit
678 if net < 0 {
679 side = journal.Debit
680 }
681 out = append(out, journal.Posting{Account: equityCode, Side: side, Amount: amt})
682 }
683
684 if len(out) == 0 {
685 return nil, ErrNothingToClose
686 }
687 if len(out) > journal.MaxPostingsPerEntry {
688 return nil, ErrTooManyNominalAccounts
689 }
690 return out, nil
691}
692
693// indexOfCode returns the index of code in chart, or -1. chart is strictly
694// ascending by Code, which Derive has already established, so this is a binary
695// search.
696func indexOfCode(chart []journal.Account, code string) int {
697 lo, hi := 0, len(chart)-1
698 for lo <= hi {
699 mid := lo + (hi-lo)/2
700 switch {
701 case chart[mid].Code == code:
702 return mid
703 case chart[mid].Code < code:
704 lo = mid + 1
705 default:
706 hi = mid - 1
707 }
708 }
709 return -1
710}
711
712// indexOfID returns the index of the entry with the given ID, or -1. entries
713// is strictly ascending by ID, which Derive establishes over the WHOLE slice
714// before computing anything, so the search is sound by precondition rather
715// than by an argument about which IDs a reversal can name.
716func indexOfID(entries []journal.Entry, id uint64) int {
717 lo, hi := 0, len(entries)-1
718 for lo <= hi {
719 mid := lo + (hi-lo)/2
720 switch {
721 case entries[mid].ID == id:
722 return mid
723 case entries[mid].ID < id:
724 lo = mid + 1
725 default:
726 hi = mid - 1
727 }
728 }
729 return -1
730}
731
732// minInt64 is the one int64 whose negation is not an int64.
733const minInt64 int64 = -1 << 63
734
735// magnitude returns the absolute value of v and reports whether it fit. It is
736// not -v: negating [minInt64] overflows on its own, so the check has to come
737// first.
738func magnitude(v int64) (int64, bool) {
739 if v >= 0 {
740 return v, true
741 }
742 if v == minInt64 {
743 return 0, false
744 }
745 return -v, true
746}
747
748// checkedAdd returns a+b and reports whether the addition did not overflow
749// int64. journal's and statements' copies are both unexported, so this is a
750// deliberate three-line duplicate of a trivial helper — a different thing from
751// a second copy of the nominal-account partition, which is why
752// [statements.SectionOf] is imported instead of restated.
753func checkedAdd(a, b int64) (int64, bool) {
754 sum := a + b
755 if (b > 0 && sum < a) || (b < 0 && sum > a) {
756 return 0, false
757 }
758 return sum, true
759}
760
761// checkedSub returns a-b and reports whether the subtraction did not overflow
762// int64. It is not checkedAdd(a, -b), for magnitude's reason.
763func checkedSub(a, b int64) (int64, bool) {
764 diff := a - b
765 if (b < 0 && diff < a) || (b > 0 && diff > a) {
766 return 0, false
767 }
768 return diff, true
769}