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

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

periods source pure

Package periods turns a cumulative double-entry book into a period report: per-account movement over a date window, t...

Overview

Package periods turns a cumulative double-entry book into a period report: per-account movement over a date window, the period income statement, and the closing entry that sweeps nominal accounts into equity.

p/journal is cumulative by construction and says so. Its Close is a backdating seal — it refuses entries dated at or before a cutoff and does not sweep anything — so revenue and expense accumulate from the book's first posting forever, and p/statements names its three figures CumulativeRevenue, CumulativeExpenses and CumulativeNet for that reason. Its package doc states the gap in one sentence: "A period income statement needs entry-level data and is out of scope." This package is that entry-level consumer. It reads no chain, holds no state, and writes nothing.

Example
1w := Window{From: q1Start, Until: q2Start}
2rep, err := Derive(chart, entries, w)
3// rep.Activity, rep.PeriodNet, rep.CrossWindowReversals
4post, err := rep.ClosingEntry("3100")   // a PROPOSAL, not a write

Why the chart is a parameter

journal.Posting is {Account string, Side, Amount int64}: it carries the account's CODE and never its Kind. So revenue cannot be told from expense from postings alone, and no income statement is derivable without the chart. That single fact fixes this package's signature. Only Code, Name and Kind are read; Debits and Credits are the book's CUMULATIVE columns and are deliberately neither read nor validated here, because period movement comes from the entries.

The window is closed-open

Window is [From, Until): From is included, Until is not. Consecutive periods therefore TILE — Window.Tiles is the predicate — with no overlap and no gap, which is what makes a sequence of period reports add up to the cumulative figures. A closed-closed window double-counts exactly one timestamp per boundary, and that error is invisible in any single report.

Two inputs, two ordering decisions, opposite answers

This package imposes an ordering precondition on one input and refuses one on the other, and the rule separating them is what journal GUARANTEES:

  • chart must be strictly ascending by Code (ErrChartNotOrdered). journal.Book.IterateAccounts is "ascending code order", so every producer meets it. It buys duplicate-code detection, chart-order output with no sort, and a binary-search code lookup instead of a map.
  • entries must be strictly ascending by ID (ErrEntriesNotOrdered). journal.Book.IterateEntries and journal.Book.Entries both guarantee it. It buys duplicate-ID detection, the binary search that resolves ReversalOf, and ID >= 1 for free, 0 being ReversalOf's sentinel.
  • entries must NOT be required in ascending At order, and this package must never require it. At is caller-supplied and backdating is legal until Close seals it, so journal guarantees nothing about it. Such a precondition would be one no producer in this portfolio can meet — a gate that can never pass, in an API signature rather than in a checker.

The reversal census has three buckets, and the third is the point

A reversal dated inside the window whose original is dated outside it changes what the period means: the window carries a correction to activity it never contained. The census reports three things and never two:

  • [Report.Reversals] — in-window reversal, original also in-window: ordinary, classified.
  • [Report.CrossWindowReversals] — in-window reversal, original dated outside: classified, and material.
  • [Report.UnresolvedReversals] — in-window reversal whose original is ABSENT from the entries slice, so the classification COULD NOT BE MADE.

Collapsing the third into either of the first two would read "could not evaluate" as assent. It is a census, not a verdict — the same posture as statements.Statement.Abnormal — and it is a function of what the caller passed, not of the book: pass a window-filtered slice and every reversal reaching back before it is unresolved by construction.

What this package does not do

  • **It never writes.** Report.ClosingEntry returns postings for the caller to Post. It sweeps PERIOD MOVEMENT, not cumulative balance, so running it twice over one window and posting both results double-sweeps — and this package never sees the Book, so it cannot detect that. The caller owns idempotence.
  • **No paging, no Merge.** MaxEntries is the hard bound. Merging reports across pages would make UnresolvedReversals an artifact of the paging rather than of the data; fixing that needs a two-pass design over the whole entry set and is a different package.
  • **No cash-flow statement.** The indirect method needs a cash-vs-accrual classification journal.Posting does not carry. [Report.Activity] is the balance-sheet delta half it would need.
  • **No audit.** Every figure is derived from what the caller supplied. A chart from one book and entries from another produce a plausible report and nothing here detects it.

p/journal: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/journal p/statements: /p/g1ut6uspuh73e02yauxpmyt8g3wwddaq8utagvm3/statements

Constants 2

const MaxAccounts

1const MaxAccounts = statements.MaxRows
source

MaxAccounts bounds the chart Derive will accept. It is statements.MaxRows — journal's own account cap, reached through the package that already made this decision rather than restated — because a row set larger than the largest chart this ecosystem's bookkeeping primitive can hold did not come from a chart.

const MaxEntries

1const MaxEntries = 4096
source

MaxEntries bounds the entry slice Derive will walk, and it is set far below journal.MaxEntriesLimit on purpose: the journal holds a million entries and a million-entry loop is not a transaction.

What the bound costs, stated rather than waved at. Derive makes one pass over the entries and, per entry, one pass over its postings, each posting costing a binary search over the chart. journal.MaxPostingsPerEntry is 64 and is re-checked here (ErrTooManyPostings) so the product is a PROVEN ceiling and not an assumed one:

Example
14096 entries x 64 postings = 262 144 posting visits, each a
2log2(10 000) ~ 14-step search

which is the same order as the 10 000-row walk statements.Derive documents. A realm wanting period reports caps its own journal at or below this; r/books allows 128 accounts and should be read the same way — this is the ceiling a caller may ask for, not a size worth reaching.

Variables 1

var ErrInvalidWindow, ErrTooManyAccounts, ErrTooManyEntries, ErrChartNotOrdered, ErrEntriesNotOrdered, ErrInvalidKind, ErrInvalidPosting, ErrTooManyPostings, ErrUnknownAccount, ErrNegativeColumns, ErrInvalidEquityCode, ErrEquityCodeIsNominal, ErrNothingToClose, ErrTooManyNominalAccounts, ErrOverflow

 1var (
 2	// ErrInvalidWindow is a window failing [Window.Valid]: From negative, or
 3	// Until not strictly after From. An empty window is refused rather than
 4	// reported as a period with no activity, because the two are different
 5	// claims and only one of them is a report.
 6	ErrInvalidWindow = errors.New("periods: window must satisfy 0 <= From < Until")
 7
 8	// ErrTooManyAccounts is a chart longer than [MaxAccounts].
 9	ErrTooManyAccounts = errors.New("periods: chart length exceeds MaxAccounts")
10
11	// ErrTooManyEntries is an entry slice longer than [MaxEntries].
12	ErrTooManyEntries = errors.New("periods: entry count exceeds MaxEntries")
13
14	// ErrChartNotOrdered is a chart not in strictly ascending Code order.
15	// journal's own enumerator is, so this also catches a duplicated code.
16	ErrChartNotOrdered = errors.New("periods: chart must be in strictly ascending code order")
17
18	// ErrEntriesNotOrdered is an entry slice not in strictly ascending ID
19	// order, which includes an ID of zero. Both of journal's producers are
20	// ordered; see the package doc on why At carries no such requirement.
21	ErrEntriesNotOrdered = errors.New("periods: entries must be in strictly ascending ID order")
22
23	// ErrInvalidKind is an account whose Kind is outside journal's five. The
24	// zero Kind is the case that matters: journal reports it as
25	// credit-normal, so a silent fallthrough would file it rather than refuse
26	// it.
27	//
28	// The two producers are not symmetrical, and saying so is the point.
29	// [Derive] raises it from the CHART PRE-PASS, which proves every Kind
30	// [journal.Kind.Valid] before anything is summed; its later
31	// [statements.SectionOf] branch is therefore UNREACHABLE, because
32	// SectionOf's switch covers exactly the five kinds Valid admits. That
33	// branch ships anyway, as fail-closed defence against the two definitions
34	// drifting apart in a later version of either package — but it is not a
35	// path this package can reach today and must not be cited as one.
36	// [Report.ClosingEntry] is the reachable SectionOf case: it is handed a
37	// Report rather than a chart, runs no pre-pass, and so a hand-built row
38	// with an invalid Kind reaches SectionOf directly.
39	ErrInvalidKind = errors.New("periods: unknown account kind")
40
41	// ErrInvalidPosting is a posting with an invalid Side or a non-positive
42	// Amount. journal cannot produce one; a hand-built entry can.
43	ErrInvalidPosting = errors.New("periods: posting needs a valid side and a positive amount")
44
45	// ErrTooManyPostings is an entry with more than
46	// [journal.MaxPostingsPerEntry] postings. journal cannot produce one, and
47	// re-checking is what makes [MaxEntries]'s cost bound a proven ceiling.
48	ErrTooManyPostings = errors.New("periods: entry exceeds journal.MaxPostingsPerEntry")
49
50	// ErrUnknownAccount is a posting naming a code absent from the chart.
51	// Dropping it silently would UNDERSTATE the period, which is worse than
52	// refusing to report it.
53	ErrUnknownAccount = errors.New("periods: posting names a code absent from the chart")
54
55	// ErrNegativeColumns is an [Activity] row whose Debits or Credits is
56	// negative. [Derive] cannot produce one — it accumulates positive posting
57	// amounts from zero — so this is only reachable through a hand-built
58	// Report, and it is checked for the reason [statements.ErrNegativeTotals]
59	// is checked over the identical hazard on the identical type.
60	//
61	// [Activity.Movement] delegates to [journal.Account.Balance], which
62	// subtracts one column from the other with no check of its own. A row
63	// carrying Credits near max and a NEGATIVE Debits therefore yields a
64	// WRAPPED movement before any arithmetic here runs, and the wrap survives
65	// every downstream guard: it is nonzero, so the m == 0 skip does not
66	// fire, and it is not minInt64, so magnitude reports success. The result
67	// was a closing posting with an INVERTED SIGN, balanced, both sides
68	// present, within the posting cap, and acceptable to [journal.Book.Post]
69	// — a wrong figure with no error anywhere, which is strictly worse than
70	// [ErrOverflow] because nothing downstream can tell it from a real one.
71	// With both columns non-negative the subtraction lands in
72	// [-maxInt64, maxInt64] and cannot wrap, which is the precondition the
73	// checked arithmetic in [Report.ClosingEntry] is sufficient under.
74	ErrNegativeColumns = errors.New("periods: activity column totals must not be negative")
75
76	// ErrInvalidEquityCode is an empty equity code, or one longer than
77	// [journal.MaxCodeLen].
78	ErrInvalidEquityCode = errors.New("periods: equity code must be non-empty and within journal.MaxCodeLen")
79
80	// ErrEquityCodeIsNominal is an equity code naming one of the nominal
81	// accounts being swept. The resulting entry would balance and mean
82	// nothing. See [Report.ClosingEntry] on what this check cannot cover.
83	ErrEquityCodeIsNominal = errors.New("periods: equity code names a nominal account being closed")
84
85	// ErrNothingToClose is a window with no nominal movement at all. The
86	// alternative is returning an empty posting slice that journal would
87	// refuse with ErrTooFewPostings, which moves the failure one call later
88	// where it is harder to read.
89	ErrNothingToClose = errors.New("periods: no nominal movement in the window")
90
91	// ErrTooManyNominalAccounts is a sweep that would exceed
92	// [journal.MaxPostingsPerEntry]: at most 63 nominal accounts plus the
93	// equity posting.
94	ErrTooManyNominalAccounts = errors.New("periods: closing entry exceeds journal.MaxPostingsPerEntry")
95
96	// ErrOverflow is an int64 overflow in any accumulation.
97	ErrOverflow = errors.New("periods: int64 overflow")
98)
source

Errors returned by this package.

Functions 2

func Derive

1func Derive(chart []journal.Account, entries []journal.Entry, w Window) (Report, error)
source

Derive computes the period report.

It is a pure function of its three arguments: the same inputs always give the same Report, and nothing about the caller, the chain or the time enters it. On any error the returned Report is the zero value, so a caller that ignores the error reads zeros rather than a half-summed period.

Preconditions, each for a reason given in the package doc: w must be valid; chart must be strictly ascending by Code and no longer than MaxAccounts, with every Kind one of journal's five; entries must be strictly ascending by ID and no longer than MaxEntries, with at most journal.MaxPostingsPerEntry postings each.

Entries outside w are skipped, not refused — a caller passing the whole book is the expected case — and their postings are therefore NOT content-checked, because no figure in the report depends on them. ErrInvalidPosting and ErrUnknownAccount are scoped to the window for that reason; the postings-per-entry cap is not, since it is what makes MaxEntries' cost ceiling a bound rather than a hope.

func MustDerive

1func MustDerive(chart []journal.Account, entries []journal.Entry, w Window) Report
source

MustDerive is Derive and panics instead of returning an error. It is for a realm's mutating path, where an error return does not revert the transaction and only a panic does.

Types 4

type Activity

struct
1type Activity struct {
2	Code    string
3	Name    string
4	Kind    journal.Kind
5	Debits  int64
6	Credits int64
7}
source

Activity is one account's movement WITHIN the window. Debits and Credits are period sums, not the book's cumulative columns, and both are non-negative AS PRODUCED BY Derive — postings carry positive amounts and Derive refuses anything else.

A Report assembled by hand is under no such constraint, so Report.ClosingEntry checks it: a negative column there is ErrNegativeColumns. The check is not tidiness. [Movement] delegates to journal.Account.Balance, an unchecked subtraction, so a negative column wraps the movement BEFORE any guard here runs and the wrap passes all of them — the earlier version of this comment claimed a hand-built Report's only consequence was ErrOverflow, and an audit disproved it by producing a sign-inverted closing entry that journal.Book.Post accepts with no error at all. See ErrNegativeColumns for the mechanism.

Derive emits a row only for a code the window actually touched: an account with no movement is ABSENT, not a zero row. Over a chart of 10 000 with three active accounts that is three rows, and the alternative is paying statements' full MaxRows cost to report nothing.

Methods on Activity

func Movement

method on Activity
1func (a Activity) Movement() int64
source

Movement returns the period movement in the account's NORMAL direction, so a revenue account that earned reads positive and a contra movement reads negative.

It is journal's own definition, reached by handing journal the two columns rather than re-deriving the normal-balance rule here. One definition of which kinds are debit-normal, in the package that owns it.

Inheriting that definition means inheriting its arithmetic: Balance subtracts without a check, so on a row with a NEGATIVE column — which only a hand-built Report can hold — this wraps and returns a plausible wrong number. It is left unchecked rather than given an error return, because a two-value Movement would have to be handled at four call sites to defend one input class that Report.ClosingEntry refuses outright. The guard is at the boundary; see ErrNegativeColumns.

func RawDelta

method on Activity
1func (a Activity) RawDelta() int64
source

RawDelta returns Debits-Credits regardless of kind. Summed over every row of a report derived from balanced entries it is exactly zero, which is what makes it useful for a consistency check and wrong for a report.

type CrossReversal

struct
1type CrossReversal struct {
2	ID         uint64
3	ReversalOf uint64
4	Total      int64
5}
source

CrossReversal is an in-window reversal whose original entry is dated outside the window: the period carries a correction to activity it never contained.

Total is the reversal's debit total (journal.Entry.Total), which is its magnitude on both sides because journal only accepts balanced entries.

type Report

struct
 1type Report struct {
 2	// Window is the window the figures are for, echoed back so a stored
 3	// report cannot be read without it.
 4	Window Window
 5
 6	// Activity is per-account period movement, in CHART order — which the
 7	// chart precondition makes ascending by Code — for codes the window
 8	// touched.
 9	Activity []Activity
10
11	// Entries is how many entries fell in the window. Zero is a legitimate
12	// report of a quiet period, not an error.
13	Entries int
14
15	// FirstID and LastID are the lowest and highest IDs IN THE WINDOW, or
16	// both zero when Entries is zero. They are min and max over the
17	// in-window SUBSET and not the ends of the supplied slice: over entries
18	// 1, 2, 3 with only 2 in the window, both read 2.
19	FirstID uint64
20	LastID  uint64
21
22	// PeriodRevenue and PeriodExpenses are the natural-sign period totals of
23	// the two nominal kinds. The names carry the word PERIOD for the same
24	// reason statements' carry CUMULATIVE: a reader who takes one for the
25	// other misreads every figure.
26	PeriodRevenue  int64
27	PeriodExpenses int64
28
29	// PeriodNet is PeriodRevenue - PeriodExpenses: the period income
30	// statement's bottom line, which no cumulative report can produce.
31	PeriodNet int64
32
33	// Reversals counts in-window reversals whose original is also in-window.
34	Reversals int
35
36	// CrossWindowReversals lists in-window reversals whose original is dated
37	// outside the window, in the order the entries were walked.
38	CrossWindowReversals []CrossReversal
39
40	// UnresolvedReversals lists the IDs of in-window reversals whose original
41	// is absent from the supplied entries, so they could be classified
42	// neither way. See the package doc: this is the bucket that must not be
43	// collapsed.
44	UnresolvedReversals []uint64
45}
source

Report is one window's activity. Every slice is freshly allocated by Derive on every call and is never nil, so the caller owns it and an empty result is an empty slice rather than a nil to be distinguished from one.

Methods on Report

func ActivityIn

method on Report
1func (r Report) ActivityIn(sec statements.Section) int
source

ActivityIn counts the rows presented in sec.

The section is recomputed from each row's Kind through statements.SectionOf rather than stored, so there is one definition of the three-bucket partition and no second field that could disagree with it. A Report built by hand can hold a row with an invalid Kind; such a row is counted in no section, and Derive cannot produce one.

It deliberately does NOT check ErrNegativeColumns, unlike Report.ClosingEntry, and has no error return to do it with. The reason is that it reads no column: the count depends on Kind alone, so a negative column cannot make this answer wrong. A guard added here for symmetry would be a check that can never change an outcome — the vacuous-true shape this portfolio refuses elsewhere — and the honest version is this sentence.

func ClosingEntry

method on Report
1func (r Report) ClosingEntry(equityCode string) ([]journal.Posting, error)
source

ClosingEntry returns the postings that zero every nominal account's PERIOD MOVEMENT and carry the net to equityCode — the entry journal.Book.Close deliberately does not make, Close being a backdating seal and nothing else.

Nominal means statements.SectionPerformance: the partition is read through statements.SectionOf rather than restated as a second list of kinds.

The result is a PROPOSAL. This package holds no Book and posts nothing, and three consequences follow that a caller must own:

  • It sweeps period movement, not cumulative balance. Posting it twice for one window double-sweeps the period, and nothing here can detect that.
  • A window the book has already sealed is still reportable — Close refuses writes, not reads — so these postings may come back journal.ErrPeriodClosed. That is journal's call; this package cannot see the seal and does not pre-empt it.
  • equityCode is checked for being empty, over-long, or one of the nominal codes being swept (ErrEquityCodeIsNominal). It CANNOT be checked for naming an Equity-kind account, because Report does not retain the chart: a code naming a known liability yields an entry journal will accept and that files net income in the wrong place. journal rejects an unknown code on its own; the wrong-kind case is the caller's.

The postings always balance and always carry both a debit and a credit when there are any at all, which is why a non-error return is always postable on those grounds. Net income is credited to equity and a net loss is debited, equity being credit-normal. A window with no nominal movement is ErrNothingToClose rather than an empty slice.

That postability claim rests on a precondition Derive supplies for free and a hand-built Report does not, so this function establishes it itself: every row's columns must be non-negative, or ErrNegativeColumns. Without the check the claim is false in the worst available way — not an error, but a balanced, postable, SIGN-INVERTED entry. See ErrNegativeColumns.

type Window

struct
1type Window struct {
2	From  int64
3	Until int64
4}
source

Window is a half-open date range [From, Until): From is included, Until is not. The unit is whatever the book's At values are — journal treats At as an opaque non-negative int64, so a caller holding seconds passes seconds and a caller holding block heights passes heights. This package imposes no calendar, which is why it needs none.

Methods on Window

func Contains

method on Window
1func (w Window) Contains(at int64) bool
source

Contains reports whether at falls in w. Half-open: From is in, Until is out.

func Tiles

method on Window
1func (w Window) Tiles(next Window) bool
source

Tiles reports whether next begins exactly where w ends, so that the two cover [w.From, next.Until) with no overlap and no gap.

It exists as API rather than as a comment because the tiling property is what makes a sequence of period reports sum to the cumulative figures, and a caller generating a sequence should be able to assert it.

func Valid

method on Window
1func (w Window) Valid() bool
source

Valid reports whether w is a usable window: From non-negative and Until strictly after it. An empty or inverted window is not a period.

Dependencies

Source Files 2