inventory.gno
56.83 Kb · 1337 lines
1// Package inventory is a per-owner inventory sub-ledger whose cost of goods
2// sold is DERIVED from the lots on hand rather than asserted by a caller.
3//
4// # What this realm is for
5//
6// A general ledger takes a debit code, a credit code and an amount from
7// whoever is posting. That is the right design for a ledger, and it means
8// gno.land/r/.../books structurally CANNOT know that an entry was a shipment,
9// or that the cost charged for that shipment is the cost the goods were
10// actually bought at. Nothing stops a shipper from booking a flattering
11// number, and nothing in the book can tell afterwards.
12//
13// This realm closes that gap for one class of entry. It holds each item's
14// cost lots as state, and when goods ship it computes the charge from those
15// lots by the item's declared costing method. The amount is never a
16// parameter: [Ship] takes a QUANTITY and returns the cost it derived.
17//
18// The resulting claims are narrow and checkable by anyone, which is the point.
19// Per item, from p/lots' own cross-foot:
20//
21// receivedCost == chargedCost + costOnHand
22//
23// and, across a whole register, against the ledger the realm posted to:
24//
25// balance(1300) == sum of costOnHand over items
26// balance(5000) == sum of chargedCost over items
27// balance(3900) == sum of receivedCost over items
28//
29// All four are exported — [ItemCrossFoot], [CrossFootInventory],
30// [CrossFootCOGS], [CrossFootContributed] — and all are FREE reads,
31// deliberately; see "Why the invariants are free reads" below.
32//
33// Note what the three ledger identities are NOT. Each compares a walk of the
34// lots against an account balance, and the two are maintained by different
35// code: the pool counts for itself inside p/lots, while this realm posts to
36// the book. A disagreement is therefore a real signal and not a tautology.
37// That is the same reason p/lots keeps counters beside its lots.
38//
39// # The chart is three accounts and is fixed
40//
41// 1300 Inventory (asset, debit-normal)
42// 5000 Cost of goods sold (expense, debit-normal)
43// 3900 Inventory contributed (equity, credit-normal)
44//
45// A receipt is debit 1300 / credit 3900. A shipment is debit 5000 / credit
46// 1300 for the DERIVED cost. There is no OpenAccount entrypoint and the
47// underlying book is created with a hard cap of three accounts, so the chart
48// cannot grow. The accounting equation offsets at 0 = 0 on an empty register
49// and after every posting, because 1300 + 5000 == 3900 is the register-wide
50// restatement of each pool's own cross-foot.
51//
52// # 3900 IS A PLUG, AND THIS REALM IS FORCED TO PICK ONE
53//
54// This is the design's one unavoidable assertion about the world, so it is
55// stated here rather than buried.
56//
57// gno.land/r/.../assets faced the same question about an asset's cost basis
58// and answered by NOT BOOKING IT: the only honest credit legs were a
59// liability ("2000 Acquisition payable") or an equity plug ("3000 Contributed
60// capital"), both of which assert how the asset was funded, which that realm
61// does not know. It kept the cost in register state as data instead.
62//
63// That answer is not available here, and the difference is structural rather
64// than a matter of taste. The product IS cost of goods sold. A shipment debits
65// 5000 and credits 1300, so 1300 must carry a balance, so the receipt must
66// reach the book — and a debit to 1300 needs a credit leg. The choice is
67// therefore not "book it or not" but "which plug", and every candidate
68// asserts something unobservable:
69//
70// - 2000 Accounts payable asserts the goods were bought on credit and that
71// a liability is outstanding. This realm cannot see a payment, so the
72// liability would never be relieved and would grow without bound.
73// - 1000 Cash asserts the goods were paid for in cash from a balance this
74// realm does not hold and cannot verify. It holds no coins at all.
75// - 3900 Inventory contributed asserts the goods were contributed by the
76// owner. It is wrong in exactly the same way the other two are, and it is
77// the only one of the three that is SELF-CONSISTENT without an
78// unobservable second event: a contribution is complete on arrival.
79//
80// So 3900 was chosen because it needs no follow-up transaction to stay
81// coherent, not because it is true. Read it as "goods arrived from somewhere
82// this ledger does not model". A register whose goods were in fact purchased
83// on credit is understating liabilities and overstating equity by exactly
84// balance(3900), and no code path here can detect that. Pairing this
85// sub-ledger with a real payables ledger is out of scope for v1 and is the
86// honest fix.
87//
88// # Costing method is per item and is FIXED at creation
89//
90// An item is added by [AddFIFO], [AddLIFO] or [AddWAVG] — three entrypoints
91// rather than one taking a method code, following r/assets' Place /
92// PlaceDeclining precedent, so a caller never has to know an enum's numbering.
93//
94// p/lots fixes the method at pool construction and documents why: changing it
95// mid-life would recost nothing already charged but would change what the next
96// shipment costs, which is a restatement a sub-ledger must not be able to make
97// silently. This realm inherits that and adds nothing: there is no entrypoint
98// that changes an item's method. An owner who must switch opens a new SKU and
99// discloses it.
100//
101// The three methods differ observably, which is the point of having them:
102// FIFO and LIFO charge different amounts whenever unit costs differ, and WAVG
103// re-anchors every receipt into a single merged lot so it holds exactly one
104// lot and can never hit the per-item lot cap.
105//
106// That exemption is not a free win, and the trade is the other way round from
107// how it reads. WAVG escapes the LOT cap and in exchange acquires a refusal
108// FIFO and LIFO do not have: a merged lot must satisfy p/lots' per-lot
109// overflow precondition `Cost <= maxInt64/Qty`, and two lots that each satisfy
110// it separately can merge into one that does not. So under WAVG a receipt can
111// be refused for the pool's ACCUMULATED HISTORY rather than for anything about
112// the receipt — the same receipt a FIFO item would have taken. It is a far
113// corner of the arithmetic and not a practical bound, but it is the reason
114// [LotCapacity] is the wrong thing to watch for a WAVG item: its figure is a
115// bound WAVG will never approach, so a caller sizing headroom from it is
116// reading the one limit that does not apply. See p/lots' Pool.Receive.
117//
118// # A ZERO-COST POSTING IS SKIPPED, AND THAT IS NOT A SHORTCUT
119//
120// p/journal rejects a posting of zero (ErrInvalidAmount) — correctly; a zero
121// entry is not an event. But both of this realm's postings can legitimately be
122// zero:
123//
124// - [Receive] with cost 0 is free goods, which p/lots accepts (ErrBadCost is
125// only for a NEGATIVE cost).
126// - [Ship] out of lots that cost nothing derives a charge of 0.
127//
128// In both cases the pool is updated and NOTHING is posted. The identities
129// above are preserved exactly, because a zero contribution to the ledger side
130// is matched by a zero contribution to the walk side. The same choice r/assets
131// makes for a degenerate declining schedule, for the same reason.
132//
133// # What this realm does NOT do
134//
135// - No custody. It holds no coins, has no withdrawal path, and refuses a
136// user call that arrives carrying any. A cost is a number an owner typed.
137// - No write-off, shrinkage or revaluation. Shrinkage is a real inventory
138// event and its absence is a real limitation: there is currently no way to
139// remove goods without charging them to 5000, so a loss is indistinguish-
140// able from a sale. The posting it needs is a debit to a 5-series loss
141// account against a credit to 1300, which means a fourth account and a
142// fourth entrypoint. Deliberately deferred to keep the chart fixed at
143// three for v1, and named here rather than discovered later.
144// - No revenue. A shipment books its COST, never a sale price. Revenue
145// belongs to a receivables ledger (r/invoices) and tying the two together
146// would require this realm to learn what was sold for how much.
147// - No period close. p/journal can seal a book up to a HEIGHT; sealing a
148// derived book could only make the next derivation unpostable. The
149// capability exists in the engine and is deliberately not exposed.
150// - No bulk receive or ship. One item per call — see the bound below.
151// - No truth. A sub-ledger is a set of claims by its owner. With no custody
152// there is nothing tying "received 100 units at cost 5000" to a real
153// holding. The invariants here prove that the postings follow from the
154// stored lots; they prove nothing about the world.
155//
156// # Why the invariants are free reads, and what the bound costs
157//
158// r/books exposes Rederive as an owner-only crossing function, to contain the
159// cost of a full re-walk. This realm diverges, deliberately and for the same
160// reason r/assets does: the derivation IS the product, and a claim only its
161// owner can check is not a claim worth making. [Rederive], [ItemCrossFoot],
162// the three register cross-foots and [Render] are all free reads, so any third
163// party can verify a register from vm/qeval with no key.
164//
165// MaxLotsPerItem is 64 against p/lots' own MaxLotsLimit of 1024, and the gap
166// is not timidity. p/lots' audit left the cost of its O(live lots) walks
167// explicitly UNPRICED and recorded that a realm rendering many pools must
168// measure its own gas. This realm is that realm, so the figures are measured
169// rather than reasoned about, under the pinned onyx toolchain.
170//
171// There are TWO cost axes, not one, and this paragraph used to name only the
172// first: MaxItemsPerRegister*MaxLotsPerItem live lots, and separately
173// MaxEntriesPerRegister postings. It called 64 items of 64 live lots "the worst
174// case this realm can hold", which is false — a register can sit at the lot cap
175// and the entry cap AT ONCE. It was also claimed in review that the two are
176// mutually exclusive by construction; that is wrong too. A one-unit FIFO
177// shipment draws from the oldest lot, which holds 100 units, so 64 of them
178// never exhaust it: 64 items x 64 receipts then 64 items x 64 one-unit
179// shipments leaves 4096 live lots and exactly 8192 entries. The figures below
180// were taken on a register asserted in-fixture to sit at all three caps
181// simultaneously, so the combined shape is measured and not argued about.
182//
183// Free reads, net of a trivial-read baseline on the same register:
184//
185// Rederive 506,486,523
186// Render, register page 186,179,474
187// CrossFoot 96,213,741
188// Render, item page 6,467,621
189// Render, index 192,716
190//
191// The dearest is 16.9% of the 3,000,000,000 maxGasQuery. The two axes do not
192// compose in any single read's price, because no single read walks both:
193// Rederive walks postings and reads no lot, while CrossFoot and Render walk
194// lots and read three accounts plus EntryCount. Measured rather than inferred —
195// Rederive at the lot cap agrees with the same read on a lot-cheap register
196// (506,495,345) to 0.002%, which is what proves the lot axis does not price it.
197// So the ceiling is the MAX of the two axes and never their product.
198//
199// A bound is not a price, and one item per call is what keeps the write side
200// cheap: [Receive] and [Ship] each touch exactly one pool. Measured on the same
201// toolchain, net of the same kind of baseline:
202//
203// Receive creating the 64th lot 930,676
204// Ship of all OnHand, dropping all 64 lots 11,837,593
205// Receive refused at the lot cap 304,454
206//
207// The dearest write is 2.3% of the dearest read, so the read side is what the
208// caps exist for. See DEPLOY_READINESS.md for the transcript, the per-header
209// GAS pairing rule, and the two limits of these figures: they came from
210// throwaway test files that were deleted rather than committed, so they are
211// reproducible but NOT guarded by the suite; and gno test's GAS line covers a
212// whole test function, so a figure is only a call's own price when the fixture
213// that enables it lives in a separate test. Both halves bit: the refused
214// receipt first measured at 53,123,019 — 174x its real price — because its
215// 64-lot fixture shared its GAS line.
216//
217// # Deploy order
218//
219// This realm imports p/journal and p/lots, so on any chain the order is
220// p/journal, p/lots, then this realm. The order is stated and the deployment
221// STATE of either import deliberately is not: a sentence about what is
222// deployed is true at signing time at best, and addpkg freezes it — r/assets
223// carries exactly that fossil. The import relation is the durable fact.
224package inventory
225
226import (
227 "chain/runtime"
228 "chain/runtime/unsafe"
229 "strconv"
230 "strings"
231
232 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal"
233 "gno.land/p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/lots"
234 "gno.land/p/nt/avl/v0"
235 "gno.land/p/nt/markdown/sanitize/v0"
236)
237
238// RealmPath is this realm's own path, used to build Render links.
239const RealmPath = "/r/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/inventory"
240
241// The fixed chart. The codes follow r/books' convention, where an account's
242// LEADING DIGIT fixes its kind: 1 asset, 2 liability, 3 equity, 4 revenue,
243// 5 expense. A six-series code would be an invalid account under that
244// convention, which is why cost of goods sold is 5000 and not the 6000 some
245// charts use.
246const (
247 InventoryCode = "1300"
248 InventoryName = "Inventory"
249
250 COGSCode = "5000"
251 COGSName = "Cost of goods sold"
252
253 ContribCode = "3900"
254 ContribName = "Inventory contributed"
255)
256
257// MaxItemsPerRegister caps one owner's SKU count. Every register-wide
258// cross-foot walks every item's lots, and this bound is half of what keeps
259// those reads affordable for everyone.
260const MaxItemsPerRegister = 64
261
262// MaxLotsPerItem caps one item's LIVE lots, well below p/lots' MaxLotsLimit of
263// 1024. It is the other half of the read bound: the worst case a register-wide
264// walk must visit is MaxItemsPerRegister*MaxLotsPerItem lots.
265//
266// Exhausted lots are dropped by p/lots, so this bounds concurrent lots and not
267// the number of receipts an item may ever take. A WAVG item merges every
268// receipt into one lot and so can never reach it — which means this is NOT the
269// binding constraint for a WAVG item, and that item has a merge-overflow
270// refusal of its own instead. See the package doc.
271const MaxLotsPerItem = 64
272
273// MaxEntriesPerRegister caps one owner's journal, far below journal's
274// MaxEntriesLimit of 1000000 because Rederive is linear in the posting count
275// and is a free read here.
276//
277// A register that fills refuses further receipts and shipments rather than
278// dropping history. There is deliberately no Archive — it would destroy the
279// record the invariants are checked against — so the remedy is a second
280// register under a second address, which is a real limitation and not a
281// hidden one.
282const MaxEntriesPerRegister = 8192
283
284// MaxSKULen bounds a SKU, which is an avl key, a Render table cell and a
285// Render path component all at once.
286const MaxSKULen = 32
287
288// MaxItemNameLen bounds an item's human name. Every name is a candidate for
289// Render output.
290const MaxItemNameLen = 64
291
292// RenderItemLimit caps how many items one register page tabulates.
293const RenderItemLimit = 25
294
295// RenderLotLimit caps how many lots one item page tabulates.
296const RenderLotLimit = 20
297
298// maxInt64 is the largest int64. It is a precondition of the counter guards
299// below, so it is pinned BY BEHAVIOUR in the suite rather than by a test that
300// restates the literal: TestMaxInt64IsTheInt64Maximum proves it wraps when
301// incremented.
302const maxInt64 int64 = 9223372036854775807
303
304// register is one owner's inventory sub-ledger and the book its postings go
305// to.
306//
307// The book and every pool are POINTERS and none is ever returned by any
308// exported function — p/journal consumer obligation 4 and the identical rule
309// for p/lots: a *Book is a mutable handle and a *Pool is a mutable handle, and
310// handing either across a realm boundary would hand out the right to post or
311// to consume.
312type register struct {
313 book *journal.Book
314 items *avl.Tree // sku -> *item
315 openedAt int64
316}
317
318// item is one stock-keeping unit and its cost pool.
319type item struct {
320 // sku is carried in the value as well as the tree key. The key IS the
321 // sku here — unlike r/assets, whose keys are zero-padded ids — so this
322 // is a convenience rather than a second encoding to keep in step.
323 sku string
324 name string
325 pool *lots.Pool
326 addedAt int64
327
328 // The posted counters, maintained INDEPENDENTLY of the pool. Each is the
329 // cumulative amount this realm actually posted for this item, so
330 // comparing one against the pool's own figure compares two things rather
331 // than restating one. That is the whole of [Invariant].
332 postedRecv int64
333 postedCOGS int64
334}
335
336// regs maps an owner's address string to that owner's *register.
337var regs = avl.NewTree()
338
339// regCount is maintained alongside the tree so the index page never has to
340// iterate it.
341var regCount int
342
343// ---------------------------------------------------------------------------
344// Guards
345// ---------------------------------------------------------------------------
346
347// refuseCoins panics if a user call arrives carrying coins.
348//
349// This realm has no withdrawal path, so accepted coins would be stranded
350// permanently. The guard is on IsUserCall rather than IsUser deliberately:
351// OriginSend is only meaningful for a direct MsgCall, and the MsgRun
352// ephemeral can consume the envelope before forwarding control, so IsUser
353// would be the wrong predicate.
354//
355// The IsCurrent() check is a separate concern and is not about coins: this is
356// a non-crossing helper reading identity off a realm PARAMETER, which is the
357// designation-forgery shape (security.md Class 2) whether or not a caller
358// exercising it exists today. It is a no-op at every call site here, and that
359// claim has one more hop in this realm than in its siblings: addItem is itself
360// non-crossing and forwards the cur it was given, so the chain is
361// AddFIFO/AddLIFO/AddWAVG -> addItem -> here. Every link forwards a crossing
362// entrypoint's OWN first cur, which is runtime-current by construction and
363// stays current through any number of non-crossing calls — so the guard still
364// never fires, and the extra hop is exactly why having it at the point of USE
365// rather than at the entrypoints is the right placement. It is kept because
366// this realm is IMMUTABLE after addpkg. p/coinio guards the same shape in
367// Receive, Payout and Sweep; this is that house precedent applied.
368//
369// No count of the call sites is written down, per CLAUDE.md § 4a.
370func refuseCoins(cur realm) {
371 if !cur.IsCurrent() {
372 panic("inventory: refuseCoins called with a realm that is not current")
373 }
374 if cur.Previous().IsUserCall() && len(unsafe.OriginSend()) > 0 {
375 panic("inventory: this realm holds no coins and has no withdrawal path; send none")
376 }
377}
378
379// mustOwnRegister returns the CALLER'S register, and there is no variant that
380// returns anyone else's. Every mutating entrypoint goes through this, so
381// ownership is implicit in the call.
382//
383// "Ownership is never a parameter" is what this comment used to conclude, and
384// it is true of the SIGNATURE and was never true of the realm VALUE: the owner
385// is derived from cur, so an uncurrent cur IS an ownership parameter. That is
386// the same Class 2 shape refuseCoins carries above and the more consequential
387// half of it, because this is the function that decides who owns the register
388// rather than one that only refuses coins. The addItem hop described above
389// applies here too. The guard is a no-op today for the same reason, kept for
390// the same reason, and its call sites are likewise not counted here. See
391// AUDIT.md.
392func mustOwnRegister(cur realm) *register {
393 if !cur.IsCurrent() {
394 panic("inventory: mustOwnRegister called with a realm that is not current")
395 }
396 owner := cur.Previous().Address()
397 r, ok := reg(owner)
398 if !ok {
399 panic("inventory: no register for " + owner.String() + "; call OpenRegister first")
400 }
401 return r
402}
403
404// reg looks an owner's register up. avl's Get returns a bare `any` and signals
405// absence with nil, so the nil test IS the lookup result and not a defensive
406// extra — every value this realm stores is a non-nil pointer.
407func reg(owner address) (*register, bool) {
408 v := regs.Get(owner.String())
409 if v == nil {
410 return nil, false
411 }
412 return v.(*register), true
413}
414
415func mustReg(owner address) *register {
416 r, ok := reg(owner)
417 if !ok {
418 panic("inventory: no register for " + owner.String())
419 }
420 return r
421}
422
423func mustItem(r *register, sku string) *item {
424 v := r.items.Get(sku)
425 if v == nil {
426 panic("inventory: no item " + sanitize.InlineText(sku))
427 }
428 return v.(*item)
429}
430
431// checkSKU validates a SKU against a deliberately narrow charset:
432// A-Z, a-z, 0-9, '.', '_' and '-'.
433//
434// The restriction buys three things at once, which is why it is worth imposing
435// on a caller. A SKU is an avl KEY, so its byte order is the Render table's
436// row order. It is a Render PATH COMPONENT, so a '/' would forge a route and a
437// '?' or '#' would truncate one. And it is a markdown TABLE CELL, so a '|'
438// would forge a column. Escaping at each use site would be three chances to
439// miss one; a charset is checked once, here, on the way in.
440//
441// Case is significant and "sku-1" and "SKU-1" are different items. That is a
442// real sharp edge for a human operator and is the honest reading of a key:
443// folding case would mean this realm, not the owner, decides which SKUs
444// collide.
445func checkSKU(sku string) {
446 if sku == "" {
447 panic("inventory: sku must not be blank")
448 }
449 if len(sku) > MaxSKULen {
450 panic("inventory: sku too long, max " + strconv.Itoa(MaxSKULen))
451 }
452 for i := 0; i < len(sku); i++ {
453 c := sku[i]
454 switch {
455 case c >= 'A' && c <= 'Z':
456 case c >= 'a' && c <= 'z':
457 case c >= '0' && c <= '9':
458 case c == '.' || c == '_' || c == '-':
459 default:
460 panic("inventory: sku may use only letters, digits, '.', '_' and '-'")
461 }
462 }
463}
464
465func checkItemName(name string) {
466 if strings.TrimSpace(name) == "" {
467 panic("inventory: name must not be blank")
468 }
469 if len(name) > MaxItemNameLen {
470 panic("inventory: name too long, max " + strconv.Itoa(MaxItemNameLen))
471 }
472}
473
474// ---------------------------------------------------------------------------
475// Writes
476// ---------------------------------------------------------------------------
477
478// OpenRegister creates the caller's register and opens its three accounts. It
479// panics if the caller already has one.
480//
481// The chart is opened HERE rather than on demand, and the book is created with
482// a cap of exactly three accounts. Both facts are load-bearing: they are why
483// this realm is its book's only writer and why the cross-foot identities are
484// exact rather than approximate.
485func OpenRegister(cur realm) {
486 refuseCoins(cur)
487 owner := cur.Previous().Address()
488 key := owner.String()
489 if regs.Has(key) {
490 panic("inventory: this address already has a register")
491 }
492
493 b := journal.MustNew(3, MaxEntriesPerRegister)
494 b.MustOpen(InventoryCode, InventoryName, journal.Asset)
495 b.MustOpen(COGSCode, COGSName, journal.Expense)
496 b.MustOpen(ContribCode, ContribName, journal.Equity)
497
498 regs.Set(key, ®ister{
499 book: b,
500 items: avl.NewTree(),
501 openedAt: runtime.ChainHeight(),
502 })
503 regCount++
504}
505
506// AddFIFO adds an item whose shipments are costed oldest-lot-first.
507func AddFIFO(cur realm, sku, name string) {
508 addItem(cur, sku, name, lots.FIFO)
509}
510
511// AddLIFO adds an item whose shipments are costed newest-lot-first.
512func AddLIFO(cur realm, sku, name string) {
513 addItem(cur, sku, name, lots.LIFO)
514}
515
516// AddWAVG adds an item whose receipts are merged into a single re-anchored lot
517// and whose shipments are costed at the blended unit cost.
518//
519// A WAVG item holds exactly one lot however many receipts it takes, so it can
520// never reach MaxLotsPerItem. It can still refuse a receipt: the MERGE has an
521// overflow precondition the other two methods never evaluate, and it is a
522// property of the pool's accumulated history rather than of the receipt. See
523// the package doc; the practical consequence is that [LotCapacity] is not the
524// figure to watch for a WAVG item.
525func AddWAVG(cur realm, sku, name string) {
526 addItem(cur, sku, name, lots.WAVG)
527}
528
529func addItem(cur realm, sku, name string, m lots.Method) {
530 refuseCoins(cur)
531 r := mustOwnRegister(cur)
532 checkSKU(sku)
533 checkItemName(name)
534
535 if r.items.Has(sku) {
536 panic("inventory: item " + sanitize.InlineText(sku) + " already exists")
537 }
538 if r.items.Size() >= MaxItemsPerRegister {
539 panic("inventory: register is full, max " + strconv.Itoa(MaxItemsPerRegister) + " items")
540 }
541
542 p, err := lots.NewPool(m, MaxLotsPerItem)
543 if err != nil {
544 panic(err)
545 }
546 r.items.Set(sku, &item{
547 sku: sku,
548 name: name,
549 pool: p,
550 addedAt: runtime.ChainHeight(),
551 })
552}
553
554// Receive takes qty units costing cost in total into the item's pool and posts
555// debit 1300 / credit 3900, returning the entry id.
556//
557// A cost of ZERO is accepted — free goods — and posts nothing; the returned id
558// is then 0, which is not a valid entry id and is the signal that no entry was
559// made. See the package doc.
560//
561// Panics if the item does not exist, if qty is not positive, if cost is
562// negative, if the pool's lot cap is reached, if a WAVG merge would breach the
563// per-lot overflow precondition — a refusal that depends on the item's history
564// and not on this receipt, see [AddWAVG] — or if the book is full. Every
565// one of those aborts the whole transaction, which is what makes the
566// pool-then-post order safe: an error return would NOT revert the pool update,
567// a panic does.
568func Receive(cur realm, sku string, qty, cost int64) uint64 {
569 refuseCoins(cur)
570 r := mustOwnRegister(cur)
571 it := mustItem(r, sku)
572
573 if err := it.pool.Receive(qty, cost); err != nil {
574 panic(err)
575 }
576
577 // Guard the realm's own counter before committing it. The bound argument
578 // says this cannot fire — postedRecv tracks pool.ReceivedCost() exactly,
579 // and p/lots already refused to let that overflow — and it is written
580 // anyway, because "unreachable" is an argument about code that may be
581 // edited and the cost of being wrong here is a silently negative
582 // inventory figure. p/lots' CostOnHand makes the same trade for the same
583 // reason.
584 if it.postedRecv > maxInt64-cost {
585 panic("inventory: posted receipt counter would overflow")
586 }
587 it.postedRecv += cost
588
589 if cost == 0 {
590 return 0
591 }
592 return r.book.MustPost(runtime.ChainHeight(), "receive "+sku, []journal.Posting{
593 {Account: InventoryCode, Side: journal.Debit, Amount: cost},
594 {Account: ContribCode, Side: journal.Credit, Amount: cost},
595 })
596}
597
598// Ship consumes qty units from the item's pool and posts debit 5000 /
599// credit 1300 for the cost it DERIVED, returning that cost.
600//
601// The cost is never a parameter. It is whatever the item's costing method
602// charges against the lots on hand, which is the whole product.
603//
604// A derived cost of ZERO posts nothing — goods that cost nothing to acquire
605// cost nothing to ship. The return value is then 0 and so is indistinguishable
606// from "no entry made", deliberately: both facts are the same fact here.
607//
608// Panics if the item does not exist, if qty is not positive, if qty exceeds
609// the quantity on hand (p/lots fails closed with ErrInsufficient and consumes
610// nothing), or if the book is full.
611func Ship(cur realm, sku string, qty int64) int64 {
612 refuseCoins(cur)
613 r := mustOwnRegister(cur)
614 it := mustItem(r, sku)
615
616 charge, err := it.pool.Consume(qty)
617 if err != nil {
618 panic(err)
619 }
620
621 // Same trade as Receive's guard, and the same bound argument: postedCOGS
622 // tracks pool.ChargedCost(), whose accumulation p/lots guards.
623 if it.postedCOGS > maxInt64-charge {
624 panic("inventory: posted COGS counter would overflow")
625 }
626 it.postedCOGS += charge
627
628 if charge == 0 {
629 return 0
630 }
631 r.book.MustPost(runtime.ChainHeight(), "ship "+sku, []journal.Posting{
632 {Account: COGSCode, Side: journal.Debit, Amount: charge},
633 {Account: InventoryCode, Side: journal.Credit, Amount: charge},
634 })
635 return charge
636}
637
638// Rename changes an item's human name. The SKU, the costing method and every
639// lot are immutable; a name is presentation only and nothing derives from it.
640func Rename(cur realm, sku, name string) {
641 refuseCoins(cur)
642 r := mustOwnRegister(cur)
643 it := mustItem(r, sku)
644 checkItemName(name)
645 it.name = name
646}
647
648// ---------------------------------------------------------------------------
649// Reads — register
650// ---------------------------------------------------------------------------
651
652// HasRegister reports whether an address has opened a register.
653func HasRegister(owner address) bool { return regs.Has(owner.String()) }
654
655// Registers is how many registers exist across the realm.
656func Registers() int { return regCount }
657
658// OpenedAt is the height an owner's register was opened at.
659func OpenedAt(owner address) int64 { return mustReg(owner).openedAt }
660
661// ItemCount is how many items an owner's register holds.
662func ItemCount(owner address) int { return mustReg(owner).items.Size() }
663
664// Height is the current chain height, so a reader can date every other figure
665// on this page without a second query.
666func Height() int64 { return runtime.ChainHeight() }
667
668// SKUs lists an owner's SKUs in key order, starting at or after from and
669// returning at most limit of them. An empty from starts at the beginning.
670//
671// This enumerator exists because its absence is a known defect elsewhere in
672// this portfolio: r/books exposes no account enumerator, so a third party
673// cannot discover what to ask it about without already knowing. A sub-ledger
674// whose invariants are per-item is useless to a verifier who cannot list the
675// items, so the list is part of the product rather than an afterthought.
676//
677// A non-positive limit returns nothing rather than everything. The cap is the
678// item cap, so a caller may always page the whole register.
679func SKUs(owner address, from string, limit int) []string {
680 r := mustReg(owner)
681 if limit <= 0 {
682 return nil
683 }
684 if limit > MaxItemsPerRegister {
685 limit = MaxItemsPerRegister
686 }
687 out := make([]string, 0, limit)
688 r.items.Iterate(from, "", func(k string, _ any) bool {
689 if len(out) >= limit {
690 return true
691 }
692 out = append(out, k)
693 return false
694 })
695 return out
696}
697
698// ---------------------------------------------------------------------------
699// Reads — item
700// ---------------------------------------------------------------------------
701
702// HasItem reports whether an owner's register holds a SKU.
703func HasItem(owner address, sku string) bool {
704 r, ok := reg(owner)
705 if !ok {
706 return false
707 }
708 return r.items.Has(sku)
709}
710
711// ItemName is an item's human name.
712func ItemName(owner address, sku string) string {
713 return mustItem(mustReg(owner), sku).name
714}
715
716// Method is an item's costing method, as "fifo", "lifo" or
717// "weighted-average".
718func Method(owner address, sku string) string {
719 return mustItem(mustReg(owner), sku).pool.Method().String()
720}
721
722// AddedAt is the height an item was added at.
723func AddedAt(owner address, sku string) int64 {
724 return mustItem(mustReg(owner), sku).addedAt
725}
726
727// OnHand is the quantity of an item on hand, DERIVED by walking its live lots
728// rather than read from a counter — which is what makes [ItemCrossFootQty] a
729// comparison rather than a tautology.
730func OnHand(owner address, sku string) int64 {
731 return mustItem(mustReg(owner), sku).pool.QtyOnHand()
732}
733
734// CostOnHand is the carrying value of an item's live lots: the figure this
735// item contributes to the 1300 balance. Derived, for the same reason as
736// [OnHand].
737func CostOnHand(owner address, sku string) int64 {
738 c, err := mustItem(mustReg(owner), sku).pool.CostOnHand()
739 if err != nil {
740 panic(err)
741 }
742 return c
743}
744
745// Received is an item's cumulative received quantity and cost.
746func Received(owner address, sku string) (qty, cost int64) {
747 p := mustItem(mustReg(owner), sku).pool
748 return p.ReceivedQty(), p.ReceivedCost()
749}
750
751// Charged is an item's cumulative shipped quantity and the cost charged out
752// for it. The cost figure is derived: no caller has ever supplied it.
753func Charged(owner address, sku string) (qty, cost int64) {
754 p := mustItem(mustReg(owner), sku).pool
755 return p.ConsumedQty(), p.ChargedCost()
756}
757
758// Receipts is how many receipts an item has taken, including those whose lots
759// are exhausted and dropped. It is NOT the live lot count — see [LotCount].
760func Receipts(owner address, sku string) int64 {
761 return mustItem(mustReg(owner), sku).pool.ReceiptCount()
762}
763
764// LotCount is how many LIVE lots an item holds. Exhausted lots are dropped.
765func LotCount(owner address, sku string) int {
766 return mustItem(mustReg(owner), sku).pool.LotCount()
767}
768
769// LotAt reports the i'th live lot of an item in receipt order, and whether i
770// was in range. It returns primitives rather than a p/lots.Lot so that the
771// figures are readable from vm/qeval without the caller knowing that type.
772func LotAt(owner address, sku string, i int) (qty, cost, consumed, remaining, remainingCost int64, ok bool) {
773 l, found := mustItem(mustReg(owner), sku).pool.Lot(i)
774 if !found {
775 return 0, 0, 0, 0, 0, false
776 }
777 rc, err := l.RemainingCost()
778 if err != nil {
779 panic(err)
780 }
781 return l.Qty, l.Cost, l.Consumed, l.Remaining(), rc, true
782}
783
784// Capacity reports how much room an owner's register has left.
785func Capacity(owner address) (itemsLeft, entriesLeft int) {
786 r := mustReg(owner)
787 return MaxItemsPerRegister - r.items.Size(), MaxEntriesPerRegister - r.book.EntryCount()
788}
789
790// LotCapacity reports how many further live lots one item can hold. A WAVG
791// item merges into one lot, so its figure is a bound it will never approach —
792// and reading headroom off it for a WAVG item is therefore reading the wrong
793// limit, because the refusal that CAN bind there is a merge overflow this
794// figure knows nothing about. See [AddWAVG].
795func LotCapacity(owner address, sku string) int {
796 return MaxLotsPerItem - mustItem(mustReg(owner), sku).pool.LotCount()
797}
798
799// ---------------------------------------------------------------------------
800// Reads — the ledger
801// ---------------------------------------------------------------------------
802
803// Balance returns an account's balance in its NORMAL direction and whether the
804// code exists. All three of this realm's accounts are normal, so all three
805// read non-negative.
806func Balance(owner address, code string) (int64, bool) {
807 return mustReg(owner).book.Balance(code)
808}
809
810// RawBalance returns debits minus credits regardless of kind. Summed over the
811// three accounts it is exactly zero in a balanced book, which is what makes it
812// the useful figure for a consistency check and the wrong one for a report.
813func RawBalance(owner address, code string) (int64, bool) {
814 a, ok := mustReg(owner).book.Account(code)
815 if !ok {
816 return 0, false
817 }
818 return a.RawDelta(), true
819}
820
821// AccountKind returns an account's kind as a word, and whether it exists.
822func AccountKind(owner address, code string) (string, bool) {
823 a, ok := mustReg(owner).book.Account(code)
824 if !ok {
825 return "", false
826 }
827 return a.Kind.String(), true
828}
829
830// InventoryDebit is the 1300 balance: the value of goods on hand per the
831// ledger.
832func InventoryDebit(owner address) int64 {
833 b, ok := mustReg(owner).book.Balance(InventoryCode)
834 if !ok {
835 panic("inventory: register has no " + InventoryCode + " account")
836 }
837 return b
838}
839
840// COGSDebit is the 5000 balance: everything ever charged out as cost of goods
841// sold.
842func COGSDebit(owner address) int64 {
843 b, ok := mustReg(owner).book.Balance(COGSCode)
844 if !ok {
845 panic("inventory: register has no " + COGSCode + " account")
846 }
847 return b
848}
849
850// ContributedCredit is the 3900 balance. Read it as "goods arrived from
851// somewhere this ledger does not model" — see the package doc on why 3900 is a
852// plug this realm is forced to pick.
853func ContributedCredit(owner address) int64 {
854 b, ok := mustReg(owner).book.Balance(ContribCode)
855 if !ok {
856 panic("inventory: register has no " + ContribCode + " account")
857 }
858 return b
859}
860
861// EntryCount is how many entries an owner's book holds.
862func EntryCount(owner address) int { return mustReg(owner).book.EntryCount() }
863
864// Totals are the book's total debits and credits.
865func Totals(owner address) (debits, credits int64) {
866 b := mustReg(owner).book
867 return b.TotalDebits(), b.TotalCredits()
868}
869
870// IsBalanced reports whether total debits equal total credits.
871func IsBalanced(owner address) bool { return mustReg(owner).book.IsBalanced() }
872
873// Equation reports the accounting equation: debit-normal against
874// credit-normal, and whether they offset. For this realm that is
875// 1300 + 5000 against 3900, which offsets at 0 = 0 on an empty register and
876// after every posting.
877//
878// READ THE SIGN BEFORE PRESENTING EITHER FIGURE. journal partitions RAW
879// DELTAS — debits minus credits — so creditNormal is NEGATIVE whenever 3900
880// carries a credit balance, and `offsets` means debitNormal + creditNormal
881// == 0, not debitNormal == creditNormal. The textbook "assets = equity" has
882// both sides positive; this does not. A caller rendering a balance sheet
883// wants -creditNormal. The first test written against this function asserted
884// the right magnitude with the wrong sign and was red for it, which is why
885// this paragraph exists rather than a reader discovering it.
886//
887// What `offsets` does NOT prove is journal's obligation 7, inherited here
888// verbatim: the two subtotals are a partition of the same addends, so
889// reclassifying every account in the chart changes both and leaves `offsets`
890// true. A Kind set wrongly at Open time is invisible to it. What pins this
891// realm's chart is the PAIR — debitNormal == InventoryDebit + COGSDebit and
892// creditNormal == -ContributedCredit — and the suite asserts exactly that.
893//
894// Sharper than that, because the partition is arithmetically inert: the two
895// subtotals are added back together, so `offsets` reduces to
896// Σ debits == Σ credits over the whole chart, which is the FIRST of the three
897// conjuncts [IsBalanced] checks. A balanced book therefore always offsets, and
898// `offsets` carries no fact [IsBalanced] has not already carried more
899// strongly. The informative half of this function is the PAIR OF FIGURES, not
900// the boolean — the figures move when a Kind changes and the boolean does not.
901// (The two walks accumulate in different orders, so a mid-sum overflow could
902// in principle separate them. At 8192 entries it would take amounts no
903// register can reach, and it is not a second independent check.)
904func Equation(owner address) (debitNormal, creditNormal int64, offsets bool) {
905 return mustReg(owner).book.AccountingEquation()
906}
907
908// Rederive re-walks every posting in the book and reports whether the account
909// totals agree with the journal.
910//
911// It is a FREE read, unlike r/books' owner-only equivalent, for the reason
912// given in the package doc. The cost is linear in the posting count, which is
913// what MaxEntriesPerRegister bounds.
914func Rederive(owner address) (debits, credits int64, agrees bool) {
915 return mustReg(owner).book.RederiveFromJournal()
916}
917
918// ---------------------------------------------------------------------------
919// The invariants
920// ---------------------------------------------------------------------------
921
922// Invariant is the per-item derivation check, and it is this realm's central
923// claim:
924//
925// postedCOGS == pool.ChargedCost()
926//
927// The left figure is what this realm posted to 5000 for the item; the right is
928// what the pool says it charged. They are maintained by different code in
929// different packages, so a false here means the realm posted an amount its own
930// lots do not imply — which no code path in this realm can produce, and which
931// is exactly why it is worth exporting. A claim is worth more checkable by a
932// stranger than asserted in a comment.
933//
934// The receipt side is checked the same way by [InvariantReceipts].
935func Invariant(owner address, sku string) (posted, derived int64, agrees bool) {
936 it := mustItem(mustReg(owner), sku)
937 d := it.pool.ChargedCost()
938 return it.postedCOGS, d, it.postedCOGS == d
939}
940
941// InvariantReceipts is [Invariant] for the receipt side:
942//
943// postedRecv == pool.ReceivedCost()
944func InvariantReceipts(owner address, sku string) (posted, derived int64, agrees bool) {
945 it := mustItem(mustReg(owner), sku)
946 d := it.pool.ReceivedCost()
947 return it.postedRecv, d, it.postedRecv == d
948}
949
950// ItemCrossFoot is p/lots' own cost identity for one item:
951//
952// receivedCost == chargedCost + costOnHand
953//
954// Received and charged are counters; on-hand is a walk of the live lots. The
955// identity therefore compares two independently maintained things, which is
956// why p/lots keeps them apart.
957func ItemCrossFoot(owner address, sku string) (received, charged, onHand int64, foots bool) {
958 return mustItem(mustReg(owner), sku).pool.CrossFootCost()
959}
960
961// ItemCrossFootQty is the quantity identity for one item:
962//
963// receivedQty == consumedQty + qtyOnHand
964//
965// It is not implied by [ItemCrossFoot]: costs and quantities can disagree
966// independently, and a bug that moved cost without moving quantity would be
967// invisible to the cost identity alone.
968func ItemCrossFootQty(owner address, sku string) (received, consumed, onHand int64, foots bool) {
969 return mustItem(mustReg(owner), sku).pool.CrossFootQty()
970}
971
972// CrossFootInventory is the register-wide check on 1300: the sum of every
973// item's carrying value against the ledger balance.
974//
975// The identity is EXACT, not approximate, and the reason is structural: this
976// realm exposes no general posting entrypoint, so it is its book's only
977// writer. Adding a general Post would destroy this check, which is the cost of
978// adding one.
979func CrossFootInventory(owner address) (sumOnHand, balance int64, agrees bool) {
980 r := mustReg(owner)
981 var sum int64
982 var failed bool
983 r.items.Iterate("", "", func(_ string, v any) bool {
984 c, err := v.(*item).pool.CostOnHand()
985 if err != nil {
986 failed = true
987 return true
988 }
989 sum += c
990 return false
991 })
992 b := InventoryDebit(owner)
993 if failed {
994 // A pool that cannot value itself makes the sum meaningless, so the
995 // check REPORTS disagreement rather than panicking or quietly
996 // returning a short sum. A partial total compared against a full
997 // balance is the shape of a false pass.
998 return 0, b, false
999 }
1000 return sum, b, sum == b
1001}
1002
1003// CrossFootCOGS is the register-wide check on 5000: the sum of every item's
1004// charged cost against the ledger balance.
1005func CrossFootCOGS(owner address) (sumCharged, balance int64, agrees bool) {
1006 r := mustReg(owner)
1007 var sum int64
1008 r.items.Iterate("", "", func(_ string, v any) bool {
1009 sum += v.(*item).pool.ChargedCost()
1010 return false
1011 })
1012 b := COGSDebit(owner)
1013 return sum, b, sum == b
1014}
1015
1016// CrossFootContributed is the register-wide check on 3900: the sum of every
1017// item's received cost against the ledger balance.
1018func CrossFootContributed(owner address) (sumReceived, balance int64, agrees bool) {
1019 r := mustReg(owner)
1020 var sum int64
1021 r.items.Iterate("", "", func(_ string, v any) bool {
1022 sum += v.(*item).pool.ReceivedCost()
1023 return false
1024 })
1025 b := ContributedCredit(owner)
1026 return sum, b, sum == b
1027}
1028
1029// CrossFoot reports all four register-wide agreements as separate bools.
1030//
1031// They are returned SEPARATELY and there is deliberately no single aggregate
1032// bool: a one-word verdict over four checks is the shape that gets quoted
1033// without its coverage, and a reader who wants the figures behind any one of
1034// these has [CrossFootInventory], [CrossFootCOGS], [CrossFootContributed] and
1035// [Equation].
1036func CrossFoot(owner address) (inventoryAgrees, cogsAgrees, contributedAgrees, equationOffsets bool) {
1037 _, _, inv := CrossFootInventory(owner)
1038 _, _, cogs := CrossFootCOGS(owner)
1039 _, _, contrib := CrossFootContributed(owner)
1040 _, _, offsets := Equation(owner)
1041 return inv, cogs, contrib, offsets
1042}
1043
1044// ---------------------------------------------------------------------------
1045// Render
1046// ---------------------------------------------------------------------------
1047
1048// Render shows the realm index at "/", one owner's register at "/<address>",
1049// and one item at "/<address>/<sku>".
1050func Render(path string) string {
1051 p := strings.TrimSpace(strings.Trim(path, "/"))
1052 if p == "" {
1053 return renderIndex()
1054 }
1055 if i := strings.Index(p, "/"); i >= 0 {
1056 return renderItem(address(p[:i]), p[i+1:])
1057 }
1058 return renderRegister(address(p))
1059}
1060
1061func renderIndex() string {
1062 var sb strings.Builder
1063 sb.WriteString("# inventory — a sub-ledger whose COGS is derived\n\n")
1064 sb.WriteString("Cost lots held as state, the cost of a shipment **derived** ")
1065 sb.WriteString("from those lots by the item's costing method, and **no ")
1066 sb.WriteString("custody of anything**.\n\n")
1067
1068 sb.WriteString("> A general ledger takes the amount from its caller, so it ")
1069 sb.WriteString("cannot tell a correctly costed shipment from a flattering ")
1070 sb.WriteString("one. This realm never takes an amount: `Ship` takes a ")
1071 sb.WriteString("quantity and returns the cost it derived. That is the whole ")
1072 sb.WriteString("product, and it proves arithmetic — not truth. Nothing here ")
1073 sb.WriteString("ties a cost figure to a real holding.\n\n")
1074
1075 sb.WriteString("## State\n\n")
1076 sb.WriteString("- registers opened: **" + strconv.Itoa(regCount) + "**\n")
1077 sb.WriteString("- chain height: **" + strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n\n")
1078
1079 sb.WriteString("## The chart is three accounts and is fixed\n\n")
1080 sb.WriteString("| code | name | kind | normal |\n")
1081 sb.WriteString("|---|---|---|---|\n")
1082 sb.WriteString("| `" + InventoryCode + "` | " + InventoryName + " | asset | debit |\n")
1083 sb.WriteString("| `" + COGSCode + "` | " + COGSName + " | expense | debit |\n")
1084 sb.WriteString("| `" + ContribCode + "` | " + ContribName + " | equity | credit |\n\n")
1085 sb.WriteString("A receipt is debit `" + InventoryCode + "` / credit `")
1086 sb.WriteString(ContribCode + "`. A shipment is debit `" + COGSCode)
1087 sb.WriteString("` / credit `" + InventoryCode + "` for the derived cost. ")
1088 sb.WriteString("There is no way to open a fourth account.\n\n")
1089 sb.WriteString("**`" + ContribCode + "` is a plug.** A debit to inventory ")
1090 sb.WriteString("needs a credit leg, and every candidate asserts something ")
1091 sb.WriteString("this realm cannot observe — a payable it would never ")
1092 sb.WriteString("relieve, a cash balance it does not hold, or a ")
1093 sb.WriteString("contribution. The contribution was chosen because it is ")
1094 sb.WriteString("the only one that stays coherent with no follow-up event, ")
1095 sb.WriteString("**not** because it is true. A register whose goods were ")
1096 sb.WriteString("bought on credit understates liabilities by exactly this ")
1097 sb.WriteString("balance.\n\n")
1098
1099 sb.WriteString("## Costing methods\n\n")
1100 sb.WriteString("| entrypoint | method | lots held |\n")
1101 sb.WriteString("|---|---|---|\n")
1102 sb.WriteString("| `AddFIFO` | " + lots.FIFO.String() + " | one per live receipt |\n")
1103 sb.WriteString("| `AddLIFO` | " + lots.LIFO.String() + " | one per live receipt |\n")
1104 sb.WriteString("| `AddWAVG` | " + lots.WAVG.String() + " | always exactly one |\n\n")
1105 sb.WriteString("Fixed at creation. Changing an item's method mid-life ")
1106 sb.WriteString("would recost nothing already charged but would change what ")
1107 sb.WriteString("the next shipment costs, which is a restatement a ")
1108 sb.WriteString("sub-ledger must not be able to make silently.\n\n")
1109 sb.WriteString("\"Always exactly one\" does not mean unconstrained. A ")
1110 sb.WriteString(lots.WAVG.String() + " item escapes the live-lot bound and ")
1111 sb.WriteString("gains a refusal the other two never evaluate: merging a ")
1112 sb.WriteString("receipt into the single lot can breach that lot's own ")
1113 sb.WriteString("overflow precondition, and whether it does depends on the ")
1114 sb.WriteString("item's accumulated history rather than on the receipt. So ")
1115 sb.WriteString("`LotCapacity` is the wrong headroom figure for a ")
1116 sb.WriteString(lots.WAVG.String() + " item — it reports the one limit that ")
1117 sb.WriteString("cannot bind there.\n\n")
1118
1119 sb.WriteString("## Bounds\n\n")
1120 sb.WriteString("| bound | value |\n")
1121 sb.WriteString("|---|---|\n")
1122 sb.WriteString("| items per register | " + strconv.Itoa(MaxItemsPerRegister) + " |\n")
1123 sb.WriteString("| live lots per item | " + strconv.Itoa(MaxLotsPerItem) + " |\n")
1124 sb.WriteString("| entries per register | " + strconv.Itoa(MaxEntriesPerRegister) + " |\n")
1125 sb.WriteString("| sku length | " + strconv.Itoa(MaxSKULen) + " |\n")
1126 sb.WriteString("| name length | " + strconv.Itoa(MaxItemNameLen) + " |\n\n")
1127 sb.WriteString("The entry bound is **register-wide and shared**, not per ")
1128 sb.WriteString("item, and that is the one bound here with a blast radius ")
1129 sb.WriteString("wider than the thing it limits. Every costed receipt and ")
1130 sb.WriteString("every costed shipment of every SKU spends one entry out of ")
1131 sb.WriteString("the same " + strconv.Itoa(MaxEntriesPerRegister) + ", and ")
1132 sb.WriteString("entries are never freed — exhausted lots are dropped, ")
1133 sb.WriteString("postings are not. So a single high-turnover SKU can spend ")
1134 sb.WriteString("the whole budget and leave the other ")
1135 sb.WriteString(strconv.Itoa(MaxItemsPerRegister-1) + " items unable to ")
1136 sb.WriteString("receive or ship, each with capacity of its own to spare. ")
1137 sb.WriteString("There is deliberately no archive and no reset — either ")
1138 sb.WriteString("would destroy the record the cross-foots are checked ")
1139 sb.WriteString("against — so the remedy is a second register under a ")
1140 sb.WriteString("second address, and busy SKUs are worth separating before ")
1141 sb.WriteString("they are busy. Watch `Capacity`'s second figure, which ")
1142 sb.WriteString("counts down for the register and not for one item.\n\n")
1143 sb.WriteString("A SKU may use only letters, digits, `.`, `_` and `-`: it is ")
1144 sb.WriteString("an index key, a page path and a table cell at once, and a ")
1145 sb.WriteString("charset checked on the way in beats escaping at three use ")
1146 sb.WriteString("sites. Case is significant.\n\n")
1147 sb.WriteString("There is **no write-off**: goods cannot leave without ")
1148 sb.WriteString("being charged to `" + COGSCode + "`, so shrinkage is ")
1149 sb.WriteString("indistinguishable from a sale. A real limitation, named ")
1150 sb.WriteString("rather than hidden.\n\n")
1151
1152 sb.WriteString("## Reading a register\n\n")
1153 sb.WriteString("`" + RealmPath + ":<address>`\n\n")
1154 sb.WriteString("`" + RealmPath + ":<address>/<sku>`\n")
1155 return sb.String()
1156}
1157
1158func renderRegister(owner address) string {
1159 r, ok := reg(owner)
1160 if !ok {
1161 return "# No register\n\nNo register exists for `" +
1162 sanitize.InlineText(owner.String()) + "`.\n"
1163 }
1164
1165 var sb strings.Builder
1166 sb.WriteString("# Register of " + sanitize.InlineText(owner.String()) + "\n\n")
1167 sb.WriteString("- opened at height: **" + strconv.FormatInt(r.openedAt, 10) + "**\n")
1168 sb.WriteString("- chain height: **" + strconv.FormatInt(runtime.ChainHeight(), 10) + "**\n")
1169 sb.WriteString("- items: **" + strconv.Itoa(r.items.Size()) + "** / " +
1170 strconv.Itoa(MaxItemsPerRegister) + "\n")
1171 sb.WriteString("- entries: **" + strconv.Itoa(r.book.EntryCount()) + "** / " +
1172 strconv.Itoa(MaxEntriesPerRegister) + "\n\n")
1173
1174 sumOnHand, invBal, invOK := CrossFootInventory(owner)
1175 sumCharged, cogsBal, cogsOK := CrossFootCOGS(owner)
1176 sumRecv, contribBal, contribOK := CrossFootContributed(owner)
1177 dn, cn, offsets := Equation(owner)
1178
1179 sb.WriteString("## Cross-foot\n\n")
1180 sb.WriteString("| account | sum over items | ledger balance | agrees |\n")
1181 sb.WriteString("|---|---|---|---|\n")
1182 sb.WriteString("| `" + InventoryCode + "` on hand | " +
1183 strconv.FormatInt(sumOnHand, 10) + " | " +
1184 strconv.FormatInt(invBal, 10) + " | **" + yesno(invOK) + "** |\n")
1185 sb.WriteString("| `" + COGSCode + "` charged | " +
1186 strconv.FormatInt(sumCharged, 10) + " | " +
1187 strconv.FormatInt(cogsBal, 10) + " | **" + yesno(cogsOK) + "** |\n")
1188 sb.WriteString("| `" + ContribCode + "` received | " +
1189 strconv.FormatInt(sumRecv, 10) + " | " +
1190 strconv.FormatInt(contribBal, 10) + " | **" + yesno(contribOK) + "** |\n\n")
1191 sb.WriteString("| figure | value |\n|---|---|\n")
1192 sb.WriteString("| debit-normal total | " + strconv.FormatInt(dn, 10) + " |\n")
1193 sb.WriteString("| credit-normal total | " + strconv.FormatInt(cn, 10) + " |\n")
1194 sb.WriteString("| equation offsets | **" + yesno(offsets) + "** |\n")
1195 sb.WriteString("| book balanced | **" + yesno(r.book.IsBalanced()) + "** |\n\n")
1196 sb.WriteString("Those last two rows are **not two independent checks**, ")
1197 sb.WriteString("and a reader counting agreements would over-count here. ")
1198 sb.WriteString("The equation partitions accounts into debit-normal and ")
1199 sb.WriteString("credit-normal and then adds the two subtotals back ")
1200 sb.WriteString("together, so \"offsets\" reduces to total debits equal ")
1201 sb.WriteString("total credits — which is the first of the three things ")
1202 sb.WriteString("\"balanced\" checks. A balanced book always offsets. ")
1203 sb.WriteString("Neither row can see an account **classified** wrongly: ")
1204 sb.WriteString("reclassifying the whole chart moves the two totals above ")
1205 sb.WriteString("and leaves both booleans true. What pins the ")
1206 sb.WriteString("classification is the cross-foot table, where each ")
1207 sb.WriteString("account's ledger balance is compared against a figure ")
1208 sb.WriteString("derived from the items independently of the chart.\n\n")
1209
1210 sb.WriteString("## Items\n\n")
1211 if r.items.Size() == 0 {
1212 sb.WriteString("_None added yet._\n")
1213 return sb.String()
1214 }
1215 sb.WriteString("| sku | name | method | lots | on hand | cost on hand | received | charged | ok |\n")
1216 sb.WriteString("|---|---|---|---|---|---|---|---|---|\n")
1217
1218 shown := 0
1219 total := r.items.Size()
1220 r.items.Iterate("", "", func(_ string, v any) bool {
1221 if shown >= RenderItemLimit {
1222 return true
1223 }
1224 it := v.(*item)
1225 coh, err := it.pool.CostOnHand()
1226 ok := err == nil && it.postedCOGS == it.pool.ChargedCost()
1227 _, _, _, foots := it.pool.CrossFootCost()
1228 sb.WriteString("| [" + cell(it.sku) + "](" + RealmPath + ":" +
1229 owner.String() + "/" + it.sku + ") | " + cell(it.name) + " | " +
1230 cell(it.pool.Method().String()) + " | " +
1231 strconv.Itoa(it.pool.LotCount()) + " | " +
1232 strconv.FormatInt(it.pool.QtyOnHand(), 10) + " | " +
1233 strconv.FormatInt(coh, 10) + " | " +
1234 strconv.FormatInt(it.pool.ReceivedCost(), 10) + " | " +
1235 strconv.FormatInt(it.pool.ChargedCost(), 10) + " | " +
1236 yesno(ok && foots) + " |\n")
1237 shown++
1238 return false
1239 })
1240 if shown < total {
1241 sb.WriteString("\n_Showing " + strconv.Itoa(shown) + " of " +
1242 strconv.Itoa(total) + " items._\n")
1243 }
1244 return sb.String()
1245}
1246
1247func renderItem(owner address, sku string) string {
1248 r, ok := reg(owner)
1249 if !ok {
1250 return "# No register\n\nNo register exists for `" +
1251 sanitize.InlineText(owner.String()) + "`.\n"
1252 }
1253 v := r.items.Get(sku)
1254 if v == nil {
1255 return "# No item\n\nNo item `" + sanitize.InlineText(sku) +
1256 "` in the register of `" + sanitize.InlineText(owner.String()) + "`.\n"
1257 }
1258 it := v.(*item)
1259
1260 var sb strings.Builder
1261 sb.WriteString("# " + cell(it.sku) + " — " + cell(it.name) + "\n\n")
1262 sb.WriteString("- owner: `" + sanitize.InlineText(owner.String()) + "`\n")
1263 sb.WriteString("- costing method: **" + cell(it.pool.Method().String()) + "**\n")
1264 sb.WriteString("- added at height: **" + strconv.FormatInt(it.addedAt, 10) + "**\n")
1265 sb.WriteString("- receipts taken: **" + strconv.FormatInt(it.pool.ReceiptCount(), 10) + "**\n")
1266 sb.WriteString("- live lots: **" + strconv.Itoa(it.pool.LotCount()) + "** / " +
1267 strconv.Itoa(MaxLotsPerItem) + "\n\n")
1268
1269 recv, charged, onHand, foots := it.pool.CrossFootCost()
1270 rq, cq, qOnHand, qFoots := it.pool.CrossFootQty()
1271 sb.WriteString("## Cross-foot\n\n")
1272 sb.WriteString("| identity | received | out | on hand | foots |\n")
1273 sb.WriteString("|---|---|---|---|---|\n")
1274 sb.WriteString("| cost | " + strconv.FormatInt(recv, 10) + " | " +
1275 strconv.FormatInt(charged, 10) + " | " +
1276 strconv.FormatInt(onHand, 10) + " | **" + yesno(foots) + "** |\n")
1277 sb.WriteString("| quantity | " + strconv.FormatInt(rq, 10) + " | " +
1278 strconv.FormatInt(cq, 10) + " | " +
1279 strconv.FormatInt(qOnHand, 10) + " | **" + yesno(qFoots) + "** |\n\n")
1280 sb.WriteString("| posted vs derived | posted | derived | agrees |\n")
1281 sb.WriteString("|---|---|---|---|\n")
1282 sb.WriteString("| cost of goods sold | " + strconv.FormatInt(it.postedCOGS, 10) +
1283 " | " + strconv.FormatInt(it.pool.ChargedCost(), 10) + " | **" +
1284 yesno(it.postedCOGS == it.pool.ChargedCost()) + "** |\n")
1285 sb.WriteString("| receipts | " + strconv.FormatInt(it.postedRecv, 10) +
1286 " | " + strconv.FormatInt(it.pool.ReceivedCost(), 10) + " | **" +
1287 yesno(it.postedRecv == it.pool.ReceivedCost()) + "** |\n\n")
1288
1289 sb.WriteString("## Live lots\n\n")
1290 all := it.pool.Lots()
1291 if len(all) == 0 {
1292 sb.WriteString("_No lots on hand._\n")
1293 return sb.String()
1294 }
1295 sb.WriteString("| # | qty | cost | consumed | remaining | remaining cost |\n")
1296 sb.WriteString("|---|---|---|---|---|---|\n")
1297 shown := 0
1298 for i, l := range all {
1299 if shown >= RenderLotLimit {
1300 break
1301 }
1302 rc, err := l.RemainingCost()
1303 rcs := strconv.FormatInt(rc, 10)
1304 if err != nil {
1305 // A lot that cannot value itself is shown as such rather than as
1306 // a zero: a zero in a cost column reads as a fact.
1307 rcs = "**invalid**"
1308 }
1309 sb.WriteString("| " + strconv.Itoa(i) + " | " +
1310 strconv.FormatInt(l.Qty, 10) + " | " +
1311 strconv.FormatInt(l.Cost, 10) + " | " +
1312 strconv.FormatInt(l.Consumed, 10) + " | " +
1313 strconv.FormatInt(l.Remaining(), 10) + " | " + rcs + " |\n")
1314 shown++
1315 }
1316 if shown < len(all) {
1317 sb.WriteString("\n_Showing " + strconv.Itoa(shown) + " of " +
1318 strconv.Itoa(len(all)) + " lots._\n")
1319 }
1320 return sb.String()
1321}
1322
1323// cell renders a caller-supplied string into a table cell. A blank value would
1324// collapse the column, so it is shown as a dash.
1325func cell(s string) string {
1326 if strings.TrimSpace(s) == "" {
1327 return "—"
1328 }
1329 return sanitize.TableCell(s)
1330}
1331
1332func yesno(b bool) string {
1333 if b {
1334 return "yes"
1335 }
1336 return "no"
1337}