cashflow.gno
62.92 Kb · 1553 lines
1// Package cashflow publishes a direct-method statement of cash flows over a
2// book this realm does not own, cannot write, and did not supply.
3//
4// # What it is, in one sentence
5//
6// An owner of a book in [r/books] declares which of their account codes hold
7// cash and what every other code's movement is for; this realm then reads the
8// entries in a window straight out of that ledger, hands them to [p/cashflow],
9// and freezes the resulting statement at a sequence number.
10//
11// # A PUBLISHED STATEMENT IS A CLAIM ABOUT A CLAIM — and here, about TWO
12//
13// [r/statements] already says this of itself and it is true here with one
14// extra layer. A book is a set of claims by its owner; nothing ties an account
15// reading `Cash 5000` to 5000 real ugnot anywhere. A cash-flow statement
16// derived from such a book inherits that completely, and then adds a SECOND
17// caller declaration on top: which codes are cash at all.
18//
19// [p/cashflow] states the consequence exactly and it is repeated here rather
20// than linked, because it is the single most important sentence about this
21// realm: *"A realm that declares a receivable as cash produces a plausible,
22// wrong statement. The declaration is the trust boundary."*
23//
24// So what this realm guarantees is narrow:
25//
26// - the ENTRIES are read from [r/books], not supplied by the publisher;
27// - the CLASSIFICATION is the publisher's own declared policy, frozen with
28// the statement so a reader sees what it was;
29// - the ARITHMETIC is [p/cashflow]'s, which nobody here can parameterise.
30//
31// That the chart describes anything real, and that the codes called cash hold
32// cash, are not claimed here and are not checkable from here.
33//
34// # Derived, not supplied — and the one figure that is supplied
35//
36// Every figure comes from [r/books] through its own read surface, except one.
37// [p/cashflow] cannot derive an OPENING cash position from a window-filtered
38// entry slice — a window carries period movement, not a cumulative position —
39// so `opening` is an argument to [Publish] and is echoed into the report
40// unchanged. [Closing] is `Opening + NetChange` and is therefore only as good
41// as a number the publisher typed.
42//
43// This realm does not leave it at that. See [OpeningReconciled]: when the
44// window extends past every entry the book holds, the sum of the declared cash
45// accounts' balances is the cumulative cash position, and that is comparable
46// to [Closing]. Where the comparison is available, [Publish] REFUSES to
47// publish a statement that fails it. Where it is not available, the report
48// says so and the figure stands as a declaration.
49//
50// # The window is REFUSED when it is too wide, never chunked
51//
52// [p/cashflow] is explicit that it does no paging and no merging, because
53// merging reports across pages would make the unresolved set an artifact of
54// the paging. Its entry cap is 4096, exactly [books.MaxEntriesPerBook], so the
55// primitive can always accept a whole book and the binding constraint is not
56// its cap — it is the cross-realm call budget, two reads per entry.
57//
58// So this realm declares [MaxWindowEntries] and refuses a window holding more,
59// telling the caller to narrow it. A chunk-and-merge would be cheaper to use
60// and would violate the primitive's own stated rule.
61//
62// # Window selection rests on a premise OWNED BY ANOTHER REALM
63//
64// Entries are located by binary search over entry ID, which is correct only
65// because `At` is non-decreasing in ID. That is not a property of [p/journal],
66// which refuses only a negative `at` and one at or before the close cutoff. It
67// is a property of [r/books], which dates every entry with
68// `runtime.ChainHeight()` at all three of its posting call sites and exposes
69// no `at`-taking entrypoint. [r/books] declares the obligation in its own
70// package doc.
71//
72// The failure mode if that premise ever breaks is the dangerous kind: a
73// statement over FEWER entries than the window holds, which still balances and
74// still reconciles. So the premise is checked rather than assumed —
75// [scan] verifies `At` is non-decreasing across every entry it reads (free,
76// since each `At` is already in hand) and probes the two entries immediately
77// outside the located range to prove the range is maximal. A violation panics
78// instead of publishing.
79//
80// RESIDUAL, stated with its boundary: that makes a violation at or adjacent to
81// the window edges loud. A violation deep outside the scanned range is
82// invisible without a full scan, which is the cost this design exists to
83// avoid. [OpeningReconciled] closes it for the one window shape where the
84// cumulative cross-check applies, and nowhere else.
85//
86// # Entry IDs are relied upon to be dense, and the argument is structural
87//
88// The binary search indexes entries 1..[books.EntryCount]. That is sound
89// because [p/journal] allocates `id := nextID; nextID++` and writes the entry
90// immediately, in one function with no error return between the two, into a
91// tree that has no delete; a post that fails panics and reverts the whole
92// transaction. So there are no gaps and no reuse. It is stated here because it
93// is a premise, not an observation: [EntryInfo] panics on an absent ID, so a
94// gap would abort the scan loudly rather than skew a figure.
95//
96// # A reversal lands in the period it was MADE
97//
98// [r/books] dates a reversing entry at the current height, not at the reversed
99// entry's date, and [p/cashflow] deliberately does not special-case reversals:
100// a reversing entry that touches cash moved cash. So reversing a prior-period
101// entry shows as movement in the CURRENT period and the corrected period's
102// published statement does not change. That is right for a cash-flow statement
103// and surprising to a reader expecting otherwise, which is why it is here.
104//
105// # NO CUSTODY, and no re-derivation seal
106//
107// There is no coinio import, no payable path, no fee, no withdrawal, no admin.
108// [Publish] and [DeclarePolicy] refuse a transaction carrying coins.
109//
110// There is deliberately NO `Verify` accessor re-deriving a stored report and
111// comparing. For a window that closed before the publication height such a
112// check is a CONSTANT TRUE by construction: entries are dated at the chain
113// height and the journal is append-only, so no entry can later fall into a
114// closed window, and the chart cannot lose an account. Shipping it would be
115// the fifth instance of the vacuous-true class this project keeps finding in
116// its own work. What ships instead is [Rescan], whose non-vacuity is exact and
117// narrow: it re-locates the window NOW and so tests the selection premise
118// above, not the ledger's contents.
119//
120// # No cross-owner aggregate beyond a count
121//
122// [r/books] scopes every read to one owner, and a cash-flow statement mixing
123// owners would assert a claim no single owner can make. [Total] is a count and
124// there is no way to enumerate publishers.
125//
126// # Limits, stated plainly
127//
128// - `opening` is caller-supplied, and verified only where
129// [OpeningReconciled] says it was.
130// - The policy is caller-declared and is the trust boundary. A wrong cash
131// declaration yields a plausible, wrong statement and no on-chain check
132// here can detect it.
133// - The cost bound is reasoned against a live precedent ([r/statements]
134// performs roughly 130 cross-realm reads per report), not measured in gas.
135// - Memos and account codes and names are an owner's free text in [r/books]
136// and are unsanitized there. Every one of them goes through [cell] before
137// it reaches [Render].
138// - The chart reconstruction here is a second copy of [r/statements]'
139// unexported `chartOf`. Promoting it would modify a live realm.
140//
141// [r/books]: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books
142// [r/statements]: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements
143// [p/cashflow]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow
144// [p/journal]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal
145// [p/periods]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods
146package cashflow
147
148import (
149 "chain/runtime"
150 "chain/runtime/unsafe"
151 "strconv"
152 "strings"
153
154 cf "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow"
155 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
156 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/periods"
157 "gno.land/p/nt/avl/v0"
158 "gno.land/p/nt/markdown/sanitize/v0"
159 "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books"
160)
161
162// RealmPath is this realm's own path, used to build Render links.
163const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/cashflow"
164
165// BooksPath is the ledger realm every entry here is read from.
166//
167// Taken BY VALUE from that realm's own exported constant rather than typed out
168// again, so this is the compile-time pin itself and not a second copy that
169// could drift. A reader checking which ledger a statement describes is reading
170// the import.
171const BooksPath = books.RealmPath
172
173// MaxWindowEntries bounds how many entries one [Publish] may read.
174//
175// This is the §7.4 refusal bound and the number has a derivation rather than a
176// feel. Reading one entry costs TWO cross-realm reads — [books.EntryInfo] for
177// the header and [books.EntryLegs] for the postings — because r/books returns
178// them separately. Add the chart reconstruction, which is one [books.Accounts],
179// one [books.Codes] at the chart cap and one [books.AccountInfo] per account,
180// and the live precedent [r/statements] pays about 130 reads per report. Add
181// the search: 2*ceil(log2(N+1)) + 4 header reads to locate and prove the range.
182//
183// At 256 the whole publish is about 666 plain reads, roughly five times a
184// report [r/statements] performs on this chain today. At r/books' own entry cap
185// of 4096 it would be about 8,350, which is the figure the discovery gate for
186// r/receivables used to defer this product.
187//
188// It is a REFUSAL and not a truncation: a window holding more entries than this
189// panics and names the count, and the caller narrows the window. Publishing a
190// statement over a silently-truncated entry set is the one failure this product
191// is least able to detect, because a short statement still balances.
192const MaxWindowEntries = 256
193
194// PolicyIsReplaceable records a decision a reader will look for.
195//
196// [DeclarePolicy] overwrites an owner's previous declaration and keeps no
197// history of it. That is safe only because every [Publish] FREEZES a copy of
198// the policy it used into the record: a published statement is readable with
199// the classification that produced it forever, via [PolicyOf], and changing
200// the live policy cannot restate a published figure.
201//
202// The alternative — versioned policies with reports pointing at a version — is
203// the same information with an extra indirection and one more place for the
204// two to disagree.
205const PolicyIsReplaceable = true
206
207// PublicationsAreUncapped mirrors [r/statements]' decision for the same three
208// reasons, which hold here unchanged: keys are per owner so an owner can only
209// fill their own history; no accessor and no [Render] path walks a publisher's
210// whole history; and there is no engine cap above this realm to mirror.
211const PublicationsAreUncapped = true
212
213// RenderPublicationLimit caps how many of a publisher's statements one
214// [Render] call lists.
215const RenderPublicationLimit = 20
216
217// RenderCensusLimit caps how many unresolved or net-zero rows one [Render]
218// call emits. A window may hold [MaxWindowEntries] entries and in a book with
219// no policy rules every one of them is unresolved.
220const RenderCensusLimit = 25
221
222// ---------------------------------------------------------------------------
223// State
224// ---------------------------------------------------------------------------
225
226// policy is one owner's frozen declaration. The slices are allocated here from
227// the caller's parsed strings and never handed out: [PolicyCash] and
228// [PolicyRules] re-encode them into a fresh string.
229type policy struct {
230 cash []string
231 rules []cf.Classification
232 at int64
233}
234
235// record is one frozen statement.
236//
237// It stores the [cf.Report] whole rather than copying its fields into a
238// parallel struct — a second field set is a second place for the two to
239// disagree — plus the three things the report cannot carry: the policy that
240// produced it, the entry-ID range that was read, and the cumulative
241// cross-check result.
242//
243// There is deliberately no seq field. Every read path reaches a record by
244// constructing pubKey from a seq it already holds, so a stored copy would only
245// ever be checked against its own source. [r/statements] records the same
246// decision and the reason it was found: a sabotage harness could not make
247// dropping the assignment fail any test.
248type record struct {
249 at int64
250 rep cf.Report
251 pol policy
252
253 // lo and hi are the inclusive entry-ID range read. hi < lo means the
254 // window held no entries, which is a legitimate quiet period.
255 lo uint64
256 hi uint64
257
258 // reconciled is whether the cumulative cross-check APPLIED. It is never
259 // stored false-after-failing: a failed check panics in [Publish], so this
260 // distinguishes "checked and agreed" from "not checkable", never
261 // "checked and disagreed".
262 reconciled bool
263
264 // cash is the declared cash accounts' summed balance at publication, read
265 // from [books.Balance]. Meaningful only when reconciled.
266 cash int64
267}
268
269// policies maps an owner's address string to *policy.
270var policies = avl.NewTree()
271
272// published maps "<owner>|<padded seq>" to *record.
273//
274// The pointer never leaves this file. Every accessor copies scalars out, so no
275// caller receives a handle onto stored state.
276var published = avl.NewTree()
277
278// counts maps an owner's address string to how many statements they have
279// published. It is the sequence allocator as well as the count.
280var counts = avl.NewTree()
281
282// pubCount is the global total, maintained alongside the tree so the index
283// page never iterates.
284var pubCount int
285
286// ---------------------------------------------------------------------------
287// Guards
288// ---------------------------------------------------------------------------
289
290// refuseCoins panics if a direct caller attached coins.
291//
292// This realm has no banker, no withdrawal path and nothing that can spend what
293// arrives, so a silently-accepted send would be stranded permanently and "we
294// hold no funds" would be true of the design and false of the balance.
295//
296// The residual is stated rather than guarded: coins can still reach this
297// realm's address through an intermediary's explicit banker send, which no
298// predicate here can see. What makes that survivable is the absence of any
299// consumption path, not this guard.
300//
301// The IsCurrent check is a no-op at both of today's call sites — a crossing
302// function's first `cur` is runtime-current by construction and stays current
303// when forwarded through a non-crossing call. It is here because this realm is
304// IMMUTABLE after addpkg, so a future third caller cannot be made safe later,
305// and a helper that reads Previous() off an unchecked realm parameter is a
306// designation-forgery shape whether or not it is reachable today.
307func refuseCoins(cur realm) {
308 if !cur.IsCurrent() {
309 panic("cashflow: refuseCoins called with a realm that is not current")
310 }
311 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
312 panic("cashflow: this realm holds no coins and has no withdrawal path; send none")
313 }
314}
315
316// ---------------------------------------------------------------------------
317// The cross-realm reads
318// ---------------------------------------------------------------------------
319
320// parseKind turns one of [r/books]' five kind words back into a
321// [journal.Kind] and PANICS on anything else.
322//
323// This is the same seam [r/statements] names in its own copy, and the reason
324// it panics is the same and is specific: the zero [journal.Kind] is reported
325// CREDIT-NORMAL by IsDebitNormal, so a silently-unparsed word would not drop
326// the row — it would misfile it and produce a statement that balances and
327// misstates. The five words are that type's own String output, and the suite
328// pins the round trip over all five rather than trusting it to stay true.
329func parseKind(s string) journal.Kind {
330 switch s {
331 case "asset":
332 return journal.Asset
333 case "liability":
334 return journal.Liability
335 case "equity":
336 return journal.Equity
337 case "revenue":
338 return journal.Revenue
339 case "expense":
340 return journal.Expense
341 }
342 panic("cashflow: " + BooksPath + " reported an unknown account kind " +
343 strconv.Quote(s))
344}
345
346// chartOf reads the owner's whole chart of accounts in one pass.
347//
348// ONE call for the codes, at r/books' own account cap. That iterator has no
349// seek and honours `from` by skipping, so one call walks the whole chart
350// however small the limit is — paging it would cost ceil(n/limit) full walks,
351// and [books.Codes]' `from` is INCLUSIVE so every paging caller also owes
352// itself a boundary dedup. A missed one double-counts an account, and a
353// double-counted balance is a defect this product cannot see: the statement
354// would still balance.
355//
356// The agreement check is not decoration. [books.Accounts] and [books.Codes]
357// are two reads through different code paths; if they disagree this refuses
358// rather than deriving over a truncated chart, because [cf.Derive] rejects a
359// posting naming an account absent from the chart and a truncated chart turns
360// an ordinary entry into ErrUnknownAccount pointing at the wrong layer.
361//
362// TAINT: the []string [books.Codes] returns was allocated under that realm and
363// arrives readonly-tainted. Nothing here mutates it or stores it — codes are
364// read out by value and the rows are allocated here.
365//
366// This is a second copy of [r/statements]' unexported chartOf, forced: that
367// realm is live and §6 forbids modifying it to promote the helper.
368func chartOf(owner address) []journal.Account {
369 n := books.Accounts(owner) // panics if the owner has no book
370 codes := books.Codes(owner, "", books.MaxAccountsPerBook)
371 if len(codes) != n {
372 panic("cashflow: " + BooksPath + " reports " + strconv.Itoa(n) +
373 " accounts but enumerated " + strconv.Itoa(len(codes)) +
374 "; refusing to report on a partial chart")
375 }
376
377 rows := make([]journal.Account, 0, n)
378 for _, code := range codes {
379 name, kind, debits, credits, balance := books.AccountInfo(owner, code)
380 a := journal.Account{
381 Code: code,
382 Name: name,
383 Kind: parseKind(kind),
384 Debits: debits,
385 Credits: credits,
386 }
387 // State its reach exactly, because the flattering reading is
388 // available: BOTH sides are journal.Account.Balance over the same two
389 // columns, so this is a CONSTANT TRUE against any disagreement between
390 // the two realms' arithmetic and must never be quoted as cross-checking
391 // it. What it catches is a transcription error in the four assignments
392 // above — swapping Debits and Credits, or reading a column off the
393 // wrong code — which is this realm's own code and the only place in
394 // this path where that could happen.
395 if a.Balance() != balance {
396 panic("cashflow: balance mismatch for account " + code +
397 "; this realm transcribed the chart wrongly")
398 }
399 rows = append(rows, a)
400 }
401 return rows
402}
403
404// lowerBound is the smallest entry ID in 1..n whose At is >= target, or n+1 if
405// there is none.
406//
407// Correct only under the non-decreasing-At premise in the package doc. A
408// violation makes this land somewhere arbitrary rather than wrong-by-one,
409// which is why [scan] probes the located boundaries rather than trusting the
410// result.
411func lowerBound(owner address, n uint64, target int64) uint64 {
412 lo, hi := uint64(1), n+1
413 for lo < hi {
414 mid := lo + (hi-lo)/2
415 at, _, _, _, _ := books.EntryInfo(owner, mid)
416 if at >= target {
417 hi = mid
418 } else {
419 lo = mid + 1
420 }
421 }
422 return lo
423}
424
425// scan locates and reads every entry in w, and returns them with the inclusive
426// ID range and whether the window extends past every entry the book holds.
427//
428// The last return value is what makes the cumulative cross-check sound; see
429// [OpeningReconciled]. It is true for an empty book as well, where every
430// balance is necessarily zero because balances come only from postings.
431//
432// Every check here is a panic rather than a return, because this is reached
433// from a mutating path and in Gno only a panic reverts a transaction.
434func scan(owner address, w periods.Window) ([]journal.Entry, uint64, uint64, bool) {
435 n := books.EntryCount(owner) // panics if the owner has no book
436 if n == 0 {
437 return nil, 1, 0, true
438 }
439 last := uint64(n)
440
441 firstAt, _, _, _, _ := books.EntryInfo(owner, 1)
442 lastAt, _, _, _, _ := books.EntryInfo(owner, last)
443 if lastAt < firstAt {
444 panic("cashflow: " + BooksPath + " entry 1 is dated " +
445 strconv.FormatInt(firstAt, 10) + " and entry " +
446 strconv.FormatUint(last, 10) + " is dated " +
447 strconv.FormatInt(lastAt, 10) +
448 "; entry dates are not non-decreasing and window selection is unsound")
449 }
450 pastEnd := w.Until > lastAt
451
452 lo := lowerBound(owner, last, w.From)
453 hi := lowerBound(owner, last, w.Until) - 1
454
455 // THERE ARE NO MAXIMALITY PROBES HERE, DELIBERATELY. Two used to sit at
456 // this point — reading the entry just below lo and the entry just above hi
457 // and refusing if either fell inside the window — and they were REMOVED
458 // because they are tautologies that can never fire. Both probed conditions
459 // are negations of [lowerBound]'s own loop invariants:
460 //
461 // hi is only ever assigned a mid at which at(mid) >= target was
462 // OBSERVED, so an exit value L <= n carries at(L) >= target, and
463 // `at(hi+1) < w.Until` is unreachable.
464 // lo is only ever assigned mid+1 at which at(mid) < target was
465 // OBSERVED, so an exit value L > 1 carries at(L-1) < target, and
466 // `at(lo-1) >= w.From` is unreachable.
467 //
468 // Neither derivation uses the non-decreasing-At premise, which is what
469 // makes them dead rather than merely redundant: they were written to catch
470 // a VIOLATION of that premise and are vacuous in exactly that case. They
471 // cost two cross-realm reads per Publish and advertised a guarantee they
472 // never provided. Measured, with a sabotage control, in
473 // TestLowerBoundsPostconditionsHoldOverAScrambledBook.
474 //
475 // WHAT CATCHES A MISLOCATED RANGE INSTEAD, all three live and pinned:
476 // - the endpoint check above, when entry 1 outranks entry n;
477 // - the `at < prev` check in the read loop below;
478 // - the `!w.Contains(at)` check in the read loop below, which is the one
479 // that fires on the common dip case.
480 // And when a dip makes the search return an EMPTY range — hi < lo, so the
481 // loop never runs and none of the three can speak — the net is Publish's
482 // cumulative cross-check against the live cash balance, which is why that
483 // check is not an optional nicety. See [OpeningReconciled] for its scope,
484 // and note the residual honestly: a window that does not extend past the
485 // last entry has no cumulative check, so a dip that produces an empty
486 // range inside a closed window is under-reported and only
487 // OpeningReconciled's false reading discloses it.
488 if hi < lo {
489 return nil, lo, hi, pastEnd
490 }
491 count := hi - lo + 1
492 if count > uint64(MaxWindowEntries) {
493 panic("cashflow: the window holds " + strconv.FormatUint(count, 10) +
494 " entries and the limit is " + strconv.Itoa(MaxWindowEntries) +
495 "; narrow the window. This realm refuses rather than reading a " +
496 "partial set, because a statement over fewer entries than the " +
497 "window holds still balances")
498 }
499
500 out := make([]journal.Entry, 0, int(count))
501 prev := int64(-1)
502 for id := lo; id <= hi; id++ {
503 at, memo, legs, reversalOf, total := books.EntryInfo(owner, id)
504 if at < prev {
505 panic("cashflow: entry " + strconv.FormatUint(id, 10) + " is dated " +
506 strconv.FormatInt(at, 10) + " after entry " +
507 strconv.FormatUint(id-1, 10) + " dated " +
508 strconv.FormatInt(prev, 10) +
509 "; entry dates are not non-decreasing and window selection is unsound")
510 }
511 prev = at
512 if !w.Contains(at) {
513 panic("cashflow: entry " + strconv.FormatUint(id, 10) + " is dated " +
514 strconv.FormatInt(at, 10) + " and is outside the window; the " +
515 "located range is wrong")
516 }
517 e := journal.Entry{
518 ID: id,
519 At: at,
520 Memo: memo,
521 Postings: parseLegs(owner, id, books.EntryLegs(owner, id)),
522 ReversalOf: reversalOf,
523 }
524 if len(e.Postings) != legs {
525 panic("cashflow: entry " + strconv.FormatUint(id, 10) + " reports " +
526 strconv.Itoa(legs) + " legs and encoded " +
527 strconv.Itoa(len(e.Postings)) + "; refusing a partial entry")
528 }
529 // Same reach as chartOf's balance check and worth the same caution:
530 // both sides are journal.Entry.Total over the same postings, so this
531 // catches a decoding error in parseLegs — a dropped leg, a flipped
532 // side, a misparsed amount — and is NOT a cross-check of r/books'
533 // arithmetic. A flipped side is the one that matters: it inverts the
534 // cash direction of the leg and the statement would still reconcile.
535 if e.Total() != total {
536 panic("cashflow: entry " + strconv.FormatUint(id, 10) +
537 " totals " + strconv.FormatInt(e.Total(), 10) +
538 " as decoded and " + strconv.FormatInt(total, 10) +
539 " as reported; this realm decoded the legs wrongly")
540 }
541 out = append(out, e)
542 }
543 return out, lo, hi, pastEnd
544}
545
546// parseLegs decodes the "code:side:amount,..." form [books.EntryLegs] returns.
547//
548// That form is [books.PostMulti]'s input format, so this is the inverse of a
549// parser living in the realm the string came from. It is duplicated rather
550// than shared for the same reason chartOf is: r/books is live.
551//
552// It is STRICTER than r/books' own parser in one direction and deliberately
553// so. r/books accepts "D", "DR" and "DEBIT" for a side because a human types
554// its input; [books.EntryLegs] emits only "D" or "C", so anything else here
555// means the format changed underneath this realm and the right outcome is a
556// refusal rather than a tolerant read. It is not stricter about leg count:
557// r/books refuses fewer than two on the way IN, and re-asserting that on the
558// way out would duplicate another realm's invariant in a place that cannot
559// enforce it.
560func parseLegs(owner address, id uint64, legs string) []journal.Posting {
561 where := " in entry " + strconv.FormatUint(id, 10) + " of " + owner.String()
562 if legs == "" {
563 panic("cashflow: " + BooksPath + " returned no legs" + where)
564 }
565 parts := strings.Split(legs, ",")
566 if len(parts) > journal.MaxPostingsPerEntry {
567 panic("cashflow: " + strconv.Itoa(len(parts)) + " legs" + where +
568 " exceeds journal.MaxPostingsPerEntry")
569 }
570 out := make([]journal.Posting, 0, len(parts))
571 for i, raw := range parts {
572 at := " at leg " + strconv.Itoa(i+1) + where
573 f := strings.Split(raw, ":")
574 if len(f) != 3 {
575 panic("cashflow: expected code:side:amount" + at)
576 }
577 if f[0] == "" {
578 panic("cashflow: empty account code" + at)
579 }
580 var side journal.Side
581 switch f[1] {
582 case "D":
583 side = journal.Debit
584 case "C":
585 side = journal.Credit
586 default:
587 panic("cashflow: " + BooksPath + " encoded side " +
588 strconv.Quote(f[1]) + at + "; want D or C")
589 }
590 amount, err := strconv.ParseInt(f[2], 10, 64)
591 if err != nil {
592 panic("cashflow: amount is not an integer" + at)
593 }
594 if amount <= 0 {
595 panic("cashflow: amount must be positive" + at)
596 }
597 out = append(out, journal.Posting{Account: f[0], Side: side, Amount: amount})
598 }
599 return out
600}
601
602// liveCash sums the declared cash accounts' balances now.
603//
604// Every cash code is a [journal.Asset] — [cf.Derive] refuses a policy that
605// declares a non-asset as cash — so each balance is debit-normal and the sum
606// is the book's cumulative net cash movement since its first posting. That is
607// the figure [OpeningReconciled] compares against.
608func liveCash(owner address, cash []string) int64 {
609 total := int64(0)
610 for _, code := range cash {
611 b, ok := books.Balance(owner, code)
612 if !ok {
613 panic("cashflow: declared cash account " + code +
614 " is not in the owner's chart")
615 }
616 sum, ok2 := checkedAdd(total, b)
617 if !ok2 {
618 panic("cashflow: the declared cash accounts' balances overflow int64")
619 }
620 total = sum
621 }
622 return total
623}
624
625// ---------------------------------------------------------------------------
626// Declaring a policy
627// ---------------------------------------------------------------------------
628
629// DeclarePolicy records the caller's own classification policy for their own
630// book, replacing any previous one.
631//
632// `cash` is a comma-separated list of account codes that hold cash, strictly
633// ascending — for example `1010,1020`. It must be non-empty and every code
634// must be an asset in the caller's chart.
635//
636// `rules` is a comma-separated list of `code:category` pairs, strictly
637// ascending by code, where category is `operating`, `investing` or
638// `financing` — for example `4000:operating,1500:investing,3000:financing`.
639// A code absent from `rules` is NOT defaulted to anything: an entry touching
640// it is reported unresolved, by name, because a default bucket turns a missing
641// declaration into a figure nobody questions. `rules` may be empty.
642//
643// The owner is `cur.Previous().Address()` and there is NO owner parameter.
644// [r/books] derives a book's owner the same way and no mutating function there
645// takes one either, so the only party who can declare a policy for a book is
646// the only party who can write to it. An owner parameter would move the trust
647// boundary to whoever called this.
648//
649// The declaration is validated against the caller's LIVE chart by deriving
650// over an empty entry set: [cf.Derive] validates the window, the chart and the
651// policy before it reads an entry, so a nil entry slice exercises exactly the
652// declaration's validity and nothing else. A policy that is valid now stays
653// valid, because [r/books] has no way to remove an account from a chart.
654func DeclarePolicy(cur realm, cash, rules string) {
655 refuseCoins(cur)
656 owner := cur.Previous().Address()
657
658 c := parseCash(cash)
659 r := parseRules(rules)
660
661 if _, err := cf.Derive(
662 chartOf(owner), nil,
663 periods.Window{From: 0, Until: 1},
664 cf.Policy{Cash: c, Rules: r},
665 0,
666 ); err != nil {
667 panic("cashflow: " + err.Error())
668 }
669
670 policies.Set(owner.String(), &policy{
671 cash: c,
672 rules: r,
673 at: runtime.ChainHeight(),
674 })
675}
676
677// parseCash decodes the cash-code list. Order and duplication are NOT checked
678// here: [cf.Derive]'s validatePolicy requires strictly ascending codes and
679// says so with its own error, and a second copy of that rule here would be a
680// second place for the two to disagree.
681func parseCash(cash string) []string {
682 if cash == "" {
683 panic("cashflow: no cash accounts declared; format is \"1010,1020\"")
684 }
685 parts := strings.Split(cash, ",")
686 if len(parts) > books.MaxAccountsPerBook {
687 panic("cashflow: " + strconv.Itoa(len(parts)) +
688 " cash codes exceeds books.MaxAccountsPerBook")
689 }
690 out := make([]string, 0, len(parts))
691 for i, raw := range parts {
692 code := strings.TrimSpace(raw)
693 if code == "" {
694 panic("cashflow: empty cash code at position " + strconv.Itoa(i+1))
695 }
696 out = append(out, code)
697 }
698 return out
699}
700
701// parseRules decodes the code:category list. An empty string is a legitimate
702// empty policy — every entry touching a non-cash account is then unresolved,
703// which is a true report of a book whose owner has declared nothing.
704func parseRules(rules string) []cf.Classification {
705 if strings.TrimSpace(rules) == "" {
706 return nil
707 }
708 parts := strings.Split(rules, ",")
709 if len(parts) > books.MaxAccountsPerBook {
710 panic("cashflow: " + strconv.Itoa(len(parts)) +
711 " rules exceeds books.MaxAccountsPerBook")
712 }
713 out := make([]cf.Classification, 0, len(parts))
714 for i, raw := range parts {
715 at := " at rule " + strconv.Itoa(i+1)
716 f := strings.Split(strings.TrimSpace(raw), ":")
717 if len(f) != 2 {
718 panic("cashflow: expected code:category" + at)
719 }
720 code := strings.TrimSpace(f[0])
721 if code == "" {
722 panic("cashflow: empty account code" + at)
723 }
724 out = append(out, cf.Classification{
725 Code: code,
726 Category: parseCategory(strings.TrimSpace(f[1])),
727 })
728 }
729 return out
730}
731
732// parseCategory turns a category word into a [cf.Category] and panics on
733// anything else. The three words are that type's own String output; the suite
734// pins the round trip over all three rather than trusting another package's
735// method to stay put.
736//
737// It panics rather than returning the zero Category because the zero value is
738// deliberately invalid in [p/cashflow] and a silently-zero rule would reach
739// validatePolicy as ErrInvalidCategory, pointing a reader at the wrong layer.
740func parseCategory(s string) cf.Category {
741 switch s {
742 case "operating":
743 return cf.CategoryOperating
744 case "investing":
745 return cf.CategoryInvesting
746 case "financing":
747 return cf.CategoryFinancing
748 }
749 panic("cashflow: unknown category " + strconv.Quote(s) +
750 "; want operating, investing or financing")
751}
752
753// ---------------------------------------------------------------------------
754// Publishing
755// ---------------------------------------------------------------------------
756
757// Publish freezes a statement of cash flows over [from, until) and returns its
758// sequence number, which counts from 1 per publisher.
759//
760// The window is half-open: `from` is included and `until` is not. The unit is
761// whatever [r/books] dates with, which is block height.
762//
763// `opening` is the caller's declared opening cash position. It cannot be
764// derived from a windowed entry set and is echoed into the report unchanged;
765// see [OpeningReconciled] for the one window shape where this realm can and
766// does check it.
767//
768// The owner is `cur.Previous().Address()`. There is NO owner parameter and no
769// way to publish over someone else's book, for the reason given at
770// [DeclarePolicy].
771//
772// It PANICS, rather than returning an error, on: no book, no declared policy,
773// an invalid window, a window holding more than [MaxWindowEntries] entries, a
774// violation of the entry-dating premise at either window edge, a disagreement
775// between the chart's two reads or an entry's two reads, any [cf.Derive] error,
776// and a failed cumulative cross-check. An error return would not revert a Gno
777// transaction; only a panic does.
778func Publish(cur realm, from, until, opening int64) int64 {
779 refuseCoins(cur)
780 owner := cur.Previous().Address()
781
782 pol := mustPolicy(owner)
783 w := periods.Window{From: from, Until: until}
784 if !w.Valid() {
785 panic("cashflow: window [" + strconv.FormatInt(from, 10) + ", " +
786 strconv.FormatInt(until, 10) + ") is not a period; want 0 <= from < until")
787 }
788
789 chart := chartOf(owner)
790 entries, lo, hi, pastEnd := scan(owner, w)
791
792 // A COPY of the stored policy goes to Derive, not the stored slices. The
793 // primitive does not mutate a Policy, but handing stored state to code in
794 // another package is the kind of thing that stays safe only until that
795 // package changes, and this realm is immutable after addpkg.
796 rep, err := cf.Derive(chart, entries, w, cf.Policy{
797 Cash: copyCodes(pol.cash),
798 Rules: copyRules(pol.rules),
799 }, opening)
800 if err != nil {
801 panic("cashflow: " + err.Error())
802 }
803
804 // Derive filters the window itself, so this asserts that the scan handed
805 // it exactly the window's entries and nothing it had to discard. A
806 // disagreement would mean scan's range check above is wrong, and the two
807 // are independent code.
808 if rep.Entries != len(entries) {
809 panic("cashflow: " + strconv.Itoa(len(entries)) +
810 " entries were read and the report counts " +
811 strconv.Itoa(rep.Entries) + " in the window")
812 }
813
814 // THE CUMULATIVE CROSS-CHECK. Sound only when the window extends past
815 // every entry the book holds: then the window's net change plus a correct
816 // opening is the book's whole cash movement, which is what the declared
817 // cash accounts' balances sum to. See [OpeningReconciled] for why a
818 // failure is a refusal rather than a flag on the record.
819 live := int64(0)
820 if pastEnd {
821 live = liveCash(owner, pol.cash)
822 if live != rep.Closing {
823 panic("cashflow: refusing to publish. The window extends past every " +
824 "entry in the book, so the declared opening of " +
825 strconv.FormatInt(opening, 10) + " plus the window's net change of " +
826 strconv.FormatInt(rep.NetChange, 10) + " should equal the declared " +
827 "cash accounts' balance of " + strconv.FormatInt(live, 10) +
828 ", and it is " + strconv.FormatInt(rep.Closing, 10) +
829 ". Either the opening is wrong or the entry scan is incomplete")
830 }
831 }
832
833 seq := int64(ownerCount(owner.String()) + 1)
834 published.Set(pubKey(owner.String(), seq), &record{
835 at: runtime.ChainHeight(),
836 rep: rep,
837 pol: policy{cash: copyCodes(pol.cash), rules: copyRules(pol.rules), at: pol.at},
838 lo: lo,
839 hi: hi,
840 reconciled: pastEnd,
841 cash: live,
842 })
843 counts.Set(owner.String(), int(seq))
844 pubCount++
845 return seq
846}
847
848// ---------------------------------------------------------------------------
849// Reading a published statement
850// ---------------------------------------------------------------------------
851
852// Count is how many statements an owner has published.
853func Count(owner address) int { return ownerCount(owner.String()) }
854
855// Latest is the newest sequence number for an owner, and false if they have
856// published none.
857func Latest(owner address) (int64, bool) {
858 n := ownerCount(owner.String())
859 if n == 0 {
860 return 0, false
861 }
862 return int64(n), true
863}
864
865// Total is how many statements every publisher has published together. There
866// is deliberately no way to enumerate the publishers; see the package doc.
867func Total() int { return pubCount }
868
869// Has reports whether a statement exists.
870func Has(owner address, seq int64) bool {
871 return published.Has(pubKey(owner.String(), seq))
872}
873
874// PublishedAt is the block height a statement was frozen at.
875func PublishedAt(owner address, seq int64) int64 {
876 return mustRecord(owner, seq).at
877}
878
879// Window is the period a statement covers, half-open: `from` is in, `until` is
880// out.
881func Window(owner address, seq int64) (from, until int64) {
882 w := mustRecord(owner, seq).rep.Window
883 return w.From, w.Until
884}
885
886// Totals is the four cumulative figures. `closing` is `opening + netChange`,
887// and `opening` was supplied by the publisher — see [OpeningReconciled] for
888// whether this realm was able to check it.
889//
890// `unattributed` is the net movement of the entries in the unresolved census
891// and is part of `netChange`, not additional to it.
892func Totals(owner address, seq int64) (opening, netChange, unattributed, closing int64) {
893 r := mustRecord(owner, seq).rep
894 return r.Opening, r.NetChange, r.Unattributed, r.Closing
895}
896
897// LineOf is one category's gross flows. `category` is `operating`, `investing`
898// or `financing`; anything else panics.
899//
900// Inflows and outflows are GROSS and both non-negative. A category with 1,000
901// in and 1,000 out is not the same business as one with nothing happening, and
902// a net of zero is all a netted figure could say about either.
903func LineOf(owner address, seq int64, category string) (inflows, outflows, net int64, entries int) {
904 l, err := mustRecord(owner, seq).rep.Line(parseCategory(category))
905 if err != nil {
906 panic("cashflow: " + err.Error())
907 }
908 return l.Inflows, l.Outflows, l.Net(), l.Entries
909}
910
911// Scope is the statement's own coverage line, and it is the thing to read
912// before quoting any figure above.
913//
914// - `entries` is how many entries fell in the window, including the ones
915// that moved no cash. Zero is a quiet period, not an error.
916// - `cashEntries` is how many touched a declared cash account at all.
917// - `netZero` is how many touched cash and moved none on net — a transfer
918// between two cash accounts. Classification is not attempted for them:
919// there is nothing to attribute.
920// - `unresolved` is how many could not be classified. A statement with
921// unresolved entries is still arithmetically whole — the residual is in
922// `unattributed` — but its SPLIT is incomplete.
923func Scope(owner address, seq int64) (entries, cashEntries, netZero, unresolved int) {
924 r := mustRecord(owner, seq).rep
925 return r.Entries, r.CashEntries, len(r.NetZero), len(r.Unresolved)
926}
927
928// EntryRange is the inclusive [r/books] entry-ID range this statement was
929// derived from. `ok` is false when the window held no entries.
930//
931// It is stored because it is the evidence for the statement's scope: a reader
932// can take the two IDs to [r/books] and read the entries themselves. A
933// statement is the only artifact here, and this is what makes it checkable
934// against its source.
935func EntryRange(owner address, seq int64) (lo, hi uint64, ok bool) {
936 r := mustRecord(owner, seq)
937 if r.hi < r.lo {
938 return 0, 0, false
939 }
940 return r.lo, r.hi, true
941}
942
943// Complete reports whether every in-window entry that moved cash was
944// attributed to a category.
945//
946// A complete statement is not a correct one. Completeness is a property of the
947// POLICY's coverage, and a policy that classifies every account wrongly is
948// complete. [p/cashflow] says this of its own accessor and it is worth
949// repeating at the realm boundary, where the word will be quoted.
950func Complete(owner address, seq int64) bool {
951 return mustRecord(owner, seq).rep.Complete()
952}
953
954// Reconciles reports whether the attributed lines plus the unattributed
955// residual equal the net change.
956//
957// On a statement published here it is ALWAYS TRUE, because [cf.Derive]
958// produces it that way and nothing between there and storage touches the
959// figures. It is exposed anyway, and the reason is narrow: it is the one check
960// a reader can run against a stored report without re-reading the ledger, and
961// an exposed constant-true that is DOCUMENTED as one is better than a reader
962// assuming the invariant holds without ever being told where it comes from.
963// It must not be quoted as evidence that a statement is right.
964func Reconciles(owner address, seq int64) bool {
965 return mustRecord(owner, seq).rep.Reconciles()
966}
967
968// OpeningReconciled reports whether this realm was able to check the
969// publisher's declared opening position against the ledger — and, because a
970// failed check REFUSES to publish, it being true means the check passed.
971//
972// # When it applies
973//
974// Only when the window extended past every entry the book held at publication.
975// Then the window's net change covers all movement from `from` onward, the
976// declared cash accounts' summed balance covers all movement ever, and the
977// difference between them is exactly the movement before `from` — which is
978// what a correct opening is. An empty book qualifies too: with no postings
979// every balance is zero, so the check requires an opening of zero.
980//
981// It does NOT apply to a window that closed before later entries were posted.
982// There the balances carry post-window movement the statement rightly excludes,
983// and comparing them would be comparing two different periods.
984//
985// # What it proves, and the second thing it proves
986//
987// First, that the declared opening is consistent with the ledger. Second — and
988// this is why it is worth its cost — that THE ENTRY SCAN WAS COMPLETE. A scan
989// that silently missed entries produces a short net change, and a short net
990// change fails this comparison. That is the one mechanical control this realm
991// has against the silent-under-reporting failure mode the package doc names,
992// and it covers exactly the window shape where it is available.
993//
994// # What it does not prove
995//
996// Nothing about whether the codes called cash hold cash. Both sides of the
997// comparison are the same owner's self-report, read through the same realm.
998// It is an internal consistency check, not a custody attestation.
999func OpeningReconciled(owner address, seq int64) bool {
1000 return mustRecord(owner, seq).reconciled
1001}
1002
1003// ReconciledCash is the declared cash accounts' summed balance at publication,
1004// and false if [OpeningReconciled] does not apply to this statement. The
1005// figure is meaningless when ok is false and is returned as zero.
1006func ReconciledCash(owner address, seq int64) (int64, bool) {
1007 r := mustRecord(owner, seq)
1008 if !r.reconciled {
1009 return 0, false
1010 }
1011 return r.cash, true
1012}
1013
1014// UnresolvedCount is how many entries could not be classified.
1015func UnresolvedCount(owner address, seq int64) int {
1016 return len(mustRecord(owner, seq).rep.Unresolved)
1017}
1018
1019// UnresolvedAt is one unresolved entry, by index into the census.
1020//
1021// - `reason` is `conflict` (the non-cash legs classify to more than one
1022// category), `undeclared` (a non-cash leg's account has no policy rule) or
1023// `unbalanced`.
1024// - `net` is the entry's net cash movement, which is real movement the
1025// statement could not attribute.
1026// - `categories` is the conflicting categories, comma-separated, for a
1027// conflict.
1028// - `codes` is the undeclared account codes, comma-separated, for an
1029// undeclared entry. Declaring them may resolve the entry, which is why
1030// `undeclared` takes precedence over `conflict` when both apply.
1031func UnresolvedAt(owner address, seq int64, i int) (id uint64, reason string, net int64, categories, codes string) {
1032 rows := mustRecord(owner, seq).rep.Unresolved
1033 if i < 0 || i >= len(rows) {
1034 panic("cashflow: unresolved index " + strconv.Itoa(i) + " out of range 0.." +
1035 strconv.Itoa(len(rows)-1))
1036 }
1037 u := rows[i]
1038 cats := make([]string, 0, len(u.Categories))
1039 for _, c := range u.Categories {
1040 cats = append(cats, c.String())
1041 }
1042 return u.EntryID, u.Reason.String(), u.Net,
1043 strings.Join(cats, ","), strings.Join(u.Codes, ",")
1044}
1045
1046// NetZeroCount is how many in-window entries touched cash and moved none on
1047// net.
1048func NetZeroCount(owner address, seq int64) int {
1049 return len(mustRecord(owner, seq).rep.NetZero)
1050}
1051
1052// NetZeroAt is one net-zero entry's ID, by index.
1053func NetZeroAt(owner address, seq int64, i int) uint64 {
1054 ids := mustRecord(owner, seq).rep.NetZero
1055 if i < 0 || i >= len(ids) {
1056 panic("cashflow: net-zero index " + strconv.Itoa(i) + " out of range 0.." +
1057 strconv.Itoa(len(ids)-1))
1058 }
1059 return ids[i]
1060}
1061
1062// PolicyOf is the classification policy that produced a published statement,
1063// re-encoded in [DeclarePolicy]'s own input format.
1064//
1065// It is the statement's policy, frozen with it, NOT the owner's current one.
1066// [PolicyIsReplaceable] explains why both exist: a reader of a published
1067// figure needs the declaration that produced it, and an owner needs to be able
1068// to correct a declaration without restating history.
1069func PolicyOf(owner address, seq int64) (cash, rules string) {
1070 p := mustRecord(owner, seq).pol
1071 return encodeCash(p.cash), encodeRules(p.rules)
1072}
1073
1074// ---------------------------------------------------------------------------
1075// Reading the live ledger
1076// ---------------------------------------------------------------------------
1077
1078// HasPolicy reports whether an owner has declared a policy.
1079func HasPolicy(owner address) bool { return policies.Has(owner.String()) }
1080
1081// PolicyCash is the owner's CURRENT declared cash codes, comma-separated.
1082func PolicyCash(owner address) string {
1083 return encodeCash(mustPolicy(owner).cash)
1084}
1085
1086// PolicyRules is the owner's CURRENT declared rules, comma-separated
1087// `code:category` pairs.
1088func PolicyRules(owner address) string {
1089 return encodeRules(mustPolicy(owner).rules)
1090}
1091
1092// PolicyDeclaredAt is the height the owner's current policy was declared at.
1093func PolicyDeclaredAt(owner address) int64 { return mustPolicy(owner).at }
1094
1095// LiveCash is the declared cash accounts' summed balance right now.
1096//
1097// This is the book's cumulative net cash movement since its first posting, and
1098// it is a LIVE figure — it moves with every posting. It is not a published
1099// statement and must not be quoted as one.
1100func LiveCash(owner address) int64 {
1101 return liveCash(owner, mustPolicy(owner).cash)
1102}
1103
1104// Rescan re-locates a published statement's window in the ledger NOW and
1105// reports whether the entry count and the net change still agree with what was
1106// frozen.
1107//
1108// # Why this exists and the re-derivation accessor does not
1109//
1110// The package doc says there is no `Verify` here, because re-deriving a
1111// statement over a window that has closed is a CONSTANT TRUE: entries are
1112// dated at the chain height and the journal is append-only, so nothing can
1113// later fall into a closed window.
1114//
1115// This is the part of that comparison which is NOT constant. Window selection
1116// is a binary search resting on a premise owned by [r/books] — that entry
1117// dates are non-decreasing in entry ID — and nothing freezes that premise.
1118// A future change there could break it silently. Re-locating the range and
1119// comparing is therefore a real test of the selection, and it is the only
1120// check here whose answer can change for a reason that matters.
1121//
1122// # What it costs and what it does not cover
1123//
1124// It is a plain read, so the caller pays. It re-reads the window's entries,
1125// which is up to 2*[MaxWindowEntries] cross-realm reads plus the search.
1126//
1127// It compares the entry COUNT and the NET CHANGE, not every line. The net
1128// change is the figure that moves if the entry set changed, and it is derived
1129// from the postings rather than from the policy, so a disagreement points at
1130// the ledger or the selection rather than at a reclassification. A false
1131// return means do not trust the statement and does not say which of the two
1132// it is.
1133func Rescan(owner address, seq int64) (entries int, netChange int64, agrees bool) {
1134 r := mustRecord(owner, seq)
1135 es, _, _, _ := scan(owner, r.rep.Window)
1136 rep, err := cf.Derive(chartOf(owner), es, r.rep.Window, cf.Policy{
1137 Cash: copyCodes(r.pol.cash),
1138 Rules: copyRules(r.pol.rules),
1139 }, r.rep.Opening)
1140 if err != nil {
1141 panic("cashflow: " + err.Error())
1142 }
1143 return rep.Entries, rep.NetChange,
1144 rep.Entries == r.rep.Entries && rep.NetChange == r.rep.NetChange
1145}
1146
1147// Height is the current block height, so a reader can tell how old a
1148// statement is without a second query.
1149func Height() int64 { return runtime.ChainHeight() }
1150
1151// Ledger is the realm every entry here is read from.
1152func Ledger() string { return BooksPath }
1153
1154// ---------------------------------------------------------------------------
1155// Internals
1156// ---------------------------------------------------------------------------
1157
1158// pubKey is owner|paddedSeq. The sequence is left-padded to 19 digits — the
1159// width of MaxInt64 — so lexicographic order over an owner's prefix is numeric
1160// order over their statements. An unpadded decimal would sort "10" before "9".
1161// The convention is r/invoices' invKey, adopted rather than re-derived.
1162func pubKey(owner string, seq int64) string {
1163 s := strconv.FormatInt(seq, 10)
1164 for len(s) < 19 {
1165 s = "0" + s
1166 }
1167 return owner + "|" + s
1168}
1169
1170// ownerCount reads a publisher's count, treating an absent key as zero.
1171//
1172// avl.Tree.Get returns ONE value and cannot distinguish an absent key from a
1173// nil-valued one, so the two-value type assertion carries the existence answer
1174// in one traversal. Only this file ever Sets, and it only ever Sets an int.
1175func ownerCount(owner string) int {
1176 n, ok := counts.Get(owner).(int)
1177 if !ok {
1178 return 0
1179 }
1180 return n
1181}
1182
1183func mustRecord(owner address, seq int64) *record {
1184 r, ok := published.Get(pubKey(owner.String(), seq)).(*record)
1185 if !ok {
1186 panic("cashflow: no statement " + strconv.FormatInt(seq, 10) +
1187 " for " + owner.String())
1188 }
1189 return r
1190}
1191
1192func mustPolicy(owner address) *policy {
1193 p, ok := policies.Get(owner.String()).(*policy)
1194 if !ok {
1195 panic("cashflow: " + owner.String() + " has declared no policy; call " +
1196 "DeclarePolicy first. Which codes hold cash cannot be guessed and " +
1197 "a guess would be a figure nobody questions")
1198 }
1199 return p
1200}
1201
1202func copyCodes(in []string) []string {
1203 out := make([]string, len(in))
1204 copy(out, in)
1205 return out
1206}
1207
1208func copyRules(in []cf.Classification) []cf.Classification {
1209 out := make([]cf.Classification, len(in))
1210 copy(out, in)
1211 return out
1212}
1213
1214func encodeCash(cash []string) string { return strings.Join(cash, ",") }
1215
1216func encodeRules(rules []cf.Classification) string {
1217 parts := make([]string, 0, len(rules))
1218 for _, r := range rules {
1219 parts = append(parts, r.Code+":"+r.Category.String())
1220 }
1221 return strings.Join(parts, ",")
1222}
1223
1224// checkedAdd is int64 addition that reports overflow instead of wrapping.
1225// Local because a /p/ import for one function would be a dependency this
1226// realm's closure does not need, and the same four lines appear in p/journal,
1227// p/periods and p/cashflow.
1228func checkedAdd(a, b int64) (int64, bool) {
1229 s := a + b
1230 if (a > 0 && b > 0 && s < 0) || (a < 0 && b < 0 && s >= 0) {
1231 return 0, false
1232 }
1233 return s, true
1234}
1235
1236// ---------------------------------------------------------------------------
1237// Render
1238// ---------------------------------------------------------------------------
1239
1240// Render serves the index at "", a publisher's page at "<address>", and one
1241// statement at "<address>/<seq>".
1242//
1243// Every caller-supplied string reaching this page — an account code, an entry
1244// memo — is an owner's free text in [r/books] and arrives unsanitized, because
1245// that realm does not sanitize what it stores. Free text reaching a page
1246// unescaped is the one injection-shaped surface a realm with no custody still
1247// has, and the bytes coming from a THIRD realm's state does not make them
1248// safer. Everything goes through [cell].
1249func Render(path string) string {
1250 p := strings.TrimSpace(strings.Trim(path, "/"))
1251 if p == "" {
1252 return renderIndex()
1253 }
1254 if i := strings.Index(p, "/"); i >= 0 {
1255 owner := address(p[:i])
1256 seq, err := strconv.ParseInt(p[i+1:], 10, 64)
1257 if err != nil {
1258 return "# Not found\n\nExpected `<address>/<sequence>`.\n"
1259 }
1260 return renderStatement(owner, seq)
1261 }
1262 return renderPublisher(address(p))
1263}
1264
1265func renderIndex() string {
1266 var sb strings.Builder
1267 sb.WriteString("# Statements of cash flows\n\n")
1268 sb.WriteString("Direct-method cash-flow statements derived from books in `")
1269 sb.WriteString(BooksPath)
1270 sb.WriteString("`.\n\n")
1271 sb.WriteString("A statement here is **a claim about a claim**. The entries are read ")
1272 sb.WriteString("from the ledger rather than supplied, and the arithmetic is ")
1273 sb.WriteString("`p/cashflow`'s — but which accounts hold *cash* is declared by the ")
1274 sb.WriteString("book's own owner, and nothing on chain can check that declaration. ")
1275 sb.WriteString("The opening position is declared too; a statement says whether this ")
1276 sb.WriteString("realm was able to reconcile it.\n\n")
1277 sb.WriteString("- Statements published: **")
1278 sb.WriteString(strconv.Itoa(pubCount))
1279 sb.WriteString("**\n- Window entry limit: **")
1280 sb.WriteString(strconv.Itoa(MaxWindowEntries))
1281 sb.WriteString("** entries — a wider window is refused, never truncated\n- Height: **")
1282 sb.WriteString(strconv.FormatInt(runtime.ChainHeight(), 10))
1283 sb.WriteString("**\n\n")
1284 sb.WriteString("There is no way to enumerate publishers here: a cash-flow statement ")
1285 sb.WriteString("is one owner's, and an index of them would be a list this realm has ")
1286 sb.WriteString("no standing to publish. View one at `")
1287 sb.WriteString(RealmPath)
1288 sb.WriteString(":<address>`.\n")
1289 return sb.String()
1290}
1291
1292func renderPublisher(owner address) string {
1293 n := ownerCount(owner.String())
1294 if n == 0 {
1295 return "# No statements\n\n`" + cell(owner.String()) +
1296 "` has published none.\n"
1297 }
1298 var sb strings.Builder
1299 sb.WriteString("# Statements of cash flows\n\n**Publisher:** `")
1300 sb.WriteString(cell(owner.String()))
1301 sb.WriteString("`\n\n**Published:** ")
1302 sb.WriteString(strconv.Itoa(n))
1303 sb.WriteString("\n\n")
1304
1305 shown := n
1306 if shown > RenderPublicationLimit {
1307 shown = RenderPublicationLimit
1308 }
1309 sb.WriteString("| seq | window | net change | closing | entries | unresolved | opening reconciled |\n")
1310 sb.WriteString("|---|---|---|---|---|---|---|\n")
1311 for seq := int64(n); seq > int64(n-shown); seq-- {
1312 r := mustRecord(owner, seq)
1313 sb.WriteString("| [")
1314 sb.WriteString(strconv.FormatInt(seq, 10))
1315 sb.WriteString("](")
1316 sb.WriteString(RealmPath)
1317 sb.WriteString(":")
1318 sb.WriteString(owner.String())
1319 sb.WriteString("/")
1320 sb.WriteString(strconv.FormatInt(seq, 10))
1321 sb.WriteString(") | [")
1322 sb.WriteString(strconv.FormatInt(r.rep.Window.From, 10))
1323 sb.WriteString(", ")
1324 sb.WriteString(strconv.FormatInt(r.rep.Window.Until, 10))
1325 sb.WriteString(") | ")
1326 sb.WriteString(strconv.FormatInt(r.rep.NetChange, 10))
1327 sb.WriteString(" | ")
1328 sb.WriteString(strconv.FormatInt(r.rep.Closing, 10))
1329 sb.WriteString(" | ")
1330 sb.WriteString(strconv.Itoa(r.rep.Entries))
1331 sb.WriteString(" | ")
1332 sb.WriteString(strconv.Itoa(len(r.rep.Unresolved)))
1333 sb.WriteString(" | ")
1334 sb.WriteString(yesNo(r.reconciled))
1335 sb.WriteString(" |\n")
1336 }
1337 if n > shown {
1338 sb.WriteString("\n_Showing the newest ")
1339 sb.WriteString(strconv.Itoa(shown))
1340 sb.WriteString(" of ")
1341 sb.WriteString(strconv.Itoa(n))
1342 sb.WriteString("._\n")
1343 }
1344 return sb.String()
1345}
1346
1347func renderStatement(owner address, seq int64) string {
1348 if !published.Has(pubKey(owner.String(), seq)) {
1349 return "# Not found\n\nNo statement " + strconv.FormatInt(seq, 10) +
1350 " for `" + cell(owner.String()) + "`.\n"
1351 }
1352 r := mustRecord(owner, seq)
1353 var sb strings.Builder
1354
1355 sb.WriteString("# Statement of cash flows #")
1356 sb.WriteString(strconv.FormatInt(seq, 10))
1357 sb.WriteString("\n\n**Publisher:** `")
1358 sb.WriteString(cell(owner.String()))
1359 sb.WriteString("`\n\n**Window:** [")
1360 sb.WriteString(strconv.FormatInt(r.rep.Window.From, 10))
1361 sb.WriteString(", ")
1362 sb.WriteString(strconv.FormatInt(r.rep.Window.Until, 10))
1363 sb.WriteString(") — half-open, by block height\n\n**Frozen at height:** ")
1364 sb.WriteString(strconv.FormatInt(r.at, 10))
1365 sb.WriteString("\n\n**Ledger:** `")
1366 sb.WriteString(BooksPath)
1367 sb.WriteString("`\n\n")
1368
1369 sb.WriteString("## Activities\n\n| category | inflows | outflows | net | entries |\n")
1370 sb.WriteString("|---|---|---|---|---|\n")
1371 for _, l := range r.rep.Lines {
1372 sb.WriteString("| ")
1373 sb.WriteString(l.Category.String())
1374 sb.WriteString(" | ")
1375 sb.WriteString(strconv.FormatInt(l.Inflows, 10))
1376 sb.WriteString(" | ")
1377 sb.WriteString(strconv.FormatInt(l.Outflows, 10))
1378 sb.WriteString(" | ")
1379 sb.WriteString(strconv.FormatInt(l.Net(), 10))
1380 sb.WriteString(" | ")
1381 sb.WriteString(strconv.Itoa(l.Entries))
1382 sb.WriteString(" |\n")
1383 }
1384
1385 sb.WriteString("\n## Reconciliation\n\n| | |\n|---|---|\n| opening (declared) | ")
1386 sb.WriteString(strconv.FormatInt(r.rep.Opening, 10))
1387 sb.WriteString(" |\n| net change | ")
1388 sb.WriteString(strconv.FormatInt(r.rep.NetChange, 10))
1389 sb.WriteString(" |\n| of which unattributed | ")
1390 sb.WriteString(strconv.FormatInt(r.rep.Unattributed, 10))
1391 sb.WriteString(" |\n| closing | ")
1392 sb.WriteString(strconv.FormatInt(r.rep.Closing, 10))
1393 sb.WriteString(" |\n\n")
1394
1395 if r.reconciled {
1396 sb.WriteString("> **Opening reconciled.** The window extended past every entry ")
1397 sb.WriteString("in the book, so the declared cash accounts' balance of **")
1398 sb.WriteString(strconv.FormatInt(r.cash, 10))
1399 sb.WriteString("** is the whole book's cash movement, and it equals the closing ")
1400 sb.WriteString("figure above. That also means the entry scan was complete. It ")
1401 sb.WriteString("says nothing about whether those accounts hold cash.\n\n")
1402 } else {
1403 sb.WriteString("> **Opening NOT reconciled.** The window closed before later ")
1404 sb.WriteString("entries were posted, so the cash accounts' current balances ")
1405 sb.WriteString("carry movement this statement rightly excludes and cannot be ")
1406 sb.WriteString("compared to it. The opening position above is the publisher's ")
1407 sb.WriteString("declaration and this realm did not check it.\n\n")
1408 }
1409
1410 sb.WriteString("## Scope\n\n")
1411 if r.hi < r.lo {
1412 sb.WriteString("- Entries read: **none** — the window holds no entries\n")
1413 } else {
1414 sb.WriteString("- Entries read: **")
1415 sb.WriteString(strconv.FormatUint(r.hi-r.lo+1, 10))
1416 sb.WriteString("**, ledger IDs ")
1417 sb.WriteString(strconv.FormatUint(r.lo, 10))
1418 sb.WriteString("–")
1419 sb.WriteString(strconv.FormatUint(r.hi, 10))
1420 sb.WriteString(" (read them in `")
1421 sb.WriteString(BooksPath)
1422 sb.WriteString("` to check this statement against its source)\n")
1423 }
1424 sb.WriteString("- In-window entries: **")
1425 sb.WriteString(strconv.Itoa(r.rep.Entries))
1426 sb.WriteString("**, of which **")
1427 sb.WriteString(strconv.Itoa(r.rep.CashEntries))
1428 sb.WriteString("** touched a declared cash account\n- Window entry limit: **")
1429 sb.WriteString(strconv.Itoa(MaxWindowEntries))
1430 sb.WriteString("**\n- Split complete: **")
1431 sb.WriteString(yesNo(r.rep.Complete()))
1432 sb.WriteString("**\n\n")
1433
1434 sb.WriteString("## Policy as published\n\n- Cash accounts: `")
1435 sb.WriteString(cell(encodeCash(r.pol.cash)))
1436 sb.WriteString("`\n- Rules: `")
1437 rules := encodeRules(r.pol.rules)
1438 if rules == "" {
1439 rules = "(none declared)"
1440 }
1441 sb.WriteString(cell(rules))
1442 sb.WriteString("`\n- Declared at height: ")
1443 sb.WriteString(strconv.FormatInt(r.pol.at, 10))
1444 sb.WriteString("\n\nThis is the policy that produced the figures above, frozen with ")
1445 sb.WriteString("them. The owner's current policy may differ. A code absent from the ")
1446 sb.WriteString("rules is not defaulted — an entry touching it is listed below.\n\n")
1447
1448 sb.WriteString(renderCensus(r))
1449 return sb.String()
1450}
1451
1452func renderCensus(r *record) string {
1453 var sb strings.Builder
1454
1455 sb.WriteString("## Unresolved\n\n")
1456 if len(r.rep.Unresolved) == 0 {
1457 sb.WriteString("None. Every in-window entry that moved cash was attributed.\n\n")
1458 } else {
1459 sb.WriteString("These entries moved cash that the policy could not attribute. ")
1460 sb.WriteString("Their net movement is in **unattributed** above, so the ")
1461 sb.WriteString("arithmetic is whole and the *split* is not.\n\n")
1462 sb.WriteString("| entry | reason | net | detail |\n|---|---|---|---|\n")
1463 shown := len(r.rep.Unresolved)
1464 if shown > RenderCensusLimit {
1465 shown = RenderCensusLimit
1466 }
1467 for i := 0; i < shown; i++ {
1468 u := r.rep.Unresolved[i]
1469 sb.WriteString("| ")
1470 sb.WriteString(strconv.FormatUint(u.EntryID, 10))
1471 sb.WriteString(" | ")
1472 sb.WriteString(u.Reason.String())
1473 sb.WriteString(" | ")
1474 sb.WriteString(strconv.FormatInt(u.Net, 10))
1475 sb.WriteString(" | ")
1476 sb.WriteString(cell(censusDetail(u)))
1477 sb.WriteString(" |\n")
1478 }
1479 if len(r.rep.Unresolved) > shown {
1480 sb.WriteString("\n_Showing ")
1481 sb.WriteString(strconv.Itoa(shown))
1482 sb.WriteString(" of ")
1483 sb.WriteString(strconv.Itoa(len(r.rep.Unresolved)))
1484 sb.WriteString("._\n")
1485 }
1486 sb.WriteString("\n")
1487 }
1488
1489 sb.WriteString("## Net-zero cash entries\n\n")
1490 if len(r.rep.NetZero) == 0 {
1491 sb.WriteString("None.\n")
1492 return sb.String()
1493 }
1494 sb.WriteString("These touched a declared cash account and moved none on net — a ")
1495 sb.WriteString("transfer between cash accounts. They are not classified because ")
1496 sb.WriteString("there is nothing to attribute.\n\n")
1497 shown := len(r.rep.NetZero)
1498 if shown > RenderCensusLimit {
1499 shown = RenderCensusLimit
1500 }
1501 ids := make([]string, 0, shown)
1502 for i := 0; i < shown; i++ {
1503 ids = append(ids, strconv.FormatUint(r.rep.NetZero[i], 10))
1504 }
1505 sb.WriteString("Entries: ")
1506 sb.WriteString(strings.Join(ids, ", "))
1507 if len(r.rep.NetZero) > shown {
1508 sb.WriteString(" _(")
1509 sb.WriteString(strconv.Itoa(shown))
1510 sb.WriteString(" of ")
1511 sb.WriteString(strconv.Itoa(len(r.rep.NetZero)))
1512 sb.WriteString(")_")
1513 }
1514 sb.WriteString("\n")
1515 return sb.String()
1516}
1517
1518// censusDetail is the actionable half of an unresolved row: the undeclared
1519// codes for an undeclared entry, the conflicting categories for a conflict.
1520func censusDetail(u cf.Unresolved) string {
1521 if len(u.Codes) > 0 {
1522 return "undeclared: " + strings.Join(u.Codes, ", ")
1523 }
1524 if len(u.Categories) > 0 {
1525 cats := make([]string, 0, len(u.Categories))
1526 for _, c := range u.Categories {
1527 cats = append(cats, c.String())
1528 }
1529 return "categories: " + strings.Join(cats, ", ")
1530 }
1531 return "—"
1532}
1533
1534// cell truncates and escapes a string for a markdown table cell.
1535//
1536// The truncation is first and is not cosmetic: an account name may be
1537// books.MaxNameLen bytes and a memo books.MaxMemoLen, and a table row carrying
1538// either whole is unreadable. The escape is sanitize.TableCell, which is what
1539// keeps an owner's free text from breaking out of the cell.
1540func cell(s string) string {
1541 const maxRunes = 48
1542 if r := []rune(s); len(r) > maxRunes {
1543 s = string(r[:maxRunes]) + "…"
1544 }
1545 return sanitize.TableCell(s)
1546}
1547
1548func yesNo(b bool) string {
1549 if b {
1550 return "yes"
1551 }
1552 return "no"
1553}