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.

cashflow.gno

32.55 Kb · 872 lines
  1// Package cashflow derives a DIRECT-METHOD statement of cash flows from a
  2// double-entry book: how cash moved over one window, split into operating,
  3// investing and financing activity, with an explicit refusal where the split
  4// is not determinate.
  5//
  6// It is the third primary financial statement. [p/statements] derives the
  7// other two from a chart at an instant, and [p/periods] derives one window's
  8// movement; all three of those packages carry a note saying they do not do
  9// this one, and all three say the INDIRECT method needs a cash-vs-accrual
 10// classification [journal.Posting] does not carry. That is true. The direct
 11// method needs something different and smaller: a declaration of which chart
 12// codes hold cash. A declaration is something a caller can supply, which is
 13// what makes this package possible without changing what a posting is.
 14//
 15// # The sign convention, and why cash must be an asset
 16//
 17// A debit to a cash account is an INFLOW and a credit is an OUTFLOW. That
 18// follows from cash being debit-normal, so [Policy] requires every declared
 19// cash code to be a [journal.Asset] and refuses anything else
 20// ([ErrCashAccountNotAsset]). Declaring a credit-normal account as cash would
 21// invert every figure in the report and nothing downstream could tell. The
 22// cost of the rule is real and is stated rather than hidden: a bank overdraft
 23// carried as a liability cannot be declared as cash, and a caller holding one
 24// must carry it as a contra-asset or leave it out.
 25//
 26// # Attribution is per ENTRY, never per leg
 27//
 28// This is the package's central correctness decision, and the natural design
 29// is wrong in a way that produces numbers which balance. Take one ordinary
 30// part-paid purchase:
 31//
 32//	debit  Inventory          100
 33//	credit Cash                60
 34//	credit AccountsPayable     40
 35//
 36// Classifying each non-cash leg and attributing its own amount reports 100 of
 37// inventory-related outflow against the 60 of cash that actually moved. It
 38// invents a cash flow of 40 that did not happen, and the statement still adds
 39// up. So the unit of attribution is the entry:
 40//
 41//   - An entry with NO cash legs is skipped. It is accrual-only and moved no
 42//     cash. This is the direct method's whole point.
 43//   - An entry whose net cash movement is ZERO is named in [Report.NetZero]
 44//     and not classified. A transfer between two declared cash accounts is the
 45//     common case; there is nothing to attribute, so determinacy is not
 46//     demanded of it.
 47//   - Otherwise the entry's net cash movement is computed from its CASH legs
 48//     alone — so the figure is what actually moved — and that whole figure is
 49//     attributed to one category.
 50//
 51// # The determinacy rule, stated as a refusal
 52//
 53// An entry touching cash is determinate iff it has at least one non-cash leg
 54// and all of its non-cash legs classify to the SAME category, in which case
 55// the entry's whole net cash delta goes there. Otherwise the entry is named in
 56// [Report.Unresolved] with the reason, and its cash delta is carried in
 57// [Report.Unattributed].
 58//
 59// The "at least one" clause is not pedantry. An entry with nonzero net cash
 60// and no non-cash leg at all cannot come out of a balanced book, and it gets
 61// its own [ReasonUnbalanced] rather than being reported as a conflict with
 62// nothing to conflict — see that constant.
 63//
 64// Under that rule the purchase above is determinate when Inventory and
 65// AccountsPayable both classify operating: 60 of operating outflow, which is
 66// right. An entry that settles a loan and buys equipment at once — one leg
 67// financing, one investing — is NOT determinate, and no apportionment is
 68// invented for it. Splitting it proportionally would be a guess wearing a
 69// number's clothes.
 70//
 71// # Why classification is per CODE and not per journal.Kind
 72//
 73// A mapping from [journal.Kind] to a category would need no caller
 74// declaration at all, and it is the first design anyone reaches for. It is
 75// wrong on the most common accounts in any book: receivables and inventory are
 76// assets whose movement is OPERATING, not investing, and a kind-based mapping
 77// puts the entire working-capital cycle in the wrong section. So there is no
 78// kind-based default and no default bucket of any sort. A non-cash account
 79// with no declared category makes its entry unresolved
 80// ([ReasonUndeclared]) — silence is not assent.
 81//
 82// # What this package does not do
 83//
 84//   - **It never writes, and never sees a Book.** [Derive] returns figures;
 85//     the caller owns persistence and idempotence.
 86//   - **It cannot verify the policy.** Which codes hold cash and what each
 87//     other code's movement means are caller declarations. A chart that
 88//     declares a receivable as cash produces a plausible, wrong statement.
 89//     The declaration is the trust boundary.
 90//   - **It cannot verify the opening balance.** A window-filtered entry slice
 91//     carries period movement, not a cumulative position, so [Report.Opening]
 92//     is echoed back exactly as supplied and [Report.Closing] is only as good
 93//     as it. This is [p/periods]' limitation inherited, not a new one.
 94//   - **No reversal handling.** A reversing entry that touches cash moved
 95//     cash, and is classified like any other entry. That differs from
 96//     [p/periods], where an unresolved reversal is a census item, and the
 97//     difference is deliberate: a cash-flow statement reports movement, and a
 98//     reversal's movement is movement.
 99//   - **No paging and no merge.** [MaxEntries] is the hard bound. Merging
100//     reports across pages would make the unresolved set an artifact of the
101//     paging rather than of the data.
102//   - **No audit.** Every figure is derived from what the caller supplied. A
103//     chart from one book and entries from another produce a plausible report
104//     and nothing here detects it.
105//   - **Reconciling against [p/periods] proves nothing about the split.**
106//     Comparing [Report.NetChange] with the cash accounts' movement in a
107//     periods report is an arithmetic self-check that both packages added the
108//     same columns the same way. A statement that files every flow under the
109//     wrong category reconciles perfectly.
110//
111// [p/journal]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal
112// [p/periods]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods
113// [p/statements]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements
114package cashflow
115
116import (
117	"errors"
118
119	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
120	"gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods"
121)
122
123// MaxAccounts bounds the chart [Derive] will accept. It is periods' bound,
124// which is statements' bound, which is journal's own account cap — one
125// number, declared once, so a chart that clears one of these packages clears
126// all three.
127const MaxAccounts = periods.MaxAccounts
128
129// MaxEntries bounds the entry slice [Derive] will walk. It is periods' bound
130// for the same reason: a caller feeding the same window to both packages must
131// not have one of them refuse what the other accepted.
132const MaxEntries = periods.MaxEntries
133
134// NumCategories is how many categories a statement of cash flows has, and so
135// the length [Report.Lines] always has. It is a constant of the standard, not
136// a configuration.
137const NumCategories = 3
138
139var (
140	// ErrInvalidWindow is a window failing [periods.Window.Valid].
141	ErrInvalidWindow = errors.New("cashflow: invalid window")
142
143	// ErrTooManyAccounts is a chart longer than [MaxAccounts].
144	ErrTooManyAccounts = errors.New("cashflow: chart length exceeds MaxAccounts")
145
146	// ErrChartNotSorted is a chart whose codes are not strictly ascending.
147	// Membership here is a binary search, so the ordering is load-bearing
148	// rather than cosmetic.
149	ErrChartNotSorted = errors.New("cashflow: chart codes not strictly ascending")
150
151	// ErrInvalidKind is a chart row whose Kind is outside journal's five.
152	ErrInvalidKind = errors.New("cashflow: invalid account kind")
153
154	// ErrTooManyEntries is an entry slice longer than [MaxEntries].
155	ErrTooManyEntries = errors.New("cashflow: entry count exceeds MaxEntries")
156
157	// ErrEntriesNotSorted is an entry slice not strictly ascending by ID.
158	ErrEntriesNotSorted = errors.New("cashflow: entry IDs not strictly ascending")
159
160	// ErrTooManyPostings is an entry with more than
161	// [journal.MaxPostingsPerEntry] postings. journal cannot produce one, and
162	// re-checking is what makes [MaxEntries]'s cost bound a proven ceiling
163	// rather than a hope.
164	ErrTooManyPostings = errors.New("cashflow: entry exceeds journal.MaxPostingsPerEntry")
165
166	// ErrInvalidPosting is an in-window posting with an invalid Side or a
167	// non-positive Amount. journal carries direction in Side and amounts are
168	// always positive, so a signed amount here is a posting from somewhere
169	// else.
170	ErrInvalidPosting = errors.New("cashflow: invalid posting")
171
172	// ErrUnknownAccount is an in-window posting naming a code absent from the
173	// chart. It is scoped to the window because no figure in the report
174	// depends on an entry outside it.
175	ErrUnknownAccount = errors.New("cashflow: posting names an account absent from the chart")
176
177	// ErrNoCashAccounts is an empty [Policy.Cash]. A statement of cash flows
178	// over a book with no declared cash is all zeroes and looks exactly like
179	// a quiet period, so the empty declaration is refused rather than
180	// reported.
181	ErrNoCashAccounts = errors.New("cashflow: policy declares no cash accounts")
182
183	// ErrCashNotSorted is a [Policy.Cash] not strictly ascending.
184	ErrCashNotSorted = errors.New("cashflow: policy cash codes not strictly ascending")
185
186	// ErrUnknownCashAccount is a declared cash code absent from the chart.
187	ErrUnknownCashAccount = errors.New("cashflow: policy declares a cash account absent from the chart")
188
189	// ErrCashAccountNotAsset is a declared cash code whose chart Kind is not
190	// [journal.Asset]. See the package doc on the sign convention: this is
191	// what keeps a debit to cash meaning an inflow.
192	ErrCashAccountNotAsset = errors.New("cashflow: policy declares a non-asset account as cash")
193
194	// ErrRulesNotSorted is a [Policy.Rules] not strictly ascending by Code.
195	ErrRulesNotSorted = errors.New("cashflow: policy rule codes not strictly ascending")
196
197	// ErrInvalidCategory is a category outside the declared three — in a
198	// policy rule, or passed to [Report.Line].
199	ErrInvalidCategory = errors.New("cashflow: unknown category")
200
201	// ErrUnknownRuleAccount is a policy rule naming a code absent from the
202	// chart.
203	ErrUnknownRuleAccount = errors.New("cashflow: policy rule names an account absent from the chart")
204
205	// ErrCashAccountClassified is a code declared both as cash and as a
206	// classified account. The two roles are exclusive: a cash account is what
207	// MOVES, and a classified account is what the movement is FOR. Accepting
208	// both would make the same code answer two different questions in one
209	// entry.
210	ErrCashAccountClassified = errors.New("cashflow: code is declared as cash and also classified")
211
212	// ErrOverflow is an int64 overflow in any accumulation, including the
213	// entry-level cash total and the closing balance.
214	ErrOverflow = errors.New("cashflow: int64 overflow")
215)
216
217// Category is which activity a cash flow belongs to. The zero value is
218// deliberately invalid, so a forgotten field is an error rather than
219// operating activity — which is the bucket a defaulting implementation would
220// pick and the one that would hide the most.
221//
222// The constants carry a Category prefix for the same reason
223// [statements.Section]'s do: a bare Operating would read as a field name.
224type Category uint8
225
226// The three activity categories of a statement of cash flows.
227const (
228	CategoryOperating Category = 1 // trading: the working-capital cycle
229	CategoryInvesting Category = 2 // long-lived assets and investments
230	CategoryFinancing Category = 3 // capital and borrowings
231)
232
233// Valid reports whether c is one of the three categories.
234func (c Category) Valid() bool {
235	return c >= CategoryOperating && c <= CategoryFinancing
236}
237
238// String returns the category's name, or "invalid" for the zero value and any
239// unrecognised code.
240func (c Category) String() string {
241	switch c {
242	case CategoryOperating:
243		return "operating"
244	case CategoryInvesting:
245		return "investing"
246	case CategoryFinancing:
247		return "financing"
248	}
249	return "invalid"
250}
251
252// Reason is why [Derive] would not classify an entry. The zero value is
253// deliberately invalid.
254type Reason uint8
255
256// The three reasons an entry is unresolved.
257const (
258	// ReasonConflict is an entry whose non-cash legs classify to more than
259	// one category. [Unresolved.Categories] names them, and names at least
260	// two.
261	ReasonConflict Reason = 1
262
263	// ReasonUndeclared is an entry with a non-cash leg whose account has no
264	// policy rule. [Unresolved.Codes] names them. An undeclared account is
265	// named rather than defaulted, because a default bucket turns a missing
266	// declaration into a figure nobody questions.
267	//
268	// It takes precedence over [ReasonConflict] when an entry has both an
269	// undeclared code and two declared categories, because the undeclared
270	// code is the actionable half: declaring it may resolve the entry, and
271	// reporting a conflict while concealing a missing declaration would send
272	// the reader to re-examine a policy that is not yet complete.
273	ReasonUndeclared Reason = 2
274
275	// ReasonUnbalanced is an entry all of whose legs are declared cash
276	// accounts and whose cash legs do not net to zero.
277	//
278	// [journal.Book] cannot produce one: a balanced entry whose every leg is
279	// cash has equal debits and credits over cash and therefore nets to zero,
280	// which makes it a [Report.NetZero] item instead. So this reason is
281	// reachable only from a hand-built entry slice, the same class
282	// [p/periods] documents for its own hand-built Report.
283	//
284	// It exists rather than being folded into [ReasonConflict] because a
285	// conflict carrying an EMPTY [Unresolved.Categories] is indistinguishable
286	// from a bug in this package, and a census item a reader cannot tell from
287	// a defect is not a census item. Found by writing the test for the
288	// conflict case, not by reading the code.
289	ReasonUnbalanced Reason = 3
290)
291
292// Valid reports whether r is one of the three reasons.
293func (r Reason) Valid() bool { return r >= ReasonConflict && r <= ReasonUnbalanced }
294
295// String returns the reason's name, or "invalid".
296func (r Reason) String() string {
297	switch r {
298	case ReasonConflict:
299		return "conflict"
300	case ReasonUndeclared:
301		return "undeclared"
302	case ReasonUnbalanced:
303		return "unbalanced"
304	}
305	return "invalid"
306}
307
308// Classification is one account code's activity category, declared by the
309// caller. See the package doc on why this is per code rather than per
310// [journal.Kind].
311type Classification struct {
312	Code     string
313	Category Category
314}
315
316// Policy is the caller's whole declaration: which chart codes hold cash, and
317// what each other code's movement is for.
318//
319// Both slices must be strictly ascending by code, because membership is a
320// binary search in [Derive]'s inner loop. The two sets must be disjoint
321// ([ErrCashAccountClassified]). Neither is verified against anything but the
322// chart — this package cannot know whether a code called "petty cash" holds
323// cash, and says so in its doc rather than pretending the name is evidence.
324type Policy struct {
325	// Cash is the codes that hold cash, strictly ascending. Must be
326	// non-empty, and every code must be a [journal.Asset] in the chart.
327	Cash []string
328
329	// Rules is the activity category for non-cash codes, strictly ascending
330	// by Code. A code absent from here is NOT defaulted; an entry touching it
331	// is unresolved with [ReasonUndeclared].
332	Rules []Classification
333}
334
335// Line is one category's gross flows over the window.
336//
337// Inflows and Outflows are both non-negative AS PRODUCED BY [Derive], and
338// gross rather than netted, because a category with 1,000 in and 1,000 out is
339// not the same business as one with nothing happening and a net of zero is
340// all a netted figure can say about either.
341type Line struct {
342	Category Category
343
344	// Inflows is the gross cash received, non-negative.
345	Inflows int64
346
347	// Outflows is the gross cash paid, non-negative.
348	Outflows int64
349
350	// Entries is how many in-window entries were attributed here.
351	Entries int
352}
353
354// Net returns Inflows minus Outflows.
355//
356// The subtraction is unchecked, and that is safe for any Line [Derive]
357// produces: both columns are non-negative, so the difference is in range for
358// every pair of int64 values they can hold. A hand-built Line with a negative
359// column can wrap, and that is the only way it can.
360//
361// The wrapped value does not stay local, which is the part worth stating:
362// [Report.Reconciles] sums Net() over the lines, and a wrapped Net() can make
363// it agree over a report whose columns say something else entirely. So
364// Reconciles refuses a negative column outright rather than summing it — see
365// the guard there. Net() itself stays unchecked because it has no way to
366// report a refusal and a Line is a figure, not a claim.
367//
368// This note previously compared the blast radius to
369// [periods.Activity.Movement] and said that one was wider because a negative
370// column there also inverts a sign convention. That comparative was wrong,
371// and an audit of this package found it: both wrap a figure that then passes
372// every downstream guard, and the real asymmetry is in where the wrapped
373// value GOES, not in sign inversion. The note is kept, corrected, because the
374// equivalent note in periods was also once wrong about its own blast radius
375// and also had to be found by an audit — twice is a pattern, and the pattern
376// is that a comparative claim about another package's hazard is the sentence
377// nothing checks.
378func (l Line) Net() int64 { return l.Inflows - l.Outflows }
379
380// Unresolved is one in-window entry [Derive] would not classify. It is a
381// census item, not an error: the entry's cash movement is real and is carried
382// in [Report.Unattributed], so [Report.NetChange] and [Report.Closing] stay
383// true even when the split is incomplete.
384//
385// Whether an entry here SHOULD have been classifiable is an accounting
386// judgement made outside this package. What is reported is that this policy
387// could not decide it, and exactly what was missing or in conflict.
388type Unresolved struct {
389	// EntryID is the entry's journal ID.
390	EntryID uint64
391
392	// Reason is why it could not be classified.
393	Reason Reason
394
395	// Net is the entry's net cash movement: positive for an inflow. Reported
396	// so a reader can see the size of what is unattributed rather than only
397	// that something is.
398	Net int64
399
400	// Categories is the conflicting set, ascending and of length at least
401	// two, when Reason is [ReasonConflict]; empty otherwise. Freshly
402	// allocated and never nil.
403	//
404	// Ascending rather than in posting order, so that two records of the same
405	// conflict compare equal whichever leg was posted first.
406	Categories []Category
407
408	// Codes is the undeclared account codes, in posting order and
409	// deduplicated, when Reason is [ReasonUndeclared]; empty otherwise.
410	// Freshly allocated and never nil.
411	//
412	// Posting order rather than ascending, because these are a worklist for
413	// whoever extends the policy and the order a reader meets them in the
414	// entry is the more useful one. The two slices differ on this
415	// deliberately: a conflict set is compared, a code list is worked
416	// through.
417	Codes []string
418}
419
420// Report is one window's statement of cash flows. Every slice is freshly
421// allocated by [Derive] on every call and is never nil, so the caller owns it
422// and an empty result is an empty slice rather than a nil to be distinguished
423// from one.
424//
425// The invariant that makes the report readable when the split is incomplete:
426//
427//	sum(Lines[i].Net()) + Unattributed == NetChange
428//	Opening + NetChange             == Closing
429//
430// Both hold on every Report [Derive] returns, including one with unresolved
431// entries. [Report.Reconciles] asserts the first.
432type Report struct {
433	// Window is the window the figures are for, echoed back so a stored
434	// report cannot be read without it.
435	Window periods.Window
436
437	// Lines is the three categories, ascending by Category, always present
438	// and always of length [NumCategories] — a category with no activity is a
439	// zero line, not an absent one, because a statement of cash flows with a
440	// missing section is not a statement of cash flows. Use [Report.Line] to
441	// reach one by category rather than indexing by arithmetic.
442	Lines []Line
443
444	// Opening is the caller-supplied opening cash position, echoed back
445	// unchanged. This package cannot verify it; see the package doc. A
446	// negative value is accepted — an overdrawn position is a real one — so
447	// the sign is not constrained here.
448	Opening int64
449
450	// NetChange is the window's total net cash movement, attributed and
451	// unattributed together. It is the book's figure, not the statement's, so
452	// it is true even when the split is not complete.
453	NetChange int64
454
455	// Unattributed is the net cash movement of the entries in Unresolved.
456	// Zero exactly when every entry that moved cash was classified.
457	Unattributed int64
458
459	// Closing is Opening plus NetChange.
460	Closing int64
461
462	// Entries is how many entries fell in the window, including the ones that
463	// moved no cash. Zero is a legitimate report of a quiet period, not an
464	// error.
465	Entries int
466
467	// CashEntries is how many in-window entries touched a declared cash
468	// account at all, including the ones in NetZero and Unresolved.
469	CashEntries int
470
471	// NetZero is the IDs of in-window entries that touched cash but moved
472	// none on net — a transfer between two declared cash accounts, possibly
473	// alongside non-cash legs. Classification is deliberately not attempted:
474	// there is nothing to attribute, so demanding determinacy of them would
475	// manufacture unresolved entries out of non-events.
476	NetZero []uint64
477
478	// Unresolved is the entries whose category was not determinate.
479	Unresolved []Unresolved
480}
481
482// Line returns the line for c. An invalid category is [ErrInvalidCategory];
483// there is no default line.
484//
485// This is the accessor to use. [Report.Lines] is ordered by Category and a
486// consumer could index it as Lines[c-1], but that arithmetic is a second copy
487// of the ordering invariant and the kind of thing that survives a reordering
488// silently.
489func (r Report) Line(c Category) (Line, error) {
490	if !c.Valid() {
491		return Line{}, ErrInvalidCategory
492	}
493	for _, l := range r.Lines {
494		if l.Category == c {
495			return l, nil
496		}
497	}
498	return Line{}, ErrInvalidCategory
499}
500
501// Complete reports whether every in-window entry that moved cash was
502// attributed to a category.
503//
504// A complete report is not a correct one: completeness is a property of the
505// POLICY's coverage, and a policy that classifies every account wrongly is
506// complete. See the package doc.
507func (r Report) Complete() bool { return len(r.Unresolved) == 0 }
508
509// Reconciles reports whether the attributed lines plus the unattributed
510// residual equal NetChange.
511//
512// It is offered as API rather than asserted internally because a Report can
513// be built by hand, and a consumer that stores one should be able to check it
514// on the way back out. On a Report from [Derive] it is always true.
515//
516// False means one of three things and does not distinguish them: the lines do
517// not sum to NetChange, the sum overflows, or a line carries a negative
518// column (which [Line.Net] would wrap). All three are "do not trust this
519// report", which is the whole question a bool can answer.
520func (r Report) Reconciles() bool {
521	total := int64(0)
522	for _, l := range r.Lines {
523		// Before the sum, and the reason is that Net() cannot refuse. A
524		// hand-built Line{Inflows: maxI64, Outflows: -1} nets to minI64 by
525		// wrapping, and checkedAdd cannot see it: the wrap happened in the
526		// subtraction, so what arrives here is an ordinary in-range negative
527		// and every add is honest. Reconciles would then agree with a report
528		// whose operating line advertises an inflow of maxI64 -- which is
529		// worse than disagreeing, because Reconciles is the one check a
530		// consumer storing a hand-built Report is told to trust.
531		//
532		// Derive never produces a negative column (both are accumulated from
533		// zero through checkedAdd), so this costs a Derive-sourced report
534		// nothing. p/periods guards the same shape at the same kind of
535		// boundary, with an error rather than a bool because it has one to
536		// return.
537		if l.Inflows < 0 || l.Outflows < 0 {
538			return false
539		}
540		n, ok := checkedAdd(total, l.Net())
541		if !ok {
542			return false
543		}
544		total = n
545	}
546	total, ok := checkedAdd(total, r.Unattributed)
547	if !ok {
548		return false
549	}
550	return total == r.NetChange
551}
552
553// Derive builds the statement for one window.
554//
555// Preconditions, all refused rather than assumed: w must be valid; chart must
556// be strictly ascending by Code, no longer than [MaxAccounts], with every
557// Kind one of journal's five; entries must be strictly ascending by ID and no
558// longer than [MaxEntries], with at most [journal.MaxPostingsPerEntry]
559// postings each; and p must satisfy the rules on [Policy].
560//
561// Entries outside w are skipped, not refused — a caller passing the whole
562// book is the expected case — and their postings are therefore not
563// content-checked, because no figure in the report depends on them.
564// [ErrInvalidPosting] and [ErrUnknownAccount] are scoped to the window for
565// that reason; the postings-per-entry cap is not, since it is what makes
566// [MaxEntries]'s cost ceiling a bound rather than a hope.
567func Derive(chart []journal.Account, entries []journal.Entry, w periods.Window, p Policy, opening int64) (Report, error) {
568	if !w.Valid() {
569		return Report{}, ErrInvalidWindow
570	}
571	if len(chart) > MaxAccounts {
572		return Report{}, ErrTooManyAccounts
573	}
574	if len(entries) > MaxEntries {
575		return Report{}, ErrTooManyEntries
576	}
577	for i, a := range chart {
578		if !a.Kind.Valid() {
579			return Report{}, ErrInvalidKind
580		}
581		if i > 0 && chart[i-1].Code >= a.Code {
582			return Report{}, ErrChartNotSorted
583		}
584	}
585	for i, e := range entries {
586		if len(e.Postings) > journal.MaxPostingsPerEntry {
587			return Report{}, ErrTooManyPostings
588		}
589		if i > 0 && entries[i-1].ID >= e.ID {
590			return Report{}, ErrEntriesNotSorted
591		}
592	}
593	if err := validatePolicy(chart, p); err != nil {
594		return Report{}, err
595	}
596
597	rep := Report{
598		Window:     w,
599		Lines:      make([]Line, 0, NumCategories),
600		Opening:    opening,
601		NetZero:    make([]uint64, 0),
602		Unresolved: make([]Unresolved, 0),
603	}
604	for c := CategoryOperating; c <= CategoryFinancing; c++ {
605		rep.Lines = append(rep.Lines, Line{Category: c})
606	}
607
608	for _, e := range entries {
609		if !w.Contains(e.At) {
610			continue
611		}
612		rep.Entries++
613
614		net := int64(0)
615		cashLegs := 0
616		cats := make([]Category, 0, NumCategories)
617		codes := make([]string, 0)
618
619		for _, pg := range e.Postings {
620			if !pg.Side.Valid() || pg.Amount <= 0 {
621				return Report{}, ErrInvalidPosting
622			}
623			if !inChart(chart, pg.Account) {
624				return Report{}, ErrUnknownAccount
625			}
626			if isCash(p.Cash, pg.Account) {
627				cashLegs++
628				signed := pg.Amount
629				if pg.Side == journal.Credit {
630					signed = -pg.Amount
631				}
632				sum, ok := checkedAdd(net, signed)
633				if !ok {
634					return Report{}, ErrOverflow
635				}
636				net = sum
637				continue
638			}
639			c, found := ruleFor(p.Rules, pg.Account)
640			if !found {
641				if !hasCode(codes, pg.Account) {
642					codes = append(codes, pg.Account)
643				}
644				continue
645			}
646			if !hasCategory(cats, c) {
647				cats = append(cats, c)
648			}
649		}
650
651		if cashLegs == 0 {
652			continue
653		}
654		rep.CashEntries++
655
656		if net == 0 {
657			rep.NetZero = append(rep.NetZero, e.ID)
658			continue
659		}
660
661		total, ok := checkedAdd(rep.NetChange, net)
662		if !ok {
663			return Report{}, ErrOverflow
664		}
665		rep.NetChange = total
666
667		if len(codes) > 0 || len(cats) != 1 {
668			// The three cases are selected explicitly rather than by falling
669			// through to a default, so that each Unresolved record carries the
670			// set that explains it: Codes for a missing declaration,
671			// Categories for a conflict, and NEITHER for an unbalanced
672			// cash-only entry -- which is why that case has its own reason
673			// rather than appearing as a conflict with an empty set.
674			var reason Reason
675			reported := make([]Category, 0, len(cats))
676			reportedCodes := make([]string, 0, len(codes))
677			switch {
678			case len(codes) > 0:
679				reason = ReasonUndeclared
680				reportedCodes = append(reportedCodes, codes...)
681			case len(cats) == 0:
682				reason = ReasonUnbalanced
683			default:
684				reason = ReasonConflict
685				reported = append(reported, sortedCategories(cats)...)
686			}
687			unattributed, ok := checkedAdd(rep.Unattributed, net)
688			if !ok {
689				return Report{}, ErrOverflow
690			}
691			rep.Unattributed = unattributed
692			rep.Unresolved = append(rep.Unresolved, Unresolved{
693				EntryID:    e.ID,
694				Reason:     reason,
695				Net:        net,
696				Categories: reported,
697				Codes:      reportedCodes,
698			})
699			continue
700		}
701
702		idx := int(cats[0]) - 1
703		if net > 0 {
704			in, ok := checkedAdd(rep.Lines[idx].Inflows, net)
705			if !ok {
706				return Report{}, ErrOverflow
707			}
708			rep.Lines[idx].Inflows = in
709		} else {
710			out, ok := checkedAdd(rep.Lines[idx].Outflows, -net)
711			if !ok {
712				return Report{}, ErrOverflow
713			}
714			rep.Lines[idx].Outflows = out
715		}
716		rep.Lines[idx].Entries++
717	}
718
719	closing, ok := checkedAdd(rep.Opening, rep.NetChange)
720	if !ok {
721		return Report{}, ErrOverflow
722	}
723	rep.Closing = closing
724
725	return rep, nil
726}
727
728// validatePolicy checks p against the chart. Every failure is a distinct
729// error, because each one has a distinct fix and "invalid policy" tells a
730// caller nothing about which of six things to look at.
731func validatePolicy(chart []journal.Account, p Policy) error {
732	if len(p.Cash) == 0 {
733		return ErrNoCashAccounts
734	}
735	if len(p.Cash) > MaxAccounts || len(p.Rules) > MaxAccounts {
736		return ErrTooManyAccounts
737	}
738	for i, code := range p.Cash {
739		if i > 0 && p.Cash[i-1] >= code {
740			return ErrCashNotSorted
741		}
742		k, found := kindOf(chart, code)
743		if !found {
744			return ErrUnknownCashAccount
745		}
746		if k != journal.Asset {
747			return ErrCashAccountNotAsset
748		}
749	}
750	for i, r := range p.Rules {
751		if i > 0 && p.Rules[i-1].Code >= r.Code {
752			return ErrRulesNotSorted
753		}
754		if !r.Category.Valid() {
755			return ErrInvalidCategory
756		}
757		if _, found := kindOf(chart, r.Code); !found {
758			return ErrUnknownRuleAccount
759		}
760		if isCash(p.Cash, r.Code) {
761			return ErrCashAccountClassified
762		}
763	}
764	return nil
765}
766
767// kindOf returns the chart Kind for code. The chart is strictly ascending, so
768// this is a binary search.
769func kindOf(chart []journal.Account, code string) (journal.Kind, bool) {
770	lo, hi := 0, len(chart)-1
771	for lo <= hi {
772		mid := lo + (hi-lo)/2
773		switch {
774		case chart[mid].Code == code:
775			return chart[mid].Kind, true
776		case chart[mid].Code < code:
777			lo = mid + 1
778		default:
779			hi = mid - 1
780		}
781	}
782	return 0, false
783}
784
785// inChart reports whether code is in the chart.
786func inChart(chart []journal.Account, code string) bool {
787	_, found := kindOf(chart, code)
788	return found
789}
790
791// isCash reports whether code is a declared cash account. cash is strictly
792// ascending, so this is a binary search.
793func isCash(cash []string, code string) bool {
794	lo, hi := 0, len(cash)-1
795	for lo <= hi {
796		mid := lo + (hi-lo)/2
797		switch {
798		case cash[mid] == code:
799			return true
800		case cash[mid] < code:
801			lo = mid + 1
802		default:
803			hi = mid - 1
804		}
805	}
806	return false
807}
808
809// ruleFor returns the declared category for code. rules is strictly ascending
810// by Code, so this is a binary search.
811func ruleFor(rules []Classification, code string) (Category, bool) {
812	lo, hi := 0, len(rules)-1
813	for lo <= hi {
814		mid := lo + (hi-lo)/2
815		switch {
816		case rules[mid].Code == code:
817			return rules[mid].Category, true
818		case rules[mid].Code < code:
819			lo = mid + 1
820		default:
821			hi = mid - 1
822		}
823	}
824	return 0, false
825}
826
827// hasCategory reports whether c is already in cats. The slice is at most
828// NumCategories long, so a linear scan is the whole cost.
829func hasCategory(cats []Category, c Category) bool {
830	for _, x := range cats {
831		if x == c {
832			return true
833		}
834	}
835	return false
836}
837
838// hasCode reports whether code is already in codes. The slice is at most
839// journal.MaxPostingsPerEntry long.
840func hasCode(codes []string, code string) bool {
841	for _, x := range codes {
842		if x == code {
843			return true
844		}
845	}
846	return false
847}
848
849// sortedCategories returns cats in ascending order, freshly allocated. The
850// input is at most NumCategories long, so this is a selection over three
851// values and the ordering exists so a reader comparing two Unresolved records
852// is comparing sets rather than posting orders.
853func sortedCategories(cats []Category) []Category {
854	out := make([]Category, 0, len(cats))
855	for c := CategoryOperating; c <= CategoryFinancing; c++ {
856		if hasCategory(cats, c) {
857			out = append(out, c)
858		}
859	}
860	return out
861}
862
863// checkedAdd returns a+b and whether it is representable. Both operands are
864// int64 and the sign test is the standard one: an overflow is exactly the
865// case where two same-signed operands produce a differently-signed sum.
866func checkedAdd(a, b int64) (int64, bool) {
867	s := a + b
868	if (a > 0 && b > 0 && s < 0) || (a < 0 && b < 0 && s >= 0) {
869		return 0, false
870	}
871	return s, true
872}