Skip to content Search Apps Documentation Source Content File Folder Download Copy Actions State String Boolean Number Struct Map Slice Pointer Function Closure Reference Nil Package Type Interface Unknown

Community realm. Read the code first. Community realm, deployed by its author. Read the code before you interact or send coins.

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}