Package aging partitions a set of unpaid obligations by how long each has been past due, and prices the resulting schedule into a single expected loss.
A realm holds the obligations — invoices, loans, dues — and asks this package two questions: how is what I am owed distributed across age bands, and what should I expect not to collect. Nothing here reads the chain, holds state, or knows what an invoice is, so a realm can test a year of its own receivables without a node.
Example
1s, _ := New([]int64{30, 60, 90}, []int64{0, 1000, 2500, 5000})
2rep, err := s.ReportAt(obs, now) // four bucket totals and their sum
3loss, err := s.Provision(rep) // the expected credit loss
The two steps are separable on purpose: the report is the line items a reader sees, and the provision is the one number that goes in a ledger. Schedule.ProvisionFor does both in one call for a caller that only wants the number.
The output is a provision, and never an allowance
The textbook name for this number is "allowance for doubtful accounts", and that word is not available here. `allowance` already means a *spending* allowance across this portfolio — including p/spendbook's `Allowance` type — and it means an approve/transferFrom quota across the wider ecosystem's GRC20 realms. A type named `Allowance` in a package imported beside either of those would read as the wrong thing at every call site, so the loss number is `Provision` throughout. See DISCOVERY.md § 4.
Why the floor is taken per bucket, and exactly what that costs
The provision is the sum of per-bucket floors:
Example
1Provision = Σ floor(bucketᵢ × rateᵢ / RateScale)
not one floor taken at the end of an exact rational total. Those are different numbers, and which one a package shipped is invisible in any test that happens to use round rates.
Per-bucket is chosen because Schedule.ProvisionByBucket is the line-item column a reader sees next to the bucket totals, and line items that do not add up to the stated total are a bookkeeping defect regardless of which total is "more accurate". Schedule.Provision is *defined* as the sum of that column, so the two cannot disagree — the same discipline p/depreciation uses to define `Charge` in terms of `CarryingAt`.
The cost of that choice is a real and measurable weakening of the obvious invariant, and it is stated here because the discovery record for this package asserted the strong form and the strong form is false:
Example
1FALSE: for a fixed unpaid set, Provision is non-decreasing in now.
The counterexample is small enough to state in full. Take edges `[30]` and rates `[5000, 5000]` — one boundary, the same 50% either side — and two obligations of amount 1. While both sit in bucket 0 the buckets are `[2, 0]` and the provision is `floor(2×5000/10000) = 1`. Let one age past 30: the buckets become `[1, 1]`, each floors to `0`, and the provision is `0`. **It fell as the receivables got older**, with no payment and no change of rate. One floor at the end would have returned 1 in both states.
What is true, and what [TestProvisionNearlyMonotone] asserts:
- The exact rational provision Σ bucketᵢ×rateᵢ/RateScale **is** non-decreasing in `now`, because an obligation only ever moves to a higher-indexed bucket and New requires the rates to be non-decreasing. That is the economic claim, and it survives.
- The integer provision is non-decreasing **to within fewer than NumBuckets units**: each bucket loses strictly less than 1 to its floor, and only a non-empty bucket loses anything, so `Provision(later) > Provision(earlier) - min(NumBuckets, len(obs))`. That bound is **attained**, not merely sound, and the figure is left to [TestTheMonotonicityBoundIsAttained] rather than restated here: at a flat rate of `RateScale-1`, `n` obligations of amount 1 price to `n-1` when they share a bucket and to 0 when they are one per bucket, so the drop is exactly `min(NumBuckets, len(obs)) - 1` at every bucket count this package allows. No slack is available to an implementation that wanted some.
The random sweep in [TestProvisionNearlyMonotone] reports a far smaller largest drop than that, and the difference is a property of the sweep rather than of the package: with randomly drawn rates the shape that loses the most to the floors essentially never comes up. Both run, and the constructed one is the one the bound is quoted from.
So the provision is safe to re-run and safe to re-run often — it cannot wander upward, and it cannot fall by more than a rounding unit per bucket. It is not a monotone function, and a realm that gates on `provision >= lastProvision` will eventually be wrong by one. [TestFloorIsPerBucketNotAtTheEnd] pins the counterexample itself, so a later "fix" that quietly moves to a single floor at the end fails the suite rather than silently changing every published number.
Why the rates must be non-decreasing
New refuses a rate schedule that falls anywhere. Two reasons, and the second is the load-bearing one:
- It is the domain claim. An older receivable is not more likely to be collected than a younger one. A schedule that says otherwise is almost always a transposed array, which is a real and silent authoring error: `{5000, 2500, 1000, 0}` is a plausible-looking argument and prices a portfolio inside-out.
- Without it the *exact rational* monotonicity above fails too, and then nothing is left. An obligation crossing an edge into a cheaper bucket reduces the exact provision, so the only invariant this package offers would be false in its strong form as well as its integer form.
Why a not-yet-due obligation is refused
An obligation with `DueAt > now` has no age, and the choices are to refuse it or to put it in the youngest bucket. Bucketing it would be silent and would inflate every provision by whatever the youngest rate is applied to money that nobody is late paying. So it is ErrNotYetDue, following `r/books`' `TestKindOfReportsInvalidRatherThanBucketing` — refused, not silently bucketed.
The "current" column of a conventional aging report is therefore the caller's, and the caller already has it: on r/invoices it is `OutstandingFrom - OverdueFrom`. This package describes the past-due tail and declines to guess about the rest.
The degenerate two-bucket case, and the off-by-one in it
A two-bucket schedule with edge `1` and rates `{0, RateScale}` reproduces r/invoices' `OverdueFrom` exactly, over the obligations whose `DueAt` has arrived: everything strictly past due is priced at 100% and everything else at 0%.
The edge is **1 and not 0**, which is worth the sentence because DISCOVERY.md § 1 says 0 and is wrong. `isOverdue` is `now > dueHeight`, strictly — an invoice at exactly its due height is due and not yet late, so that invoice has age 0 and must land in the *unpriced* bucket. Edge 0 makes bucket 0 empty and prices it at 100%, overstating the loss by every invoice due precisely today. An off-by-one on a half-open boundary is the defect class this package is most exposed to, and the discovery record shipped one in the very claim that identified the consumer.
Edge 0 is now refused outright — ErrBadEdges, see check — so the overstatement described above is no longer expressible rather than merely documented. It was expressible, and accepted, until the pre-deployment audit found it: AUDIT.md Y2. A paragraph naming a defect class while the validator admitted an instance of it is the shape worth remembering here.
Note also what an `Amount` is in that correspondence: `OverdueFrom` sums `amount - paid`, the unpaid remainder. [Obligation.Amount] is that remainder and not a face amount. A caller that passes face amounts prices money it has already collected.
What bounds the work
Bucket lookup is a linear scan over the edges, which is cheaper than a binary search at these sizes and is the shape an auditor can check by reading. MaxBuckets is 16, so there are at most 15 edges, and MaxObligations is 1024: one Schedule.ReportAt therefore performs at most 15,360 comparisons plus 1024 additions, and Schedule.Provision performs at most 16 multiplications and divisions. Every exported method re-runs `check`, which costs at most 31 further comparisons.
Those are the figures, not an assertion that they are affordable. A realm that ages several portfolios in one transaction, or does it inside a `Render`, must size them against the gas it has. This package bounds the work; it does not price it.
What this package does not claim
- **No novelty for partitioning time into buckets.** p/twap on gnoland-1 quantises an observation series into fixed-width windows and was read in full before this was written; what was not found in the searched sources is the domain model — obligations, a past-due age, a per-band loss rate, a provision. See DISCOVERY.md § 1 and § 3.
- **Nothing about money.** A provision is an estimate in a report. This package never sees a coin, and a realm using it must not infer custody or a transfer from it.
- **No unit of time, deliberately.** `DueAt`, `now` and the edges are int64 in whatever unit the caller keeps consistently — block heights or unix seconds. The rule and its rationale are adopted from p/moul/x/games/accrual: prefer a timestamp, because a height-indexed band drifts in wall-clock terms every time the chain's block time moves. "30 days" is the caller's arithmetic, not a constant here. See DISCOVERY.md § 4, which attributes the decision.
- **No opinion on where the rates come from.** Picking loss rates is the whole of credit policy and none of it is in this package. The rates are an input, validated for shape and ordering and never for plausibility.
- **No write-off, no recovery, no roll-forward.** A provision is a point estimate from one snapshot. Moving a receivable out of the pool, and reversing it when a payment arrives late, are a realm's job because both are state changes and nothing here holds state.
p/spendbook: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/spendbook p/depreciation: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/depreciation r/invoices: /r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/invoices p/twap: /p/moul/x/twap/v0 p/moul/x/games/accrual: /p/moul/x/games/accrual/v0