receivables.gno
46.13 Kb · 1076 lines
1// Package receivables publishes a receivables aging report and the resulting
2// allowance for credit losses over invoices held in [r/invoices].
3//
4// An issuer registers an aging schedule — age bands in BLOCKS and a loss rate
5// per band — and then names the invoice ids to age. This realm reads every
6// FACT from the invoice realm (amount, amount paid, due height, status) and
7// prices the result with [p/aging]. The published report is frozen: the
8// per-bucket unpaid totals, the per-bucket provision, the grand provision, and
9// a snapshot of the schedule that priced it.
10//
11// # The caller supplies the selection, the chain supplies the facts
12//
13// This is the central design constraint and it is a measured one, not a
14// preference. [r/invoices] exports no per-issuer id enumerator — its internal
15// `Iterate` is unexported and nothing above it can walk an issuer's records —
16// so a realm here CANNOT derive "every open invoice" on its own. It therefore
17// takes the id list from the caller.
18//
19// The consequence is stated rather than hidden: **a report can under-report**.
20// An issuer who omits an overdue invoice from the list gets a provision that
21// is too small, and no check here can see the omission. What this realm does
22// instead is publish a COVERAGE LINE with every report — how many ids were
23// listed, how many were aged, how many were filtered and why, and how many
24// open invoices and stored records the issuer held at that height — so a
25// reader can compute the shortfall themselves. See [Coverage].
26//
27// Under-reporting is made VISIBLE here. It is not prevented, and a reader who
28// treats a published provision as complete without reading the coverage line
29// is reading it wrongly.
30//
31// # Ages are in BLOCKS
32//
33// [r/invoices] stores `dueHeight` as a block height, so an age is a count of
34// blocks and every schedule edge is a count of blocks. A "30/60/90 day"
35// schedule is a conversion the ISSUER owns and this realm never performs:
36// nothing here knows the block time, and compiling a guess in would make every
37// published rate wrong by whatever the guess was off by.
38//
39// # This realm writes no ledger entry
40//
41// A provision is an accounting entry — debit a credit-loss expense, credit an
42// allowance — and this realm deliberately does not post it. [r/books] derives
43// the owner of a book structurally from its caller, so a realm that called
44// `PostMulti` would post into ITS OWN book and not the issuer's. There is no
45// owner parameter to pass and no delegation path to use.
46//
47// So [ProposedEntry] returns the entry as a string in the form r/books'
48// `PostMulti` accepts, and the issuer posts it themselves from their own
49// account. The realm stays keyless with respect to every ledger, which is the
50// stronger position: it cannot write a wrong entry because it cannot write.
51//
52// # Custody
53//
54// None. This realm holds no coins, has no withdrawal path, and refuses a user
55// call that arrives carrying any. Nothing here moves value; a provision is a
56// statement about value, which is not the same thing.
57//
58// # Limitations
59//
60// - **The report is a self-report of a self-report.** r/invoices' amounts are
61// whatever an issuer typed; an aging of them inherits that entirely and
62// then looks like an audited artifact. No audit happens here.
63// - **It can under-report, by construction.** See above and [Coverage].
64// - **It cannot over-report an unlisted invoice**, because every fact comes
65// from the chain — but it CAN over-report a *listed* one whose amount the
66// issuer inflated in r/invoices in the first place.
67// - **No write-off.** [p/aging] prices an expected loss and never removes an
68// obligation; a receivable this report provisions at 100% is still a live
69// invoice in r/invoices. Voiding is that realm's operation, not this one's.
70// - **A frozen report does not track the invoices.** A payment landing after
71// publication leaves the published figures untouched, which is what
72// "frozen" means. There is no `Verify` here: re-deriving would need the id
73// list, which is not stored (see [Publish]).
74// - **Buckets are floored per bucket**, so the per-bucket column sums to the
75// grand provision exactly and both are at or below the unrounded figure.
76// [p/aging]'s package doc carries the counterexample.
77// - **No cross-issuer aggregate.** There is no enumerator and no total
78// provision across issuers; such a figure would sum strangers' assertions.
79//
80// [r/invoices]: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices
81// [r/books]: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books
82// [p/aging]: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/aging
83package receivables
84
85import (
86 "chain/runtime"
87 "chain/runtime/unsafe"
88 "strconv"
89 "strings"
90
91 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/aging"
92 "gno.land/p/nt/avl/v0"
93 "gno.land/p/nt/markdown/sanitize/v0"
94 "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices"
95)
96
97// RealmPath is this realm's own path, used to build Render links.
98const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/receivables"
99
100// InvoicesPath is the receivables source every figure here is read from.
101//
102// It is taken BY VALUE from that realm's own exported constant rather than
103// typed out again, so this is the compile-time pin itself and not a second
104// copy that could drift. The convention is r/statements' BooksPath.
105const InvoicesPath = invoices.RealmPath
106
107// RateScale is the unit of a loss rate, re-exported by value from [p/aging]:
108// a rate of 2500 is 25%. Re-exported because every rate in this realm's API
109// is in these units and a caller should not have to find the other package to
110// learn the denominator.
111const RateScale = aging.RateScale
112
113// MaxIDsPerCall caps how many invoice ids one [Publish] may name, and it is
114// r/invoices' own per-issuer storage cap taken by value.
115//
116// That cap is the right bound and it is worth saying why, because the two
117// candidate bounds differ by a factor of sixteen. [p/aging] accepts up to
118// `aging.MaxObligations` (1024) obligations and offers a chunking protocol for
119// more; r/invoices stores at most `MaxRecordsPerIssuer` (64) records of any
120// status per issuer. An issuer therefore cannot have more than 64 ageable
121// invoices in existence at one height, so **this product never chunks**, and
122// p/aging's documented chunk-flooring error — where the sum of per-chunk
123// provisions is not the provision of the whole — cannot arise here. The two
124// caps were chosen independently and it is their inequality, not any code
125// below, that makes the composition exact.
126const MaxIDsPerCall = invoices.MaxRecordsPerIssuer
127
128// ReportsAreUncapped records a decision by its absence, because every other
129// realm in this portfolio caps its per-owner state and a reader will look for
130// the cap here.
131//
132// There is none, for r/statements' reasons, which transfer unchanged: keys are
133// per issuer so an issuer can fill only their own history; nothing iterates a
134// history except Render, which reads the newest [RenderReportLimit] by direct
135// key lookup; and there is no engine cap above this realm to mirror. Each
136// publication costs its own gas and storage deposit.
137//
138// There is also no retraction, and that is the same decision from the other
139// side. A `Retract` would split the sequence allocator from the live count —
140// two numbers that can disagree — in exchange for letting an issuer delete a
141// report they regret. A frozen record that can be deleted is not frozen, and
142// the realm has no cap an issuer could be bricked against.
143const ReportsAreUncapped = true
144
145// RenderReportLimit caps how many of an issuer's reports one Render call
146// lists, which is what keeps an issuer's page cost flat while histories are
147// unbounded.
148const RenderReportLimit = 20
149
150// ---------------------------------------------------------------------------
151// State
152// ---------------------------------------------------------------------------
153
154// report is one frozen aging report.
155//
156// It stores the [aging.Schedule] that priced it, WHOLE, and that field is the
157// point of this struct. p/aging's own doc warns that a Report carries no
158// reference to its Schedule, so two schedules with the same bucket count will
159// accept each other's reports and silently price them on the wrong rates with
160// no error. Replacing a schedule is legitimate here — rates change — so the
161// hazard is live rather than theoretical, and snapshotting removes the
162// opportunity structurally: no bare report ever persists, and a later
163// [SetSchedule] cannot retro-price anything already published.
164//
165// The priced columns are stored rather than re-derived on read, because
166// re-deriving would mean calling into p/aging from every accessor and a view
167// that recomputes is a second place for the arithmetic to live.
168type report struct {
169 at int64 // chain height at publication
170 sched aging.Schedule // the schedule that priced this report, snapshotted
171 unpaid []int64 // unpaid total per bucket, youngest first
172 provByB []int64 // provision per bucket, youngest first
173 total int64 // sum of unpaid
174 provision int64 // sum of provByB
175
176 // The coverage line. See [Coverage] for what each one means and why
177 // they are stored rather than recomputable.
178 listed int
179 aged int
180 notOpen int
181 zeroOut int
182 notDue int
183 dupes int
184 openAt int
185 recordsAt int
186}
187
188// reports maps "<issuer>|<padded seq>" to *report.
189//
190// The pointer never leaves this file. Every accessor copies scalars out, so no
191// caller receives a handle onto stored state — the discipline r/books and
192// r/statements both apply.
193var reports = avl.NewTree()
194
195// schedules maps an issuer's address string to their current
196// [aging.Schedule]. One per issuer; [SetSchedule] replaces it.
197var schedules = avl.NewTree()
198
199// counts maps an issuer's address string to how many reports they have
200// published. It is the sequence allocator as well as the count, and the two
201// can never disagree because nothing deletes a report.
202var counts = avl.NewTree()
203
204// repCount is the global total, maintained alongside the tree so the index
205// page never iterates.
206var repCount int
207
208// ---------------------------------------------------------------------------
209// Guards
210// ---------------------------------------------------------------------------
211
212// refuseCoins panics if a user call arrives carrying coins. Adopted from
213// r/books and r/statements, which face the identical slot: no withdrawal path,
214// so accepted coins are stranded permanently.
215//
216// IsUserCall rather than IsUser, and the reason is not the canonical
217// payment-guard one. That advice is for a realm that ACCEPTS payment, where
218// IsUser is dangerously broad. This guard REFUSES, so a broader predicate
219// would refuse more and "more conservative" is the wrong frame. The real
220// reason is that OriginSend is a fact about the TRANSACTION and is a faithful
221// receipt only on the direct MsgCall path; pairing it with IsUser adds a false
222// refusal mode — a panic on a call that brought this realm nothing — and buys
223// no protection.
224//
225// The residual is stated rather than guarded: coins can still reach this
226// address through an intermediary's explicit banker send, which no predicate
227// here can see. What makes that survivable is the absence of any consumption
228// path — nothing in this realm can spend what arrives.
229//
230// The IsCurrent check is a no-op at today's call sites, where the forwarded
231// cur is runtime-current by construction. It is here because this realm is
232// IMMUTABLE after addpkg, so a future second caller cannot be made safe later,
233// and a helper reading Previous() off an unchecked realm parameter is a
234// designation-forgery shape whether or not it is reachable today.
235func refuseCoins(cur realm) {
236 if !cur.IsCurrent() {
237 panic("receivables: refuseCoins called with a realm that is not current")
238 }
239 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
240 panic("receivables: this realm holds no coins and has no withdrawal path; send none")
241 }
242}
243
244// ---------------------------------------------------------------------------
245// Parsing
246// ---------------------------------------------------------------------------
247
248// parseInt64List parses a comma-separated list of decimal int64s, rejecting an
249// empty list, a blank element, and more than max elements.
250//
251// The element cap is enforced BEFORE the slice grows, so a caller cannot make
252// this realm allocate a long list on the way to a refusal. The `what` word is
253// only for the panic message.
254func parseInt64List(s string, max int, what string) []int64 {
255 parts := strings.Split(s, ",")
256 if len(parts) > max {
257 panic("receivables: at most " + strconv.Itoa(max) + " " + what +
258 "s, got " + strconv.Itoa(len(parts)))
259 }
260 out := make([]int64, 0, len(parts))
261 for _, p := range parts {
262 t := strings.TrimSpace(p)
263 if t == "" {
264 panic("receivables: empty " + what + " in " + strconv.Quote(s))
265 }
266 v, err := strconv.ParseInt(t, 10, 64)
267 if err != nil {
268 panic("receivables: " + strconv.Quote(t) + " is not a valid " + what)
269 }
270 out = append(out, v)
271 }
272 return out
273}
274
275// parseIDs parses the invoice-id list, returning it sorted ascending with
276// duplicates removed, and how many duplicates were dropped.
277//
278// Deduplication is THIS realm's responsibility and that is explicit in
279// p/aging's contract: an Obligation "carries no identity", so the same
280// obligation passed twice is counted twice and the provision is double. A
281// repeated id in a hand-written list of sixty-four is an ordinary mistake, and
282// the resulting over-provision is exactly the error class nothing downstream
283// can detect — the report would still cross-foot.
284//
285// Duplicates are DROPPED and COUNTED rather than refused, because the count
286// appears in the coverage line and so the caller learns about it either way,
287// and a refusal would make an issuer re-type a sixty-four-element list to fix
288// a repeat the realm already understood.
289//
290// Insertion sort over at most MaxIDsPerCall elements, which is 64: O(n²) at
291// n=64 is cheaper here than importing a sort.
292func parseIDs(ids string) ([]int64, int) {
293 raw := parseInt64List(ids, MaxIDsPerCall, "invoice id")
294 out := make([]int64, 0, len(raw))
295 dupes := 0
296 for _, v := range raw {
297 if v <= 0 {
298 panic("receivables: invoice id must be positive, got " +
299 strconv.FormatInt(v, 10))
300 }
301 i := 0
302 for i < len(out) && out[i] < v {
303 i++
304 }
305 if i < len(out) && out[i] == v {
306 dupes++
307 continue
308 }
309 out = append(out, 0)
310 copy(out[i+1:], out[i:])
311 out[i] = v
312 }
313 return out, dupes
314}
315
316// validCode reports whether a string is usable as a ledger account code in a
317// [ProposedEntry] leg.
318//
319// It is a WHITELIST — ASCII letters, digits, `.`, `_` and `-` — and that
320// choice is the guard, not a side effect of it. The legs format r/books'
321// PostMulti accepts is `code:side:amount` joined by commas, so a code
322// containing `:` or `,` would inject extra legs into an entry this realm
323// presents as its own arithmetic: a caller could make a one-line provision
324// read as a balanced three-line entry crediting an account of their choosing.
325// A blacklist of those two bytes would close today's format and not the class.
326//
327// What this does NOT check is whether the code exists, or is the right kind,
328// in any ledger. This realm reads no ledger and has no way to; the entry is a
329// PROPOSAL and the posting realm validates its own chart.
330func validCode(s string) bool {
331 if len(s) == 0 || len(s) > 16 {
332 return false
333 }
334 for i := 0; i < len(s); i++ {
335 c := s[i]
336 switch {
337 case c >= 'a' && c <= 'z':
338 case c >= 'A' && c <= 'Z':
339 case c >= '0' && c <= '9':
340 case c == '.' || c == '_' || c == '-':
341 default:
342 return false
343 }
344 }
345 return true
346}
347
348// formatInt64List renders a slice as the comma-separated form the setters
349// accept, so a round trip through [ScheduleOf] and [SetSchedule] is exact.
350func formatInt64List(v []int64) string {
351 var sb strings.Builder
352 for i, n := range v {
353 if i > 0 {
354 sb.WriteString(",")
355 }
356 sb.WriteString(strconv.FormatInt(n, 10))
357 }
358 return sb.String()
359}
360
361// ---------------------------------------------------------------------------
362// Write
363// ---------------------------------------------------------------------------
364
365// SetSchedule registers or replaces the caller's own aging schedule and
366// returns its bucket count.
367//
368// `edges` is a comma-separated list of block ages, positive and strictly
369// increasing; `rates` is one more number than there are edges, each in
370// [0, RateScale] and non-decreasing. So
371//
372// SetSchedule("216000,432000,648000", "0,1000,2500,5000")
373//
374// reads as: nothing provisioned under 216,000 blocks past due, 10% from there
375// to 432,000, 25% to 648,000, 50% beyond. Every constraint is p/aging's and is
376// validated by `aging.New` rather than re-checked here; a second copy of
377// somebody else's validation is a second place for it to drift.
378//
379// The owner is `cur.Previous().Address()` and there is no owner parameter, so
380// authorization is structural rather than a check: there is no address to
381// forge and no way to set a schedule for somebody else.
382//
383// REPLACEMENT IS SAFE and that is a property of [report], not of this
384// function: every published report snapshots the schedule that priced it, so a
385// new schedule applies only to reports published after it. Nothing already
386// frozen is re-priced.
387func SetSchedule(cur realm, edges, rates string) int {
388 refuseCoins(cur)
389 issuer := cur.Previous().Address()
390
391 // aging.MaxBuckets is the bucket ceiling, so at most MaxBuckets-1 edges
392 // and at most MaxBuckets rates. Bounding the parse separately keeps a
393 // thousand-element string from being materialised before aging.New
394 // refuses it.
395 e := parseInt64List(edges, aging.MaxBuckets-1, "edge")
396 r := parseInt64List(rates, aging.MaxBuckets, "rate")
397
398 s, err := aging.New(e, r)
399 if err != nil {
400 panic("receivables: " + err.Error())
401 }
402 schedules.Set(issuer.String(), s)
403 return s.NumBuckets()
404}
405
406// Publish freezes an aging report over the named invoices at the current
407// height and returns its sequence number, which starts at 1 for each issuer.
408//
409// `ids` is a comma-separated list of the caller's own invoice ids in
410// [r/invoices], at most [MaxIDsPerCall] of them. The caller must have
411// registered a schedule first.
412//
413// # What each id is checked for
414//
415// An id with no record for this issuer PANICS and fails the whole call. That
416// is deliberate: a silently-dropped id is an under-report, and under-reporting
417// is the one error this product cannot detect after the fact. The existence
418// probe is a separate `HasInvoice` call rather than letting the next read
419// panic, because r/invoices' own message is `"unknown invoice"` and does not
420// say WHICH — unhelpful in a list of sixty-four.
421//
422// An id that exists but is not ageable is filtered, counted, and reported:
423//
424// - not `open` — settled or voided, so nothing is owed. Counted `notOpen`.
425// - open with nothing outstanding. Counted `zeroOut`.
426// - open, owed, but `dueHeight > now`. Counted `notDue`.
427//
428// The third one is not an optimisation. `aging.ReportAt` requires `DueAt <=
429// now` for EVERY obligation and fails the whole call on the first that is not,
430// so a report over a list containing one current invoice would produce nothing
431// at all. Pre-filtering here is what makes "age everything I have" a usable
432// call, and the count is what stops the filter being invisible.
433//
434// # The outstanding cross-check
435//
436// `amount - paid` is computed here and compared against r/invoices'
437// `Outstanding`. State its reach exactly, because the flattering reading is
438// available: both sides are that realm's own two numbers, so this is a
439// CONSTANT TRUE against any disagreement in ITS arithmetic and must never be
440// quoted as cross-checking it. What it catches is a transcription error in
441// THIS realm — reading `paid` where `amount` belongs in a six-value return, or
442// subtracting the wrong way round — which is the one place in this path such
443// an error could happen, and an inflated outstanding is an over-provision that
444// still cross-foots.
445//
446// # The id list is not stored
447//
448// A report keeps the figures and the coverage counts, not the ids. Storing 64
449// ids per report would roughly double the record for the sake of a `Verify`
450// that could not mean much anyway: the invoices keep moving, so re-deriving
451// would answer "do the figures still hold at today's height" and not "was this
452// report right when made". r/statements documents the same distinction about
453// its own Verify. The honest artifact is the frozen figure plus the coverage
454// line.
455func Publish(cur realm, ids string) int64 {
456 refuseCoins(cur)
457 issuer := cur.Previous().Address()
458 s := mustSchedule(issuer)
459 now := runtime.ChainHeight()
460
461 list, dupes := parseIDs(ids)
462
463 // An empty list is refused; a list that FILTERS DOWN to nothing is not.
464 // The difference is that the second one is a statement — "I aged twenty
465 // invoices and none of them is overdue, so the provision is zero" is a
466 // real and useful report, and its coverage line proves the work. An empty
467 // list says nothing and would cost a permanent storage deposit to say it.
468 //
469 // THE REFUSAL IS REAL AND THIS BRANCH IS NOT WHAT PERFORMS IT. The message
470 // below is UNREACHABLE and the branch is defensive, kept for the reason
471 // the zeroOut counter is kept: an invariant worth relying on is worth
472 // stating where it is relied on. What a caller actually sees, measured,
473 // and asserted by TestAnEmptyOrMalformedIdListIsRefused:
474 //
475 // - Publish("") panics `receivables: empty invoice id in ""`, raised in
476 // parseInt64List. strings.Split("", ",") yields ONE blank element and
477 // not zero elements, so the blank-element guard fires first.
478 // - Publish(" ") the same, via TrimSpace.
479 // - any list that parses reaches here with len(list) >= 1, because
480 // parseIDs appends the FIRST of every duplicate run and drops only the
481 // repeats — dedup cannot empty a non-empty list.
482 //
483 // The note this replaces documented the refusal by quoting a string the
484 // realm cannot emit. A comment asserting an unreachable message is a claim
485 // no test can fail on, and this realm is immutable after addpkg.
486 if len(list) == 0 {
487 panic("receivables: name at least one invoice id to age")
488 }
489
490 obs := make([]aging.Obligation, 0, len(list))
491 var notOpen, zeroOut, notDue int
492 for _, id := range list {
493 if !invoices.HasInvoice(issuer, id) {
494 panic("receivables: " + InvoicesPath + " holds no invoice " +
495 strconv.FormatInt(id, 10) + " for " + issuer.String())
496 }
497 _, amount, paid, dueHeight, _, status := invoices.InvoiceInfo(issuer, id)
498 if status != invoices.StatusOpen {
499 notOpen++
500 continue
501 }
502 out := amount - paid
503 if out != invoices.Outstanding(issuer, id) {
504 panic("receivables: outstanding mismatch on invoice " +
505 strconv.FormatInt(id, 10) +
506 "; this realm transcribed the record wrongly")
507 }
508 if out <= 0 {
509 zeroOut++
510 continue
511 }
512 if dueHeight > now {
513 notDue++
514 continue
515 }
516 obs = append(obs, aging.Obligation{Amount: out, DueAt: dueHeight})
517 }
518
519 // MustReport, not a soft error: an error return does not revert a Gno
520 // transaction and only a panic does. Every refusal below is p/aging's and
521 // every precondition it names has been established above, so a panic here
522 // means this realm filtered wrongly.
523 rep, err := s.ReportAt(obs, now)
524 if err != nil {
525 panic("receivables: " + err.Error())
526 }
527 byB, err := s.ProvisionByBucket(rep)
528 if err != nil {
529 panic("receivables: " + err.Error())
530 }
531 prov, err := s.Provision(rep)
532 if err != nil {
533 panic("receivables: " + err.Error())
534 }
535
536 // Cross-foot the column against the grand total. p/aging DOCUMENTS that
537 // the per-bucket floor makes these agree exactly, and a documented
538 // identity across a package boundary is worth one loop to assert: this
539 // realm publishes both numbers side by side and a reader will subtract
540 // them.
541 var sum int64
542 for _, b := range byB {
543 sum += b
544 }
545 if sum != prov {
546 panic("receivables: per-bucket provision does not sum to the total")
547 }
548
549 key := issuer.String()
550 seq := int64(issuerCount(key)) + 1
551 reports.Set(repKey(key, seq), &report{
552 at: now,
553 sched: s,
554 unpaid: rep.Buckets,
555 provByB: byB,
556 total: rep.Total,
557 provision: prov,
558 listed: len(list),
559 aged: len(obs),
560 notOpen: notOpen,
561 zeroOut: zeroOut,
562 notDue: notDue,
563 dupes: dupes,
564 openAt: invoices.OpenOf(issuer),
565 recordsAt: invoices.RecordsOf(issuer),
566 })
567 counts.Set(key, int(seq))
568 repCount++
569 return seq
570}
571
572// ---------------------------------------------------------------------------
573// Reads — schedules
574// ---------------------------------------------------------------------------
575
576// HasSchedule reports whether an issuer has registered a schedule, so a reader
577// can distinguish that from the panic [ScheduleOf] raises.
578func HasSchedule(issuer address) bool {
579 return schedules.Has(issuer.String())
580}
581
582// ScheduleOf returns an issuer's CURRENT schedule in the comma-separated form
583// [SetSchedule] accepts, so the round trip is exact.
584//
585// This is the schedule that would price the issuer's NEXT report, not
586// necessarily the one that priced their last. [ScheduleUsed] answers that.
587func ScheduleOf(issuer address) (edges, rates string) {
588 s := mustSchedule(issuer)
589 return formatInt64List(s.Edges()), formatInt64List(s.Rates())
590}
591
592// Buckets is how many age bands an issuer's current schedule has.
593func Buckets(issuer address) int { return mustSchedule(issuer).NumBuckets() }
594
595// ---------------------------------------------------------------------------
596// Reads — frozen reports
597// ---------------------------------------------------------------------------
598
599// Count is how many reports an issuer has published. Zero for an address that
600// never has, which is not an error.
601func Count(issuer address) int { return issuerCount(issuer.String()) }
602
603// Latest is an issuer's highest sequence number, and whether they have
604// published at all. The second result matters: sequence numbers start at 1, so
605// a zero first result means "never published" and never "report 0".
606func Latest(issuer address) (int64, bool) {
607 n := issuerCount(issuer.String())
608 if n == 0 {
609 return 0, false
610 }
611 return int64(n), true
612}
613
614// Total is how many reports exist across every issuer. It is a count and
615// nothing more; see the package doc on why there is no cross-issuer provision.
616func Total() int { return repCount }
617
618// Has reports whether a report exists, so a reader can distinguish it from the
619// panics the accessors below raise for an unknown one.
620func Has(issuer address, seq int64) bool {
621 return reports.Has(repKey(issuer.String(), seq))
622}
623
624// PublishedAt is the chain height a report was frozen at. It is also the `now`
625// every age in it was measured against.
626func PublishedAt(issuer address, seq int64) int64 {
627 return mustReport(issuer, seq).at
628}
629
630// Provision is the allowance for credit losses a report concluded: the sum of
631// the per-bucket column, each bucket floored.
632func Provision(issuer address, seq int64) int64 {
633 return mustReport(issuer, seq).provision
634}
635
636// Unpaid is the total outstanding the report aged. It is the denominator the
637// provision is a fraction of, and it covers ONLY the aged invoices — not the
638// issuer's whole receivable book. See [Coverage].
639func Unpaid(issuer address, seq int64) int64 {
640 return mustReport(issuer, seq).total
641}
642
643// Totals returns a report's headline figures together, which is the shape a
644// reader wants and the shape that cannot be assembled from two reads at
645// different heights.
646func Totals(issuer address, seq int64) (unpaid, provision int64, aged int) {
647 r := mustReport(issuer, seq)
648 return r.total, r.provision, r.aged
649}
650
651// BucketCount is how many age bands a frozen report has, which is the bucket
652// count of the schedule that priced it and may differ from the issuer's
653// current schedule.
654func BucketCount(issuer address, seq int64) int {
655 return len(mustReport(issuer, seq).unpaid)
656}
657
658// BucketAt returns one line item of a frozen report, youngest band first.
659//
660// `lo` and `hi` are the band's age range in BLOCKS, half-open [lo, hi); when
661// `unbounded` is true the band has no upper edge and `hi` is meaningless. An
662// age equal to an edge belongs to the OLDER band. `rate` is in [RateScale]
663// units, `unpaid` is the outstanding total that landed in the band, and
664// `provision` is `floor(unpaid × rate / RateScale)`.
665func BucketAt(issuer address, seq int64, i int) (lo, hi int64, unbounded bool, rate, unpaid, provision int64) {
666 r := mustReport(issuer, seq)
667 if i < 0 || i >= len(r.unpaid) {
668 panic("receivables: bucket " + strconv.Itoa(i) + " is out of range for a report with " +
669 strconv.Itoa(len(r.unpaid)) + " buckets")
670 }
671 lo, hi, unbounded, err := r.sched.BucketRange(i)
672 if err != nil {
673 panic("receivables: " + err.Error())
674 }
675 return lo, hi, unbounded, r.sched.Rates()[i], r.unpaid[i], r.provByB[i]
676}
677
678// ScheduleUsed returns the schedule that PRICED a frozen report, in the
679// comma-separated form [SetSchedule] accepts.
680//
681// This exists because p/aging warns that a report carries no reference to its
682// schedule and two schedules of the same bucket count will silently accept
683// each other's reports. Here the reference is stored, so a reader can see the
684// rates that produced the figure rather than inferring them from whatever the
685// issuer has registered today.
686func ScheduleUsed(issuer address, seq int64) (edges, rates string) {
687 r := mustReport(issuer, seq)
688 return formatInt64List(r.sched.Edges()), formatInt64List(r.sched.Rates())
689}
690
691// Coverage is the part of a report a reader must not skip.
692//
693// This realm cannot enumerate an issuer's invoices (see the package doc), so a
694// report covers exactly the ids the issuer named. These eight numbers are what
695// let a reader judge whether that was everything:
696//
697// - `listed` — distinct ids in the call, after deduplication.
698// - `aged` — ids that became obligations in the report. The provision covers
699// these and nothing else.
700// - `notOpen` — settled or voided, so nothing owed.
701// - `zeroOut` — open with nothing outstanding.
702// - `notDue` — open and owed, but not yet due at `PublishedAt`.
703// - `dupes` — repeated ids dropped before aging.
704// - `openAt` — `invoices.OpenOf(issuer)` at publication: how many open
705// invoices the issuer actually held.
706// - `recordsAt` — `invoices.RecordsOf(issuer)` at publication: records of
707// any status, which is the figure r/invoices' per-issuer cap applies to.
708//
709// The arithmetic to do: `aged + zeroOut + notDue` is how many OPEN invoices
710// the report accounted for, so `openAt` minus that is **how many open invoices
711// were left out**. A non-zero remainder means the provision is understated by
712// an unknown amount, and no part of this realm can say by how much.
713//
714// `listed = aged + notOpen + zeroOut + notDue` always holds; `dupes` is
715// outside it, having been dropped before the filter.
716//
717// # ACCOUNTED FOR IS NOT THE SAME AS AT RISK
718//
719// `notDue` counts toward "accounted for" and carries NO provision. That is
720// right on its own terms — the invoice was examined, and p/aging deliberately
721// refuses to bucket something that is not yet due — but it makes one reading
722// wrong in a way the arithmetic above will not catch. A reader who takes
723// `aged` (or the provision) as "the receivables at risk" while `notDue` is
724// non-zero has measured a STRICT SUBSET of the open book: the omitted part is
725// not omitted by mistake, it is merely not due yet, and it becomes due later
726// with no further call to this realm.
727//
728// So `OmittedOpen == 0` means "every open invoice was examined", never "every
729// open invoice is provisioned". The second question is answered by reading
730// `notDue` beside the provision, and the render pages print both for exactly
731// this reason.
732func Coverage(issuer address, seq int64) (listed, aged, notOpen, zeroOut, notDue, dupes, openAt, recordsAt int) {
733 r := mustReport(issuer, seq)
734 return r.listed, r.aged, r.notOpen, r.zeroOut, r.notDue, r.dupes, r.openAt, r.recordsAt
735}
736
737// OmittedOpen is the coverage arithmetic as one number: how many of the
738// issuer's open invoices the report did not account for, at the height it was
739// published.
740//
741// Zero is the answer a complete report gives. A positive answer means the
742// provision is understated. A NEGATIVE answer is possible and is not a bug
743// here: `openAt` is read after the loop, so an invoice settled or voided by a
744// transaction between — impossible within one transaction, but the figure is a
745// snapshot either way — would make the accounted set larger than the open
746// count. It is returned signed rather than clamped, because a clamp would turn
747// an inconsistency into a clean zero.
748func OmittedOpen(issuer address, seq int64) int {
749 r := mustReport(issuer, seq)
750 return r.openAt - (r.aged + r.zeroOut + r.notDue)
751}
752
753// ProposedEntry returns the double entry that records a report's provision, in
754// the `code:side:amount` form r/books' PostMulti accepts — and this realm does
755// NOT post it.
756//
757// It cannot. r/books derives a book's owner structurally from its caller, so a
758// posting made from here would land in this realm's own book rather than the
759// issuer's, and there is no owner parameter and no delegation path. The issuer
760// posts this string themselves, from their own account, into their own book.
761// That keeps this realm keyless with respect to every ledger: it cannot write
762// a wrong entry because it cannot write.
763//
764// The entry debits `expenseCode` and credits `allowanceCode` for the whole
765// provision:
766//
767// ProposedEntry(issuer, seq, "E-BADDEBT", "A-ALLOW")
768// → "E-BADDEBT:D:1250,A-ALLOW:C:1250"
769//
770// Both codes go through [validCode], an ASCII whitelist, and the reason is the
771// format itself: a code containing `:` or `,` would inject extra legs into a
772// string this realm presents as its own arithmetic. Whether a code EXISTS, or
773// is the right kind, in any ledger is not checked and cannot be — this realm
774// reads no ledger.
775//
776// A zero provision is refused rather than returned as a balanced pair of
777// zeros, which would be a storage deposit spent to post nothing.
778//
779// It is a PROPOSAL in the full sense: nothing here records that it was posted,
780// posting it twice double-counts, and the allowance it credits is a contra
781// asset whose previous balance this realm cannot see. An issuer provisioning
782// monthly posts the DELTA, not this figure, and working that out is theirs.
783func ProposedEntry(issuer address, seq int64, expenseCode, allowanceCode string) string {
784 r := mustReport(issuer, seq)
785 if r.provision == 0 {
786 panic("receivables: the provision is zero; there is no entry to post")
787 }
788 if !validCode(expenseCode) {
789 panic("receivables: " + strconv.Quote(expenseCode) +
790 " is not a usable account code; want 1-16 of [A-Za-z0-9._-]")
791 }
792 if !validCode(allowanceCode) {
793 panic("receivables: " + strconv.Quote(allowanceCode) +
794 " is not a usable account code; want 1-16 of [A-Za-z0-9._-]")
795 }
796 if expenseCode == allowanceCode {
797 panic("receivables: the expense and allowance accounts must differ")
798 }
799 n := strconv.FormatInt(r.provision, 10)
800 return expenseCode + ":D:" + n + "," + allowanceCode + ":C:" + n
801}
802
803// Source is the realm every figure here is read from, by value from its own
804// exported constant.
805func Source() string { return InvoicesPath }
806
807// Height is the current chain height, which is the `now` a report published in
808// this transaction would be aged against.
809func Height() int64 { return runtime.ChainHeight() }
810
811// ---------------------------------------------------------------------------
812// Helpers
813// ---------------------------------------------------------------------------
814
815// repKey is issuer|paddedSeq. The sequence is left-padded to 19 digits — the
816// width of MaxInt64 — so lexicographic order over an issuer's prefix is
817// numeric order over their reports. An unpadded decimal would sort "10" before
818// "9". The convention is r/invoices' invKey, adopted rather than re-derived.
819func repKey(issuer string, seq int64) string {
820 s := strconv.FormatInt(seq, 10)
821 for len(s) < 19 {
822 s = "0" + s
823 }
824 return issuer + "|" + s
825}
826
827// issuerCount reads an issuer's report count, treating an absent key as zero.
828//
829// avl.Tree.Get returns ONE value and cannot distinguish an absent key from a
830// nil-valued one, so the two-value type assertion carries the existence answer
831// in one traversal. Only this file ever Sets, and it only ever Sets an int.
832func issuerCount(issuer string) int {
833 n, ok := counts.Get(issuer).(int)
834 if !ok {
835 return 0
836 }
837 return n
838}
839
840func mustSchedule(issuer address) aging.Schedule {
841 s, ok := schedules.Get(issuer.String()).(aging.Schedule)
842 if !ok {
843 panic("receivables: " + issuer.String() +
844 " has no aging schedule; call SetSchedule first")
845 }
846 return s
847}
848
849func mustReport(issuer address, seq int64) *report {
850 r, ok := reports.Get(repKey(issuer.String(), seq)).(*report)
851 if !ok {
852 panic("receivables: no report " + strconv.FormatInt(seq, 10) +
853 " for " + issuer.String())
854 }
855 return r
856}
857
858// ---------------------------------------------------------------------------
859// Render
860// ---------------------------------------------------------------------------
861
862// Render serves the index at "", an issuer's page at "<address>", and one
863// report at "<address>/<seq>".
864//
865// Every caller-supplied string reaching the output goes through the sanitizer.
866// The surface is narrower here than in the sibling realms — this realm stores
867// no free text, so the only untrusted bytes are the path itself — and it is
868// still the one injection-shaped surface a realm with no custody has.
869func Render(path string) string {
870 p := strings.TrimSpace(strings.Trim(path, "/"))
871 if p == "" {
872 return renderIndex()
873 }
874 if i := strings.Index(p, "/"); i >= 0 {
875 issuer := address(strings.TrimSpace(p[:i]))
876 rest := strings.TrimSpace(strings.Trim(p[i+1:], "/"))
877 seq, err := strconv.ParseInt(rest, 10, 64)
878 if err != nil {
879 return "# receivables\n\n`" + sanitize.InlineText(rest) +
880 "` is not a report number.\n"
881 }
882 return renderReport(issuer, seq)
883 }
884 return renderIssuer(address(p))
885}
886
887func renderIndex() string {
888 var sb strings.Builder
889 sb.WriteString("# receivables — aging and the allowance for credit losses\n\n")
890 sb.WriteString("A receivables aging report and the resulting provision, ")
891 sb.WriteString("priced from invoices in [`" + InvoicesPath + "`](" +
892 InvoicesPath + ") — never from amounts supplied here.\n\n")
893
894 sb.WriteString("> **A report covers the invoices the issuer named, and ")
895 sb.WriteString("nothing else.** `" + InvoicesPath + "` exports no ")
896 sb.WriteString("per-issuer enumerator, so this realm cannot find an ")
897 sb.WriteString("invoice that was left out. Every report therefore ")
898 sb.WriteString("publishes a coverage line, and `OmittedOpen` is the one ")
899 sb.WriteString("number that says whether the provision is complete. A ")
900 sb.WriteString("provision read without it is being read wrongly.\n\n")
901
902 sb.WriteString("## State\n\n")
903 sb.WriteString("- reports published: **" + strconv.Itoa(repCount) + "**\n")
904 sb.WriteString("- source: `" + InvoicesPath + "`\n")
905 sb.WriteString("- chain height: **" +
906 strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n\n")
907 sb.WriteString("There is deliberately no way to enumerate issuers or to ")
908 sb.WriteString("aggregate a provision across them: such a total would sum ")
909 sb.WriteString("every stranger's self-reported receivable.\n\n")
910
911 sb.WriteString("## Ages are in BLOCKS\n\n")
912 sb.WriteString("An invoice's `dueHeight` is a block height, so an age is ")
913 sb.WriteString("a count of blocks and every schedule edge is a count of ")
914 sb.WriteString("blocks. A \"30/60/90 day\" schedule is a conversion the ")
915 sb.WriteString("issuer owns; nothing here knows the block time, and ")
916 sb.WriteString("compiling a guess in would make every published rate ")
917 sb.WriteString("wrong by whatever the guess was off by.\n\n")
918
919 sb.WriteString("## This realm posts no entry\n\n")
920 sb.WriteString("A provision is a double entry, and `ProposedEntry` ")
921 sb.WriteString("returns it as a string the ledger's `PostMulti` accepts — ")
922 sb.WriteString("for the **issuer** to post from their own account. ")
923 sb.WriteString("A ledger derives a book's owner from its caller, so a ")
924 sb.WriteString("posting made from here would land in this realm's book ")
925 sb.WriteString("and not the issuer's. The realm stays keyless with ")
926 sb.WriteString("respect to every ledger: it cannot write a wrong entry ")
927 sb.WriteString("because it cannot write.\n\n")
928
929 sb.WriteString("## Usage\n\n")
930 sb.WriteString("```\n")
931 sb.WriteString("SetSchedule(\"216000,432000,648000\", \"0,1000,2500,5000\")\n")
932 sb.WriteString("seq := Publish(\"1,2,3,7\") // the caller's own invoice ids\n")
933 sb.WriteString("Totals(issuer, seq) // unpaid, provision, aged\n")
934 sb.WriteString("Coverage(issuer, seq) // read this one\n")
935 sb.WriteString("OmittedOpen(issuer, seq) // 0 means complete\n")
936 sb.WriteString("ProposedEntry(issuer, seq, \"E-BADDEBT\", \"A-ALLOW\")\n")
937 sb.WriteString("```\n\n")
938 sb.WriteString("At most **" + strconv.Itoa(MaxIDsPerCall) + "** ids per ")
939 sb.WriteString("call, which is the source realm's own per-issuer record ")
940 sb.WriteString("cap, so an aging never has to be chunked.\n\n")
941 sb.WriteString("An issuer's reports are at `" + RealmPath +
942 ":<address>`, and one report at `" + RealmPath +
943 ":<address>/<seq>`.\n")
944 return sb.String()
945}
946
947func renderIssuer(issuer address) string {
948 var sb strings.Builder
949 sb.WriteString("# receivables — " + sanitize.InlineText(issuer.String()) + "\n\n")
950
951 n := issuerCount(issuer.String())
952 if HasSchedule(issuer) {
953 e, r := ScheduleOf(issuer)
954 sb.WriteString("Current schedule: edges `" + sanitize.InlineText(e) +
955 "` (blocks), rates `" + sanitize.InlineText(r) + "` (of " +
956 strconv.FormatInt(RateScale, 10) + ").\n\n")
957 } else {
958 sb.WriteString("No aging schedule registered.\n\n")
959 }
960 if n == 0 {
961 sb.WriteString("No reports published.\n")
962 return sb.String()
963 }
964
965 sb.WriteString("Reports published: **" + strconv.Itoa(n) + "**")
966 first := int64(1)
967 if int64(n) > RenderReportLimit {
968 first = int64(n) - RenderReportLimit + 1
969 sb.WriteString(", showing the newest " +
970 strconv.Itoa(RenderReportLimit))
971 }
972 sb.WriteString(".\n\n")
973
974 sb.WriteString("| seq | height | unpaid | provision | aged | omitted open |\n")
975 sb.WriteString("|---|---|---|---|---|---|\n")
976 for seq := int64(n); seq >= first; seq-- {
977 rep := mustReport(issuer, seq)
978 s := strconv.FormatInt(seq, 10)
979 // The issuer is sanitized here even though it is UNREACHABLE as
980 // attacker bytes, and the reason is the point. This loop runs only
981 // when n != 0, and n comes from issuerCount(issuer.String()), which
982 // is keyed exclusively by cur.Previous().Address().String() — the
983 // only three .Set calls in this file are so keyed — so a path
984 // an attacker chose has count 0 and returns above. The value here is
985 // always a genuine bech32 address, whose charset holds no markdown
986 // metacharacter.
987 //
988 // That is a four-step reachability argument, and the header thirty
989 // lines up sanitizes the same value. An audit raised the
990 // inconsistency and resolved it by re-deriving the argument; the
991 // sanitizer costs nothing and means the next reader does not have to.
992 // A guard that depends on an argument holding elsewhere in the file
993 // is one refactor from being wrong, and this realm is immutable
994 // after addpkg.
995 sb.WriteString("| [" + s + "](" + RealmPath + ":" +
996 sanitize.InlineText(issuer.String()) + "/" + s + ") | " +
997 strconv.FormatInt(rep.at, 10) + " | " +
998 strconv.FormatInt(rep.total, 10) + " | " +
999 strconv.FormatInt(rep.provision, 10) + " | " +
1000 strconv.Itoa(rep.aged) + " | " +
1001 strconv.Itoa(rep.openAt-(rep.aged+rep.zeroOut+rep.notDue)) + " |\n")
1002 }
1003 return sb.String()
1004}
1005
1006func renderReport(issuer address, seq int64) string {
1007 if !Has(issuer, seq) {
1008 return "# receivables\n\nNo report " +
1009 strconv.FormatInt(seq, 10) + " for `" +
1010 sanitize.InlineText(issuer.String()) + "`.\n"
1011 }
1012 r := mustReport(issuer, seq)
1013
1014 var sb strings.Builder
1015 sb.WriteString("# receivables — report " + strconv.FormatInt(seq, 10) + "\n\n")
1016 sb.WriteString("Issuer `" + sanitize.InlineText(issuer.String()) +
1017 "`, frozen at height **" + strconv.FormatInt(r.at, 10) +
1018 "**, from `" + InvoicesPath + "`.\n\n")
1019
1020 sb.WriteString("- unpaid aged: **" + strconv.FormatInt(r.total, 10) + "**\n")
1021 sb.WriteString("- provision: **" + strconv.FormatInt(r.provision, 10) + "**\n")
1022 sb.WriteString("- schedule used: edges `" +
1023 sanitize.InlineText(formatInt64List(r.sched.Edges())) +
1024 "`, rates `" + sanitize.InlineText(formatInt64List(r.sched.Rates())) +
1025 "`\n\n")
1026
1027 sb.WriteString("## Buckets\n\n")
1028 sb.WriteString("Ages in **blocks**, youngest band first. A band is ")
1029 sb.WriteString("half-open `[lo, hi)`, so an age equal to an edge belongs ")
1030 sb.WriteString("to the older band.\n\n")
1031 sb.WriteString("| band | from | to | rate | unpaid | provision |\n")
1032 sb.WriteString("|---|---|---|---|---|---|\n")
1033 rates := r.sched.Rates()
1034 for i := range r.unpaid {
1035 lo, hi, unbounded, err := r.sched.BucketRange(i)
1036 if err != nil {
1037 panic("receivables: " + err.Error())
1038 }
1039 to := strconv.FormatInt(hi, 10)
1040 if unbounded {
1041 to = "—"
1042 }
1043 sb.WriteString("| " + strconv.Itoa(i) + " | " +
1044 strconv.FormatInt(lo, 10) + " | " + to + " | " +
1045 strconv.FormatInt(rates[i], 10) + " | " +
1046 strconv.FormatInt(r.unpaid[i], 10) + " | " +
1047 strconv.FormatInt(r.provByB[i], 10) + " |\n")
1048 }
1049 sb.WriteString("\nThe provision column is floored **per band**, so it ")
1050 sb.WriteString("sums to the total exactly and both are at or below the ")
1051 sb.WriteString("unrounded figure.\n\n")
1052
1053 sb.WriteString("## Coverage\n\n")
1054 omitted := r.openAt - (r.aged + r.zeroOut + r.notDue)
1055 sb.WriteString("| listed | aged | not open | nothing owed | not yet due | dupes dropped | open then | records then |\n")
1056 sb.WriteString("|---|---|---|---|---|---|---|---|\n| " +
1057 strconv.Itoa(r.listed) + " | " + strconv.Itoa(r.aged) + " | " +
1058 strconv.Itoa(r.notOpen) + " | " + strconv.Itoa(r.zeroOut) + " | " +
1059 strconv.Itoa(r.notDue) + " | " + strconv.Itoa(r.dupes) + " | " +
1060 strconv.Itoa(r.openAt) + " | " + strconv.Itoa(r.recordsAt) + " |\n\n")
1061
1062 if omitted > 0 {
1063 sb.WriteString("> **This report is incomplete.** " +
1064 strconv.Itoa(omitted) + " of the issuer's open invoices were ")
1065 sb.WriteString("not named in the call, so the provision is ")
1066 sb.WriteString("understated by an amount nothing here can determine.\n")
1067 } else if omitted == 0 {
1068 sb.WriteString("Every open invoice the issuer held at that height was ")
1069 sb.WriteString("accounted for.\n")
1070 } else {
1071 sb.WriteString("> The accounted set is larger than the open count by " +
1072 strconv.Itoa(-omitted) + ", which is reported rather than ")
1073 sb.WriteString("clamped. See `OmittedOpen`.\n")
1074 }
1075 return sb.String()
1076}