Cosmic Bull

receivables

gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/receivables on onyx-1 testnet

realmapplicationreceivableslive on onyx-1

See which invoices are overdue and what they are worth.

Ages the live invoices and prices the shortfall, turning a list of unpaid bills into a provision you can put in the accounts.

In detail curated

Receivables aging and the allowance for credit losses over invoices held in r/invoices. An issuer registers an aging schedule with SetSchedule -- age bands in BLOCKS and a loss rate per band in RateScale basis points -- then names invoice ids to Age; the realm reads every FACT from r/invoices (amount, amount paid, due height, status) and prices the result with p/aging, publishing a frozen report: per-bucket unpaid totals, per-bucket provision, the grand provision, and a snapshot of the schedule that priced it. THE DESIGN AXIS IS THAT THE CALLER SUPPLIES THE SELECTION AND THE CHAIN SUPPLIES THE FACTS, AND THE REALM PUBLISHES ITS OWN INCOMPLETENESS RATHER THAN HIDING IT. r/invoices exports no per-issuer id enumerator -- its Iterate is unexported -- so nothing here can derive 'every open invoice' and the id list must come from the caller. The consequence is that A REPORT CAN UNDER-REPORT: an issuer who omits an overdue invoice gets a provision that is too small and no check here can see the omission. So every report carries a COVERAGE LINE -- ids listed, aged, filtered and why, plus the open-invoice and stored-record counts the issuer held at that height -- from which a reader computes the shortfall themselves. Under-reporting is made VISIBLE, not prevented, and Render says so in prose rather than leaving a reader to infer completeness from a single number. The cross-realm facts are pulled, never pushed: no invoice state is mirrored here, so a report is a priced snapshot of r/invoices at its own height and cannot drift from it between reports. MaxIDsPerCall is pinned BY VALUE to invoices.MaxRecordsPerIssuer rather than restated, so the two realms' bounds cannot diverge, and RateScale is pinned to aging.RateScale for the same reason -- both were confirmed live against the chain's own bindings (64 and 10000) rather than against the source. ReportsAreUncapped is true and declared: the report store grows with the issuer's history and nothing prunes it, which is a stated property and not an oversight. RenderReportLimit bounds only the rendered view. mustSchedule panics with the issuer address in the text when no schedule is registered, which is the realm's own which-bytes-run signature. This realm custodies NO coins: it has no banker import, no payable entrypoint and no withdrawal path, so the monetary-invariant class that governs every custodial realm in this portfolio does not apply to it at all.

Use it

A realm holds state. Reading it is a free query; changing it is a transaction you sign yourself.

Read it — no key, no account, no GNOT

gnokey query vm/qeval \
  --data 'gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/receivables.Height()' \
  --remote https://rpc.onyx.testnets.gno.land:443

Height is read-only, so this is a query rather than a transaction: it costs nothing, signs nothing and needs no funded account. Every exported function is listed under the API below; swap the name and the arguments to read any of the others.

Change its state

Every state change is a transaction you sign with your own key — this site never asks for one and holds none. You do not have to hand-write the transaction: gnoweb generates the exact gnokey maketx call command for any of this realm's 2 crossing functions, with the argument names filled in.

Read the known limitations further down this page first. They record what was not verified on chain, which is usually the part that matters.

Import it from another realm

import "gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/receivables"

The path is the address: there is no package manager, nothing to add to a manifest and no version to pin, so the package you compile against is the one the chain runs. It differs per chain because the namespace in it is the deploying account's.

Deployments

onyx-1 testnet onyx-1

RenderedSourceCall builderState

chain-attested
Import pathgno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/receivables
Namespaceg1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3
Deployed at height422,172
Deploy transactionbcaec9102b1ea3dacd24be29ca990d5d96b45a02c58a9bcc1cedd8dd828776f6 look it up on the RPC
Deployerg1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3
Gas used62,867,025
Storagenot attested per package on this chain — see the limitations below
Files on chaingnomod.toml receivables.gno
Deployed bytesreceivables.gno — 47,241 bytes
sha25689b2b611f82977cd08e04aa325d079e5f606a868ee81ed523b1ed10ee6203fcd
Realm addressg10nux2erkupzyus32nae6klf8uyg5ua2d8a9x2n

Do not take the hash above on trust. $download returns the bytes onyx-1 is actually running; this command fetches them and prints their digest, which should equal the one in the table:

curl -sS 'https://onyx.testnets.gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/receivables$download&file=receivables.gno' | shasum -a 256

Expected: 89b2b611f82977cd08e04aa325d079e5f606a868ee81ed523b1ed10ee6203fcd — 47,241 bytes. Use curl: the gno.land edges answer Python's default user-agent with HTTP 403.

API

chain-derived 22 exported functions.

Every function below deep-links to gnoweb's call builder, which generates a ready-to-run gnokey maketx call for it:

BucketAtBucketCountBucketsCountCoverageHasHasScheduleHeightLatestOmittedOpenProposedEntryProvisionPublishPublishedAtRenderScheduleOfScheduleUsedSetScheduleSourceTotalTotalsUnpaid

Overview

Package receivables publishes a receivables aging report and the resulting allowance for credit losses over invoices held in r/invoices.

An issuer registers an aging schedule — age bands in BLOCKS and a loss rate per band — and then names the invoice ids to age. This realm reads every FACT from the invoice realm (amount, amount paid, due height, status) and prices the result with p/aging. The published report is frozen: the per-bucket unpaid totals, the per-bucket provision, the grand provision, and a snapshot of the schedule that priced it.

The caller supplies the selection, the chain supplies the facts

This is the central design constraint and it is a measured one, not a preference. r/invoices exports no per-issuer id enumerator — its internal `Iterate` is unexported and nothing above it can walk an issuer's records — so a realm here CANNOT derive "every open invoice" on its own. It therefore takes the id list from the caller.

The consequence is stated rather than hidden: **a report can under-report**. An issuer who omits an overdue invoice from the list gets a provision that is too small, and no check here can see the omission. What this realm does instead is publish a COVERAGE LINE with every report — how many ids were listed, how many were aged, how many were filtered and why, and how many open invoices and stored records the issuer held at that height — so a reader can compute the shortfall themselves. See Coverage.

Under-reporting is made VISIBLE here. It is not prevented, and a reader who treats a published provision as complete without reading the coverage line is reading it wrongly.

Ages are in BLOCKS

r/invoices stores `dueHeight` as a block height, so an age is a count of blocks and every schedule edge is a count of blocks. A "30/60/90 day" schedule is a conversion the ISSUER owns and this realm never performs: nothing here knows the block time, and compiling a guess in would make every published rate wrong by whatever the guess was off by.

This realm writes no ledger entry

A provision is an accounting entry — debit a credit-loss expense, credit an allowance — and this realm deliberately does not post it. r/books derives the owner of a book structurally from its caller, so a realm that called `PostMulti` would post into ITS OWN book and not the issuer's. There is no owner parameter to pass and no delegation path to use.

So ProposedEntry returns the entry as a string in the form r/books' `PostMulti` accepts, and the issuer posts it themselves from their own account. The realm stays keyless with respect to every ledger, which is the stronger position: it cannot write a wrong entry because it cannot write.

Custody

None. This realm holds no coins, has no withdrawal path, and refuses a user call that arrives carrying any. Nothing here moves value; a provision is a statement about value, which is not the same thing.

Limitations

r/invoices: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices r/books: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/books p/aging: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/aging

Imports

Constants and variables

InvoicesPath is the receivables source every figure here is read from.

It is taken BY VALUE from that realm's own exported constant rather than typed out again, so this is the compile-time pin itself and not a second copy that could drift. The convention is r/statements' BooksPath.

const InvoicesPath = invoices.RealmPath

MaxIDsPerCall caps how many invoice ids one Publish may name, and it is r/invoices' own per-issuer storage cap taken by value.

That cap is the right bound and it is worth saying why, because the two candidate bounds differ by a factor of sixteen. p/aging accepts up to `aging.MaxObligations` (1024) obligations and offers a chunking protocol for more; r/invoices stores at most `MaxRecordsPerIssuer` (64) records of any status per issuer. An issuer therefore cannot have more than 64 ageable invoices in existence at one height, so **this product never chunks**, and p/aging's documented chunk-flooring error — where the sum of per-chunk provisions is not the provision of the whole — cannot arise here. The two caps were chosen independently and it is their inequality, not any code below, that makes the composition exact.

const MaxIDsPerCall = invoices.MaxRecordsPerIssuer

RateScale is the unit of a loss rate, re-exported by value from p/aging: a rate of 2500 is 25%. Re-exported because every rate in this realm's API is in these units and a caller should not have to find the other package to learn the denominator.

const RateScale = aging.RateScale

RealmPath is this realm's own path, used to build Render links.

const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/receivables"

RenderReportLimit caps how many of an issuer's reports one Render call lists, which is what keeps an issuer's page cost flat while histories are unbounded.

const RenderReportLimit = 20

ReportsAreUncapped records a decision by its absence, because every other realm in this portfolio caps its per-owner state and a reader will look for the cap here.

There is none, for r/statements' reasons, which transfer unchanged: keys are per issuer so an issuer can fill only their own history; nothing iterates a history except Render, which reads the newest RenderReportLimit by direct key lookup; and there is no engine cap above this realm to mirror. Each publication costs its own gas and storage deposit.

There is also no retraction, and that is the same decision from the other side. A `Retract` would split the sequence allocator from the live count — two numbers that can disagree — in exchange for letting an issuer delete a report they regret. A frozen record that can be deleted is not frozen, and the realm has no cap an issuer could be bricked against.

const ReportsAreUncapped = true

Functions

BucketAt

func BucketAt(issuer address, seq int64, i int) (lo, hi int64, unbounded bool, rate, unpaid, provision int64)

BucketAt returns one line item of a frozen report, youngest band first.

`lo` and `hi` are the band's age range in BLOCKS, half-open [lo, hi); when `unbounded` is true the band has no upper edge and `hi` is meaningless. An age equal to an edge belongs to the OLDER band. `rate` is in RateScale units, `unpaid` is the outstanding total that landed in the band, and `provision` is `floor(unpaid × rate / RateScale)`.

BucketCount

func BucketCount(issuer address, seq int64) int

BucketCount is how many age bands a frozen report has, which is the bucket count of the schedule that priced it and may differ from the issuer's current schedule.

Buckets

func Buckets(issuer address) int

Buckets is how many age bands an issuer's current schedule has.

Count

func Count(issuer address) int

Count is how many reports an issuer has published. Zero for an address that never has, which is not an error.

Coverage

func Coverage(issuer address, seq int64) (listed, aged, notOpen, zeroOut, notDue, dupes, openAt, recordsAt int)

Coverage is the part of a report a reader must not skip.

This realm cannot enumerate an issuer's invoices (see the package doc), so a report covers exactly the ids the issuer named. These eight numbers are what let a reader judge whether that was everything:

The arithmetic to do: `aged + zeroOut + notDue` is how many OPEN invoices the report accounted for, so `openAt` minus that is **how many open invoices were left out**. A non-zero remainder means the provision is understated by an unknown amount, and no part of this realm can say by how much.

`listed = aged + notOpen + zeroOut + notDue` always holds; `dupes` is outside it, having been dropped before the filter.

ACCOUNTED FOR IS NOT THE SAME AS AT RISK

`notDue` counts toward "accounted for" and carries NO provision. That is right on its own terms — the invoice was examined, and p/aging deliberately refuses to bucket something that is not yet due — but it makes one reading wrong in a way the arithmetic above will not catch. A reader who takes `aged` (or the provision) as "the receivables at risk" while `notDue` is non-zero has measured a STRICT SUBSET of the open book: the omitted part is not omitted by mistake, it is merely not due yet, and it becomes due later with no further call to this realm.

So `OmittedOpen == 0` means "every open invoice was examined", never "every open invoice is provisioned". The second question is answered by reading `notDue` beside the provision, and the render pages print both for exactly this reason.

Has

func Has(issuer address, seq int64) bool

Has reports whether a report exists, so a reader can distinguish it from the panics the accessors below raise for an unknown one.

HasSchedule

func HasSchedule(issuer address) bool

HasSchedule reports whether an issuer has registered a schedule, so a reader can distinguish that from the panic ScheduleOf raises.

Height

func Height() int64

Height is the current chain height, which is the `now` a report published in this transaction would be aged against.

Latest

func Latest(issuer address) (int64, bool)

Latest is an issuer's highest sequence number, and whether they have published at all. The second result matters: sequence numbers start at 1, so a zero first result means "never published" and never "report 0".

OmittedOpen

func OmittedOpen(issuer address, seq int64) int

OmittedOpen is the coverage arithmetic as one number: how many of the issuer's open invoices the report did not account for, at the height it was published.

Zero is the answer a complete report gives. A positive answer means the provision is understated. A NEGATIVE answer is possible and is not a bug here: `openAt` is read after the loop, so an invoice settled or voided by a transaction between — impossible within one transaction, but the figure is a snapshot either way — would make the accounted set larger than the open count. It is returned signed rather than clamped, because a clamp would turn an inconsistency into a clean zero.

ProposedEntry

func ProposedEntry(issuer address, seq int64, expenseCode, allowanceCode string) string

ProposedEntry returns the double entry that records a report's provision, in the `code:side:amount` form r/books' PostMulti accepts — and this realm does NOT post it.

It cannot. r/books derives a book's owner structurally from its caller, so a posting made from here would land in this realm's own book rather than the issuer's, and there is no owner parameter and no delegation path. The issuer posts this string themselves, from their own account, into their own book. That keeps this realm keyless with respect to every ledger: it cannot write a wrong entry because it cannot write.

The entry debits `expenseCode` and credits `allowanceCode` for the whole provision:

ProposedEntry(issuer, seq, "E-BADDEBT", "A-ALLOW") → "E-BADDEBT:D:1250,A-ALLOW:C:1250"

Both codes go through [validCode], an ASCII whitelist, and the reason is the format itself: a code containing `:` or `,` would inject extra legs into a string this realm presents as its own arithmetic. Whether a code EXISTS, or is the right kind, in any ledger is not checked and cannot be — this realm reads no ledger.

A zero provision is refused rather than returned as a balanced pair of zeros, which would be a storage deposit spent to post nothing.

It is a PROPOSAL in the full sense: nothing here records that it was posted, posting it twice double-counts, and the allowance it credits is a contra asset whose previous balance this realm cannot see. An issuer provisioning monthly posts the DELTA, not this figure, and working that out is theirs.

Provision

func Provision(issuer address, seq int64) int64

Provision is the allowance for credit losses a report concluded: the sum of the per-bucket column, each bucket floored.

Publish

func Publish(cur realm, ids string) int64

Crossing function. Callable from a transaction via MsgCall, and from another realm as Publish(cross(cur), ...).

Publish freezes an aging report over the named invoices at the current height and returns its sequence number, which starts at 1 for each issuer.

`ids` is a comma-separated list of the caller's own invoice ids in r/invoices, at most MaxIDsPerCall of them. The caller must have registered a schedule first.

What each id is checked for

An id with no record for this issuer PANICS and fails the whole call. That is deliberate: a silently-dropped id is an under-report, and under-reporting is the one error this product cannot detect after the fact. The existence probe is a separate `HasInvoice` call rather than letting the next read panic, because r/invoices' own message is `"unknown invoice"` and does not say WHICH — unhelpful in a list of sixty-four.

An id that exists but is not ageable is filtered, counted, and reported:

The third one is not an optimisation. `aging.ReportAt` requires `DueAt <= now` for EVERY obligation and fails the whole call on the first that is not, so a report over a list containing one current invoice would produce nothing at all. Pre-filtering here is what makes "age everything I have" a usable call, and the count is what stops the filter being invisible.

The outstanding cross-check

`amount - paid` is computed here and compared against r/invoices' `Outstanding`. State its reach exactly, because the flattering reading is available: both sides are that realm's own two numbers, so this is a CONSTANT TRUE against any disagreement in ITS arithmetic and must never be quoted as cross-checking it. What it catches is a transcription error in THIS realm — reading `paid` where `amount` belongs in a six-value return, or subtracting the wrong way round — which is the one place in this path such an error could happen, and an inflated outstanding is an over-provision that still cross-foots.

The id list is not stored

A report keeps the figures and the coverage counts, not the ids. Storing 64 ids per report would roughly double the record for the sake of a `Verify` that could not mean much anyway: the invoices keep moving, so re-deriving would answer "do the figures still hold at today's height" and not "was this report right when made". r/statements documents the same distinction about its own Verify. The honest artifact is the frozen figure plus the coverage line.

PublishedAt

func PublishedAt(issuer address, seq int64) int64

PublishedAt is the chain height a report was frozen at. It is also the `now` every age in it was measured against.

Render

func Render(path string) string

Render serves the index at "", an issuer's page at "<address>", and one report at "<address>/<seq>".

Every caller-supplied string reaching the output goes through the sanitizer. The surface is narrower here than in the sibling realms — this realm stores no free text, so the only untrusted bytes are the path itself — and it is still the one injection-shaped surface a realm with no custody has.

ScheduleOf

func ScheduleOf(issuer address) (edges, rates string)

ScheduleOf returns an issuer's CURRENT schedule in the comma-separated form SetSchedule accepts, so the round trip is exact.

This is the schedule that would price the issuer's NEXT report, not necessarily the one that priced their last. ScheduleUsed answers that.

ScheduleUsed

func ScheduleUsed(issuer address, seq int64) (edges, rates string)

ScheduleUsed returns the schedule that PRICED a frozen report, in the comma-separated form SetSchedule accepts.

This exists because p/aging warns that a report carries no reference to its schedule and two schedules of the same bucket count will silently accept each other's reports. Here the reference is stored, so a reader can see the rates that produced the figure rather than inferring them from whatever the issuer has registered today.

SetSchedule

func SetSchedule(cur realm, edges, rates string) int

Crossing function. Callable from a transaction via MsgCall, and from another realm as SetSchedule(cross(cur), ...).

SetSchedule registers or replaces the caller's own aging schedule and returns its bucket count.

`edges` is a comma-separated list of block ages, positive and strictly increasing; `rates` is one more number than there are edges, each in [0, RateScale] and non-decreasing. So

SetSchedule("216000,432000,648000", "0,1000,2500,5000")

reads as: nothing provisioned under 216,000 blocks past due, 10% from there to 432,000, 25% to 648,000, 50% beyond. Every constraint is p/aging's and is validated by `aging.New` rather than re-checked here; a second copy of somebody else's validation is a second place for it to drift.

The owner is `cur.Previous().Address()` and there is no owner parameter, so authorization is structural rather than a check: there is no address to forge and no way to set a schedule for somebody else.

REPLACEMENT IS SAFE and that is a property of [report], not of this function: every published report snapshots the schedule that priced it, so a new schedule applies only to reports published after it. Nothing already frozen is re-priced.

Source

func Source() string

Source is the realm every figure here is read from, by value from its own exported constant.

Total

func Total() int

Total is how many reports exist across every issuer. It is a count and nothing more; see the package doc on why there is no cross-issuer provision.

Totals

func Totals(issuer address, seq int64) (unpaid, provision int64, aged int)

Totals returns a report's headline figures together, which is the shape a reader wants and the shape that cannot be assembled from two reads at different heights.

Unpaid

func Unpaid(issuer address, seq int64) int64

Unpaid is the total outstanding the report aged. It is the denominator the provision is a fraction of, and it covers ONLY the aged invoices — not the issuer's whole receivable book. See Coverage.


Doc text is reproduced as vm/qdoc returns it. The node markdown-escapes doc comments, so a bracket or angle bracket may carry a backslash the committed source does not have. The source itself is at source and in this repository.

Dependencies

chain-attested
Importschain/runtime, chain/runtime/unsafe, gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/aging, gno.land/p/nt/avl/v0, gno.land/p/nt/markdown/sanitize/v0, gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices, strconv, strings
First-party dependenciesgno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/aging, gno.land/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices

Known limitations

curated

The manifest records no limitation for this package on onyx-1. That is an absence of a recorded caveat, not a proof that none exists — the deployment records below are the fuller account, and each carries its own "what was NOT verified live" section.

Source

Source fileonyx/r/receivables/receivables.gno at commit c2e10d17ffbf in the project repository (not public — the digest command above is the check that needs no repository)
Matches the deployed bytesyes — byte-identical