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.

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}