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}