journal_test.gno
43.71 Kb · 1389 lines
1package journal
2
3import (
4 "testing"
5)
6
7const (
8 cash = "1000"
9 ar = "1100"
10 payable = "2000"
11 capital = "3000"
12 revenue = "4000"
13 rent = "5000"
14)
15
16// newBook is the default fixture: a six-line chart covering all five kinds,
17// a generous entry cap.
18func newBook(t *testing.T) *Book {
19 t.Helper()
20 b, err := New(32, 1000)
21 if err != nil {
22 t.Fatalf("New: %v", err)
23 }
24 b.MustOpen(cash, "Cash", Asset)
25 b.MustOpen(ar, "Accounts receivable", Asset)
26 b.MustOpen(payable, "Accounts payable", Liability)
27 b.MustOpen(capital, "Owner capital", Equity)
28 b.MustOpen(revenue, "Sales", Revenue)
29 b.MustOpen(rent, "Rent expense", Expense)
30 return b
31}
32
33// mustPanicWith asserts fn panics with exactly want. A /p/ function called
34// from a /p/ test crosses no realm boundary, so the panic is LOCAL and
35// recover() is the right family -- Aborts* would not see it at all.
36func mustPanicWith(t *testing.T, want string, fn func()) {
37 t.Helper()
38 defer func() {
39 r := recover()
40 if r == nil {
41 t.Fatalf("expected panic %q, got none", want)
42 }
43 err, ok := r.(error)
44 if !ok || err.Error() != want {
45 t.Fatalf("panic = %v, want %q", r, want)
46 }
47 }()
48 fn()
49}
50
51// pair is the simplest balanced entry: debit one account, credit another.
52func pair(dr, cr string, amount int64) []Posting {
53 return []Posting{
54 {Account: dr, Side: Debit, Amount: amount},
55 {Account: cr, Side: Credit, Amount: amount},
56 }
57}
58
59// --- construction ---------------------------------------------------------
60
61func TestNewRejectsBadConfig(t *testing.T) {
62 cases := []struct {
63 name string
64 maxAccounts, maxEntries int
65 }{
66 {"zero accounts", 0, 10},
67 {"negative accounts", -1, 10},
68 {"accounts over limit", MaxAccountsLimit + 1, 10},
69 {"zero entries", 10, 0},
70 {"negative entries", 10, -1},
71 {"entries over limit", 10, MaxEntriesLimit + 1},
72 }
73 for _, tc := range cases {
74 t.Run(tc.name, func(t *testing.T) {
75 if _, err := New(tc.maxAccounts, tc.maxEntries); err != ErrInvalidConfig {
76 t.Fatalf("New = %v, want ErrInvalidConfig", err)
77 }
78 })
79 }
80}
81
82func TestNewAtLimitsIsAccepted(t *testing.T) {
83 // The positive control for the bounds above: a sabotage that rejected
84 // everything would pass those cases and fail this one.
85 if _, err := New(1, 1); err != nil {
86 t.Fatalf("New(1,1) = %v, want nil", err)
87 }
88 if _, err := New(MaxAccountsLimit, MaxEntriesLimit); err != nil {
89 t.Fatalf("New at limits = %v, want nil", err)
90 }
91}
92
93func TestNewBookIsEmptyAndBalanced(t *testing.T) {
94 b := MustNew(4, 4)
95 if b.AccountCount() != 0 || b.EntryCount() != 0 {
96 t.Fatalf("counts = %d/%d, want 0/0", b.AccountCount(), b.EntryCount())
97 }
98 if b.NextID() != 1 {
99 t.Fatalf("NextID = %d, want 1", b.NextID())
100 }
101 if !b.IsBalanced() {
102 t.Fatal("an empty book must be balanced")
103 }
104 if _, _, agrees := b.RederiveFromJournal(); !agrees {
105 t.Fatal("an empty book must rederive")
106 }
107 if _, closed := b.ClosedThrough(); closed {
108 t.Fatal("a new book must not report a closed period")
109 }
110}
111
112func TestMustNewPanics(t *testing.T) {
113 mustPanicWith(t, "journal: invalid book configuration", func() { MustNew(0, 1) })
114}
115
116// --- kinds and sides ------------------------------------------------------
117
118func TestKindNormalBalance(t *testing.T) {
119 cases := []struct {
120 k Kind
121 valid bool
122 debitNormal bool
123 name string
124 }{
125 {Asset, true, true, "asset"},
126 {Expense, true, true, "expense"},
127 {Liability, true, false, "liability"},
128 {Equity, true, false, "equity"},
129 {Revenue, true, false, "revenue"},
130 {Kind(0), false, false, "invalid"},
131 {Kind(6), false, false, "invalid"},
132 {Kind(255), false, false, "invalid"},
133 }
134 for _, tc := range cases {
135 if got := tc.k.Valid(); got != tc.valid {
136 t.Fatalf("Kind(%d).Valid = %v, want %v", uint8(tc.k), got, tc.valid)
137 }
138 if got := tc.k.IsDebitNormal(); got != tc.debitNormal {
139 t.Fatalf("Kind(%d).IsDebitNormal = %v, want %v", uint8(tc.k), got, tc.debitNormal)
140 }
141 if got := tc.k.String(); got != tc.name {
142 t.Fatalf("Kind(%d).String = %q, want %q", uint8(tc.k), got, tc.name)
143 }
144 }
145}
146
147func TestSideValidityAndOpposite(t *testing.T) {
148 if !Debit.Valid() || !Credit.Valid() {
149 t.Fatal("Debit and Credit must be valid")
150 }
151 if Side(0).Valid() || Side(3).Valid() {
152 t.Fatal("only Debit and Credit are valid sides")
153 }
154 if Debit.Opposite() != Credit || Credit.Opposite() != Debit {
155 t.Fatal("Opposite must swap the two sides")
156 }
157 // An invalid side returns itself rather than a plausible default, so a
158 // caller that skipped validation gets an invalid posting.
159 if Side(0).Opposite() != Side(0) {
160 t.Fatal("Opposite of an invalid side must stay invalid")
161 }
162 if Debit.String() != "debit" || Credit.String() != "credit" || Side(9).String() != "invalid" {
163 t.Fatal("Side.String")
164 }
165}
166
167// --- opening accounts -----------------------------------------------------
168
169func TestOpenValidation(t *testing.T) {
170 long := ""
171 for i := 0; i < MaxCodeLen+1; i++ {
172 long += "x"
173 }
174 longName := ""
175 for i := 0; i < MaxNameLen+1; i++ {
176 longName += "y"
177 }
178 cases := []struct {
179 name string
180 code string
181 acct string
182 kind Kind
183 want error
184 }{
185 {"empty code", "", "Cash", Asset, ErrEmptyCode},
186 {"code too long", long, "Cash", Asset, ErrCodeTooLong},
187 {"name too long", "1", longName, Asset, ErrNameTooLong},
188 {"zero kind", "1", "Cash", Kind(0), ErrInvalidKind},
189 {"unknown kind", "1", "Cash", Kind(6), ErrInvalidKind},
190 }
191 for _, tc := range cases {
192 t.Run(tc.name, func(t *testing.T) {
193 b := MustNew(4, 4)
194 if err := b.Open(tc.code, tc.acct, tc.kind); err != tc.want {
195 t.Fatalf("Open = %v, want %v", err, tc.want)
196 }
197 if b.AccountCount() != 0 {
198 t.Fatal("a refused Open must leave the chart unchanged")
199 }
200 })
201 }
202}
203
204func TestOpenAtExactLengthLimitsIsAccepted(t *testing.T) {
205 // Positive control for the two length guards: off-by-one in the wrong
206 // direction would reject these.
207 code := ""
208 for i := 0; i < MaxCodeLen; i++ {
209 code += "x"
210 }
211 name := ""
212 for i := 0; i < MaxNameLen; i++ {
213 name += "y"
214 }
215 b := MustNew(4, 4)
216 if err := b.Open(code, name, Asset); err != nil {
217 t.Fatalf("Open at exact limits = %v, want nil", err)
218 }
219}
220
221func TestOpenRejectsDuplicateAndRespectsCap(t *testing.T) {
222 b := MustNew(2, 4)
223 b.MustOpen("1", "One", Asset)
224 if err := b.Open("1", "Different name", Revenue); err != ErrAccountExists {
225 t.Fatalf("duplicate Open = %v, want ErrAccountExists", err)
226 }
227 // The duplicate must not have overwritten the original.
228 a, _ := b.Account("1")
229 if a.Name != "One" || a.Kind != Asset {
230 t.Fatalf("account mutated by refused Open: %+v", a)
231 }
232 b.MustOpen("2", "Two", Liability)
233 if err := b.Open("3", "Three", Asset); err != ErrChartFull {
234 t.Fatalf("Open past cap = %v, want ErrChartFull", err)
235 }
236 if b.AccountCount() != 2 {
237 t.Fatalf("AccountCount = %d, want 2", b.AccountCount())
238 }
239}
240
241func TestMustOpenPanics(t *testing.T) {
242 b := MustNew(4, 4)
243 mustPanicWith(t, "journal: empty account code", func() { b.MustOpen("", "x", Asset) })
244}
245
246func TestAccountAndBalanceDistinguishMissingFromZero(t *testing.T) {
247 b := newBook(t)
248 if _, ok := b.Account("nope"); ok {
249 t.Fatal("Account must report a missing code")
250 }
251 // The distinction that matters: a missing account is not a zero balance.
252 if v, ok := b.Balance("nope"); ok || v != 0 {
253 t.Fatalf("Balance(missing) = %d,%v want 0,false", v, ok)
254 }
255 if v, ok := b.Balance(cash); !ok || v != 0 {
256 t.Fatalf("Balance(open, unposted) = %d,%v want 0,true", v, ok)
257 }
258}
259
260// --- posting --------------------------------------------------------------
261
262func TestPostBalancedEntryUpdatesBothSides(t *testing.T) {
263 b := newBook(t)
264 id, err := b.Post(100, "owner funds the business", pair(cash, capital, 5000))
265 if err != nil {
266 t.Fatalf("Post: %v", err)
267 }
268 if id != 1 || b.NextID() != 2 {
269 t.Fatalf("id=%d nextID=%d, want 1 and 2", id, b.NextID())
270 }
271 if got, _ := b.Balance(cash); got != 5000 {
272 t.Fatalf("cash balance = %d, want 5000", got)
273 }
274 // Equity is credit-normal, so a credit is a POSITIVE balance. A
275 // debit-normal reading would report -5000 here.
276 if got, _ := b.Balance(capital); got != 5000 {
277 t.Fatalf("capital balance = %d, want +5000 (credit-normal)", got)
278 }
279 a, _ := b.Account(cash)
280 if a.Debits != 5000 || a.Credits != 0 || a.RawDelta() != 5000 {
281 t.Fatalf("cash account = %+v", a)
282 }
283 e, _ := b.Account(capital)
284 if e.Debits != 0 || e.Credits != 5000 || e.RawDelta() != -5000 {
285 t.Fatalf("capital account = %+v", e)
286 }
287 if b.TotalDebits() != 5000 || b.TotalCredits() != 5000 {
288 t.Fatalf("totals = %d/%d, want 5000/5000", b.TotalDebits(), b.TotalCredits())
289 }
290 if !b.IsBalanced() {
291 t.Fatal("book must be balanced after a balanced entry")
292 }
293}
294
295func TestPostRefusesUnbalancedAndNamesTheShortSide(t *testing.T) {
296 b := newBook(t)
297 // Debits short: 100 debit against 150 credit.
298 short := []Posting{
299 {Account: cash, Side: Debit, Amount: 100},
300 {Account: revenue, Side: Credit, Amount: 150},
301 }
302 if _, err := b.Post(1, "", short); err != ErrDebitShort {
303 t.Fatalf("Post = %v, want ErrDebitShort", err)
304 }
305 // Credits short: the mirror case must report the OTHER error, or the
306 // refusal is not actually naming the short side.
307 other := []Posting{
308 {Account: cash, Side: Debit, Amount: 150},
309 {Account: revenue, Side: Credit, Amount: 100},
310 }
311 if _, err := b.Post(1, "", other); err != ErrCreditShort {
312 t.Fatalf("Post = %v, want ErrCreditShort", err)
313 }
314 if b.EntryCount() != 0 || b.TotalDebits() != 0 {
315 t.Fatal("a refused Post must leave the book completely unchanged")
316 }
317 if got, _ := b.Balance(cash); got != 0 {
318 t.Fatalf("cash moved on a refused Post: %d", got)
319 }
320}
321
322func TestPostValidation(t *testing.T) {
323 long := ""
324 for i := 0; i < MaxMemoLen+1; i++ {
325 long += "m"
326 }
327 many := []Posting{}
328 for i := 0; i < MaxPostingsPerEntry+1; i++ {
329 side := Debit
330 if i%2 == 1 {
331 side = Credit
332 }
333 many = append(many, Posting{Account: cash, Side: side, Amount: 1})
334 }
335 cases := []struct {
336 name string
337 at int64
338 memo string
339 postings []Posting
340 want error
341 }{
342 {"negative at", -1, "", pair(cash, revenue, 1), ErrInvalidAt},
343 {"memo too long", 1, long, pair(cash, revenue, 1), ErrMemoTooLong},
344 {"no postings", 1, "", []Posting{}, ErrTooFewPostings},
345 {"one posting", 1, "", []Posting{{Account: cash, Side: Debit, Amount: 1}}, ErrTooFewPostings},
346 {"too many postings", 1, "", many, ErrTooManyPostings},
347 {"zero side", 1, "", []Posting{
348 {Account: cash, Side: Side(0), Amount: 1},
349 {Account: revenue, Side: Credit, Amount: 1},
350 }, ErrInvalidSide},
351 {"zero amount", 1, "", []Posting{
352 {Account: cash, Side: Debit, Amount: 0},
353 {Account: revenue, Side: Credit, Amount: 0},
354 }, ErrInvalidAmount},
355 {"negative amount", 1, "", []Posting{
356 {Account: cash, Side: Debit, Amount: -5},
357 {Account: revenue, Side: Credit, Amount: -5},
358 }, ErrInvalidAmount},
359 {"unknown account", 1, "", pair(cash, "9999", 1), ErrUnknownAccount},
360 {"all debits", 1, "", []Posting{
361 {Account: cash, Side: Debit, Amount: 1},
362 {Account: ar, Side: Debit, Amount: 1},
363 }, ErrNoCredit},
364 {"all credits", 1, "", []Posting{
365 {Account: revenue, Side: Credit, Amount: 1},
366 {Account: payable, Side: Credit, Amount: 1},
367 }, ErrNoDebit},
368 }
369 for _, tc := range cases {
370 t.Run(tc.name, func(t *testing.T) {
371 b := newBook(t)
372 if _, err := b.Post(tc.at, tc.memo, tc.postings); err != tc.want {
373 t.Fatalf("Post = %v, want %v", err, tc.want)
374 }
375 if b.EntryCount() != 0 {
376 t.Fatal("a refused Post must write nothing")
377 }
378 if !b.IsBalanced() {
379 t.Fatal("a refused Post must leave the book balanced")
380 }
381 })
382 }
383}
384
385func TestPostAtExactPostingLimitIsAccepted(t *testing.T) {
386 // Positive control for ErrTooManyPostings.
387 b := newBook(t)
388 ps := []Posting{}
389 for i := 0; i < MaxPostingsPerEntry; i++ {
390 side := Debit
391 if i%2 == 1 {
392 side = Credit
393 }
394 ps = append(ps, Posting{Account: cash, Side: side, Amount: 1})
395 }
396 if _, err := b.Post(1, "", ps); err != nil {
397 t.Fatalf("Post at exactly MaxPostingsPerEntry = %v, want nil", err)
398 }
399}
400
401func TestPostAtZeroIsAcceptedOnAnUnclosedBook(t *testing.T) {
402 // at == 0 is a legitimate instant. It is only refused once a period has
403 // actually been closed through it -- which is why hasClosed exists.
404 b := newBook(t)
405 if _, err := b.Post(0, "genesis", pair(cash, capital, 1)); err != nil {
406 t.Fatalf("Post(at=0) = %v, want nil", err)
407 }
408}
409
410func TestPostMultiLegEntry(t *testing.T) {
411 b := newBook(t)
412 // One debit split across two credits.
413 id := b.MustPost(10, "part cash, part credit", []Posting{
414 {Account: cash, Side: Debit, Amount: 300},
415 {Account: ar, Side: Debit, Amount: 700},
416 {Account: revenue, Side: Credit, Amount: 1000},
417 })
418 e, ok := b.Entry(id)
419 if !ok {
420 t.Fatal("entry missing")
421 }
422 if len(e.Postings) != 3 {
423 t.Fatalf("postings = %d, want 3", len(e.Postings))
424 }
425 if e.Total() != 1000 {
426 t.Fatalf("Total = %d, want 1000", e.Total())
427 }
428 if got, _ := b.Balance(revenue); got != 1000 {
429 t.Fatalf("revenue = %d, want 1000", got)
430 }
431 if !b.IsBalanced() {
432 t.Fatal("multi-leg entry must balance")
433 }
434}
435
436func TestPostSameAccountTwiceInOneEntryAccumulatesBothLegs(t *testing.T) {
437 // The staging loop folds earlier stagings of the same account. Without
438 // that fold the second leg would overwrite the first and the account
439 // would silently lose a posting while the book-level totals kept both --
440 // which IsBalanced is built to catch, so assert both.
441 b := newBook(t)
442 b.MustPost(1, "net settlement through one account", []Posting{
443 {Account: cash, Side: Debit, Amount: 500},
444 {Account: cash, Side: Credit, Amount: 200},
445 {Account: revenue, Side: Credit, Amount: 400},
446 {Account: rent, Side: Debit, Amount: 100},
447 })
448 a, _ := b.Account(cash)
449 if a.Debits != 500 || a.Credits != 200 {
450 t.Fatalf("cash = %+v, want debits 500 credits 200", a)
451 }
452 if a.Balance() != 300 {
453 t.Fatalf("cash balance = %d, want 300", a.Balance())
454 }
455 if !b.IsBalanced() {
456 t.Fatal("per-account totals drifted from the running totals")
457 }
458 if _, _, agrees := b.RederiveFromJournal(); !agrees {
459 t.Fatal("journal replay disagrees with the accumulators")
460 }
461}
462
463func TestPostThreeLegsToOneAccountFoldsAllOfThem(t *testing.T) {
464 // THREE legs, not two, and that is the entire point of the case.
465 //
466 // The staging fold scans every earlier staging and reassigns without
467 // breaking, so the LAST match wins -- which is correct, because each
468 // staging already carries the folds up to it. A `break` after the
469 // first match would be the bug, and with only TWO legs the first
470 // match IS the last, so the two-leg case above passes either way.
471 // It takes a third leg to tell the implementation from the sabotage,
472 // and neither IsBalanced nor RederiveFromJournal would notice: the
473 // dropped middle leg would be absent from the account AND from the
474 // stored entry the replay reads.
475 b := newBook(t)
476 b.MustPost(1, "three legs through one account", []Posting{
477 {Account: cash, Side: Debit, Amount: 500},
478 {Account: cash, Side: Credit, Amount: 200},
479 {Account: cash, Side: Debit, Amount: 100},
480 {Account: revenue, Side: Credit, Amount: 400},
481 })
482 a, _ := b.Account(cash)
483 if a.Debits != 600 || a.Credits != 200 {
484 t.Fatalf("cash = %+v, want debits 600 credits 200 (all three legs)", a)
485 }
486 if a.Balance() != 400 {
487 t.Fatalf("cash balance = %d, want 400", a.Balance())
488 }
489 if b.TotalDebits() != 600 || b.TotalCredits() != 600 {
490 t.Fatalf("book totals = %d/%d, want 600/600",
491 b.TotalDebits(), b.TotalCredits())
492 }
493 if !b.IsBalanced() {
494 t.Fatal("per-account totals drifted from the running totals")
495 }
496 if _, _, agrees := b.RederiveFromJournal(); !agrees {
497 t.Fatal("journal replay disagrees with the accumulators")
498 }
499 // And the stored entry kept all four postings, not a deduplicated three.
500 e, ok := b.Entry(1)
501 if !ok {
502 t.Fatal("entry 1 missing")
503 }
504 if len(e.Postings) != 4 {
505 t.Fatalf("stored postings = %d, want 4", len(e.Postings))
506 }
507}
508
509func TestAccountingEquationIsBlindToAKindMisclassification(t *testing.T) {
510 // The sabotage that proves the NEGATIVE claim in the docs, because an
511 // unenforced doc claim is the defect this package keeps being audited
512 // for. Obligation 7 and AccountingEquation's own doc both say a wrong
513 // Kind is undetectable by every check here. Assert it rather than
514 // asking a reader to believe it.
515 //
516 // If this test ever fails, some check learned to validate a Kind --
517 // update the docs, do not relax the test.
518 b := newBook(t)
519 b.MustPost(1, "", pair(cash, revenue, 100))
520
521 dn, cn, offsets := b.AccountingEquation()
522 if !offsets {
523 t.Fatal("precondition: a clean book must offset")
524 }
525 if dn != 100 || cn != -100 {
526 t.Fatalf("subtotals = %d/%d, want 100/-100", dn, cn)
527 }
528
529 // Reclassify Cash from Asset to Liability: a credit-normal bucket for
530 // an account holding a debit balance. Nothing about Debits or Credits
531 // changes, so nothing built on them can see it.
532 a, _ := b.Account(cash)
533 a.Kind = Liability
534 b.accounts.Set(a.Code, a)
535
536 dn2, cn2, offsets2 := b.AccountingEquation()
537 if !offsets2 {
538 t.Fatal("offsets must STAY true -- it is invariant under the partition")
539 }
540 // Both subtotals moved, which is how we know the misclassification
541 // really took effect and the invariance is not a vacuous pass.
542 if dn2 != 0 || cn2 != 0 {
543 t.Fatalf("subtotals = %d/%d, want 0/0 after reclassification", dn2, cn2)
544 }
545 if dn2 == dn && cn2 == cn {
546 t.Fatal("subtotals did not move; the sabotage did not take")
547 }
548 // The other two tiers are equally blind.
549 if !b.IsBalanced() {
550 t.Fatal("IsBalanced must stay true: Kind is not one of its inputs")
551 }
552 if _, _, agrees := b.RederiveFromJournal(); !agrees {
553 t.Fatal("RederiveFromJournal must stay true for the same reason")
554 }
555 // And the visible consequence: a sign-flipped balance, reported
556 // confidently by the only accessor a consumer is likely to quote.
557 a2, _ := b.Account(cash)
558 if a2.Balance() != -100 {
559 t.Fatalf("balance = %d, want -100 (the sign flip, silently)",
560 a2.Balance())
561 }
562 if a2.RawDelta() != 100 {
563 t.Fatalf("RawDelta = %d, want 100 -- unchanged, which is the point",
564 a2.RawDelta())
565 }
566}
567
568func TestPostRespectsEntryCap(t *testing.T) {
569 b, err := New(8, 2)
570 if err != nil {
571 t.Fatalf("New: %v", err)
572 }
573 b.MustOpen(cash, "Cash", Asset)
574 b.MustOpen(revenue, "Sales", Revenue)
575 b.MustPost(1, "", pair(cash, revenue, 1))
576 b.MustPost(2, "", pair(cash, revenue, 1))
577 if _, err := b.Post(3, "", pair(cash, revenue, 1)); err != ErrBookFull {
578 t.Fatalf("Post past cap = %v, want ErrBookFull", err)
579 }
580 if b.EntryCount() != 2 {
581 t.Fatalf("EntryCount = %d, want 2", b.EntryCount())
582 }
583 // A refused Post must not consume an ID either.
584 if b.NextID() != 3 {
585 t.Fatalf("NextID = %d, want 3", b.NextID())
586 }
587}
588
589func TestMustPostPanics(t *testing.T) {
590 b := newBook(t)
591 mustPanicWith(t, "journal: entry does not balance, debits are short", func() {
592 b.MustPost(1, "", []Posting{
593 {Account: cash, Side: Debit, Amount: 1},
594 {Account: revenue, Side: Credit, Amount: 2},
595 })
596 })
597}
598
599// --- overflow -------------------------------------------------------------
600
601func TestPostOverflowLeavesBookUnchanged(t *testing.T) {
602 const maxInt64 = int64(9223372036854775807)
603 b := newBook(t)
604 b.MustPost(1, "", pair(cash, revenue, maxInt64))
605 // A second entry of any positive size overflows the running totals.
606 if _, err := b.Post(2, "", pair(cash, revenue, 1)); err != ErrOverflow {
607 t.Fatalf("Post = %v, want ErrOverflow", err)
608 }
609 if b.EntryCount() != 1 || b.TotalDebits() != maxInt64 {
610 t.Fatal("an overflowing Post must write nothing")
611 }
612 if !b.IsBalanced() {
613 t.Fatal("book must remain balanced after a refused overflow")
614 }
615}
616
617func TestPostOverflowWithinOneEntry(t *testing.T) {
618 const maxInt64 = int64(9223372036854775807)
619 b := newBook(t)
620 if _, err := b.Post(1, "", []Posting{
621 {Account: cash, Side: Debit, Amount: maxInt64},
622 {Account: ar, Side: Debit, Amount: 1},
623 {Account: revenue, Side: Credit, Amount: maxInt64},
624 {Account: payable, Side: Credit, Amount: 1},
625 }); err != ErrOverflow {
626 t.Fatalf("Post = %v, want ErrOverflow", err)
627 }
628 if b.EntryCount() != 0 {
629 t.Fatal("must write nothing")
630 }
631}
632
633func TestPostRefusesWhenIDsAreExhausted(t *testing.T) {
634 // nextID starts at 1 and only ever increases, so the wrap is 2^64
635 // entries away and unreachable through the API. The guard is still
636 // load-bearing -- a reused id makes one id name two entries -- so it
637 // is proven by reaching past the API to the single state that fires
638 // it, which is the move TestIsBalancedCatchesADriftedAccumulator makes
639 // and for the same reason.
640 b := newBook(t)
641 b.nextID = 0
642 if _, err := b.Post(1, "", pair(cash, revenue, 100)); err != ErrIDExhausted {
643 t.Fatalf("Post = %v, want ErrIDExhausted", err)
644 }
645 if b.EntryCount() != 0 {
646 t.Fatal("a refused Post must write nothing")
647 }
648}
649
650func TestPostCopiesTheCallerSlice(t *testing.T) {
651 // The write-side twin of TestEntryPostingsAreADefensiveCopy, which
652 // mutates the slice Entry RETURNS. This one mutates the slice the
653 // CALLER passed in: if post stored that slice by reference, the caller
654 // keeps a writable window into a sealed journal entry, and no
655 // read-side copy can take it back.
656 b := newBook(t)
657 legs := pair(cash, revenue, 100)
658 id := b.MustPost(1, "", legs)
659
660 legs[0].Amount = 999
661 legs[0].Account = "hacked"
662 legs[1].Side = Debit
663
664 e, found := b.Entry(id)
665 if !found {
666 t.Fatal("the entry vanished")
667 }
668 if e.Postings[0].Amount != 100 || e.Postings[0].Account != cash {
669 t.Fatalf("post stored the caller's slice: %+v", e.Postings[0])
670 }
671 if e.Postings[1].Side != Credit {
672 t.Fatal("post stored the caller's slice: the side was mutated through it")
673 }
674 if _, _, agrees := b.RederiveFromJournal(); !agrees {
675 t.Fatal("the journal must still agree with both accumulators")
676 }
677}
678
679func TestPostChecksBothRunningTotalsSeparately(t *testing.T) {
680 // A posted entry advances both running totals by the same amount, so
681 // through the API either check alone would catch every overflow the
682 // other one would -- which makes neither of them provable. Drifting
683 // one accumulator first is what makes the debit check and the credit
684 // check distinguishable at all.
685 const maxInt64 = int64(9223372036854775807)
686
687 b := newBook(t)
688 b.totalDebits = maxInt64
689 if _, err := b.Post(1, "", pair(cash, revenue, 1)); err != ErrOverflow {
690 t.Fatalf("debit side: Post = %v, want ErrOverflow", err)
691 }
692 if b.EntryCount() != 0 {
693 t.Fatal("debit side: a refused Post must write nothing")
694 }
695
696 b = newBook(t)
697 b.totalCredits = maxInt64
698 if _, err := b.Post(1, "", pair(cash, revenue, 1)); err != ErrOverflow {
699 t.Fatalf("credit side: Post = %v, want ErrOverflow", err)
700 }
701 if b.EntryCount() != 0 {
702 t.Fatal("credit side: a refused Post must write nothing")
703 }
704}
705
706func TestPostRefusesAPerAccountOverflow(t *testing.T) {
707 // The book-level totals are checked before the chart is touched, and
708 // here they are at zero and pass -- so this reaches the per-account
709 // staging check and nothing else does. Without it a chart line wraps
710 // negative while the book still reads balanced at the top level.
711 const maxInt64 = int64(9223372036854775807)
712 b := newBook(t)
713 a, _ := b.Account(cash)
714 a.Debits = maxInt64
715 b.accounts.Set(a.Code, a)
716
717 if b.TotalDebits() != 0 {
718 t.Fatal("precondition: the book-level check must not be the one that fires")
719 }
720 if _, err := b.Post(1, "", pair(cash, revenue, 1)); err != ErrOverflow {
721 t.Fatalf("Post = %v, want ErrOverflow", err)
722 }
723 if b.EntryCount() != 0 {
724 t.Fatal("a refused Post must write nothing")
725 }
726 again, _ := b.Account(cash)
727 if again.Debits != maxInt64 {
728 t.Fatalf("cash.Debits = %d, want the whole staging list discarded", again.Debits)
729 }
730}
731
732func TestCheckedAddBranches(t *testing.T) {
733 const maxInt64 = int64(9223372036854775807)
734 const minInt64 = -maxInt64 - 1
735 if v, ok := checkedAdd(2, 3); !ok || v != 5 {
736 t.Fatalf("checkedAdd(2,3) = %d,%v", v, ok)
737 }
738 if _, ok := checkedAdd(maxInt64, 1); ok {
739 t.Fatal("positive overflow not caught")
740 }
741 if _, ok := checkedAdd(minInt64, -1); ok {
742 t.Fatal("negative overflow not caught")
743 }
744 if v, ok := checkedAdd(maxInt64, -1); !ok || v != maxInt64-1 {
745 t.Fatalf("checkedAdd(max,-1) = %d,%v", v, ok)
746 }
747 if v, ok := checkedAdd(5, 0); !ok || v != 5 {
748 t.Fatalf("checkedAdd(5,0) = %d,%v", v, ok)
749 }
750}
751
752// --- reversal -------------------------------------------------------------
753
754func TestReverseMirrorsEveryPostingAndNetsToZero(t *testing.T) {
755 b := newBook(t)
756 id := b.MustPost(10, "wrong account", []Posting{
757 {Account: cash, Side: Debit, Amount: 300},
758 {Account: ar, Side: Debit, Amount: 700},
759 {Account: revenue, Side: Credit, Amount: 1000},
760 })
761 revID := b.MustReverse(id, 20, "fixing it")
762 if revID != id+1 {
763 t.Fatalf("reversal id = %d, want %d", revID, id+1)
764 }
765
766 rev, _ := b.Entry(revID)
767 if !rev.IsReversal() || rev.ReversalOf != id {
768 t.Fatalf("reversal metadata = %+v", rev)
769 }
770 if rev.At != 20 || rev.Memo != "fixing it" {
771 t.Fatalf("reversal dating/memo = %+v", rev)
772 }
773 orig, _ := b.Entry(id)
774 if orig.IsReversal() {
775 t.Fatal("the original must not be marked a reversal")
776 }
777 if len(rev.Postings) != len(orig.Postings) {
778 t.Fatalf("postings = %d, want %d", len(rev.Postings), len(orig.Postings))
779 }
780 for i, p := range rev.Postings {
781 o := orig.Postings[i]
782 if p.Account != o.Account || p.Amount != o.Amount || p.Side != o.Side.Opposite() {
783 t.Fatalf("posting %d = %+v, want mirror of %+v", i, p, o)
784 }
785 }
786
787 // The whole point: every balance is back to zero, and BOTH entries are
788 // still visible.
789 for _, code := range []string{cash, ar, revenue} {
790 if got, _ := b.Balance(code); got != 0 {
791 t.Fatalf("%s = %d, want 0 after reversal", code, got)
792 }
793 }
794 if b.EntryCount() != 2 {
795 t.Fatalf("EntryCount = %d, want 2 -- a reversal must not delete", b.EntryCount())
796 }
797 if !b.IsBalanced() {
798 t.Fatal("book must be balanced after reversal")
799 }
800 if _, _, agrees := b.RederiveFromJournal(); !agrees {
801 t.Fatal("journal replay must agree after reversal")
802 }
803}
804
805func TestReverseIsOnceOnly(t *testing.T) {
806 b := newBook(t)
807 id := b.MustPost(1, "", pair(cash, revenue, 100))
808 if b.IsReversed(id) {
809 t.Fatal("a fresh entry must not read as reversed")
810 }
811 b.MustReverse(id, 2, "")
812 if !b.IsReversed(id) {
813 t.Fatal("IsReversed must report the reversal")
814 }
815 if _, err := b.Reverse(id, 3, ""); err != ErrAlreadyReversed {
816 t.Fatalf("second Reverse = %v, want ErrAlreadyReversed", err)
817 }
818 if b.EntryCount() != 2 {
819 t.Fatalf("EntryCount = %d, want 2", b.EntryCount())
820 }
821}
822
823func TestReverseAReversalIsPermittedAndAlsoOnceOnly(t *testing.T) {
824 // Documented behaviour: reversing a reversal re-applies the original.
825 b := newBook(t)
826 id := b.MustPost(1, "", pair(cash, revenue, 100))
827 revID := b.MustReverse(id, 2, "")
828 if got, _ := b.Balance(cash); got != 0 {
829 t.Fatalf("cash = %d, want 0", got)
830 }
831 reRevID := b.MustReverse(revID, 3, "undo the undo")
832 if got, _ := b.Balance(cash); got != 100 {
833 t.Fatalf("cash = %d, want 100 -- re-reversal must re-apply", got)
834 }
835 if _, err := b.Reverse(revID, 4, ""); err != ErrAlreadyReversed {
836 t.Fatalf("second Reverse of the reversal = %v, want ErrAlreadyReversed", err)
837 }
838 if reRevID != 3 || b.EntryCount() != 3 {
839 t.Fatalf("ids/count = %d/%d, want 3/3", reRevID, b.EntryCount())
840 }
841 if !b.IsBalanced() {
842 t.Fatal("must stay balanced")
843 }
844}
845
846func TestReverseValidation(t *testing.T) {
847 long := ""
848 for i := 0; i < MaxMemoLen+1; i++ {
849 long += "m"
850 }
851 b := newBook(t)
852 id := b.MustPost(10, "", pair(cash, revenue, 100))
853 cases := []struct {
854 name string
855 id uint64
856 at int64
857 memo string
858 want error
859 }{
860 {"no such entry", 999, 20, "", ErrEntryNotFound},
861 {"entry id zero", 0, 20, "", ErrEntryNotFound},
862 {"negative at", id, -1, "", ErrInvalidAt},
863 {"memo too long", id, 20, long, ErrMemoTooLong},
864 }
865 for _, tc := range cases {
866 t.Run(tc.name, func(t *testing.T) {
867 if _, err := b.Reverse(tc.id, tc.at, tc.memo); err != tc.want {
868 t.Fatalf("Reverse = %v, want %v", err, tc.want)
869 }
870 if b.EntryCount() != 1 {
871 t.Fatal("a refused Reverse must write nothing")
872 }
873 if b.IsReversed(id) {
874 t.Fatal("a refused Reverse must not consume the single reversal")
875 }
876 })
877 }
878}
879
880func TestRefusedReverseDoesNotConsumeTheReversal(t *testing.T) {
881 // The ordering that matters: `reversed` is marked only AFTER post
882 // succeeds. A book at its entry cap refuses the reversal; the original
883 // must still be reversible once there is room.
884 b, err := New(8, 1)
885 if err != nil {
886 t.Fatalf("New: %v", err)
887 }
888 b.MustOpen(cash, "Cash", Asset)
889 b.MustOpen(revenue, "Sales", Revenue)
890 id := b.MustPost(1, "", pair(cash, revenue, 100))
891 if _, err := b.Reverse(id, 2, ""); err != ErrBookFull {
892 t.Fatalf("Reverse = %v, want ErrBookFull", err)
893 }
894 if b.IsReversed(id) {
895 t.Fatal("a reversal refused at the cap must not mark the entry reversed")
896 }
897
898 // Same book, with room: the reversal now succeeds.
899 b2, _ := New(8, 2)
900 b2.MustOpen(cash, "Cash", Asset)
901 b2.MustOpen(revenue, "Sales", Revenue)
902 id2 := b2.MustPost(1, "", pair(cash, revenue, 100))
903 if _, err := b2.Reverse(id2, 2, ""); err != nil {
904 t.Fatalf("Reverse with room = %v, want nil", err)
905 }
906}
907
908func TestMustReversePanics(t *testing.T) {
909 b := newBook(t)
910 mustPanicWith(t, "journal: no such entry", func() { b.MustReverse(42, 1, "") })
911}
912
913// --- period close ---------------------------------------------------------
914
915func TestCloseSealsBackdatingForBothPostAndReverse(t *testing.T) {
916 b := newBook(t)
917 id := b.MustPost(10, "", pair(cash, revenue, 100))
918 if err := b.Close(100); err != nil {
919 t.Fatalf("Close: %v", err)
920 }
921 through, closed := b.ClosedThrough()
922 if !closed || through != 100 {
923 t.Fatalf("ClosedThrough = %d,%v want 100,true", through, closed)
924 }
925 // At the boundary and below are sealed; the instant after is open.
926 if _, err := b.Post(100, "", pair(cash, revenue, 1)); err != ErrPeriodClosed {
927 t.Fatalf("Post at the seal = %v, want ErrPeriodClosed", err)
928 }
929 if _, err := b.Post(50, "", pair(cash, revenue, 1)); err != ErrPeriodClosed {
930 t.Fatalf("Post before the seal = %v, want ErrPeriodClosed", err)
931 }
932 if _, err := b.Post(101, "", pair(cash, revenue, 1)); err != nil {
933 t.Fatalf("Post after the seal = %v, want nil", err)
934 }
935 // Reverse is sealed by the same rule: a closed period cannot be
936 // corrected in place.
937 if _, err := b.Reverse(id, 100, ""); err != ErrPeriodClosed {
938 t.Fatalf("Reverse into the seal = %v, want ErrPeriodClosed", err)
939 }
940 if b.IsReversed(id) {
941 t.Fatal("a period-refused Reverse must not consume the reversal")
942 }
943 if _, err := b.Reverse(id, 200, "corrected in the open period"); err != nil {
944 t.Fatalf("Reverse in the open period = %v, want nil", err)
945 }
946}
947
948func TestCloseOnlyMovesForward(t *testing.T) {
949 b := newBook(t)
950 if err := b.Close(100); err != nil {
951 t.Fatalf("Close: %v", err)
952 }
953 if err := b.Close(100); err != ErrCloseNotForward {
954 t.Fatalf("re-close at the same instant = %v, want ErrCloseNotForward", err)
955 }
956 if err := b.Close(99); err != ErrCloseNotForward {
957 t.Fatalf("close backwards = %v, want ErrCloseNotForward", err)
958 }
959 if err := b.Close(-1); err != ErrInvalidAt {
960 t.Fatalf("Close(-1) = %v, want ErrInvalidAt", err)
961 }
962 // The seal must be exactly where it was after three refusals.
963 through, _ := b.ClosedThrough()
964 if through != 100 {
965 t.Fatalf("ClosedThrough = %d, want 100", through)
966 }
967 if err := b.Close(101); err != nil {
968 t.Fatalf("Close forward = %v, want nil", err)
969 }
970}
971
972func TestCloseThroughZeroIsDistinctFromNeverClosed(t *testing.T) {
973 // hasClosed exists for exactly this: without it, "closed through 0" is
974 // indistinguishable from a new book, and a new book would silently
975 // reject every entry dated at 0.
976 b := newBook(t)
977 if _, err := b.Post(0, "accepted before any close", pair(cash, capital, 1)); err != nil {
978 t.Fatalf("Post(0) on a new book = %v, want nil", err)
979 }
980 if err := b.Close(0); err != nil {
981 t.Fatalf("Close(0) = %v, want nil", err)
982 }
983 through, closed := b.ClosedThrough()
984 if !closed || through != 0 {
985 t.Fatalf("ClosedThrough = %d,%v want 0,true", through, closed)
986 }
987 if _, err := b.Post(0, "", pair(cash, capital, 1)); err != ErrPeriodClosed {
988 t.Fatalf("Post(0) after Close(0) = %v, want ErrPeriodClosed", err)
989 }
990}
991
992func TestMustClosePanics(t *testing.T) {
993 b := newBook(t)
994 b.MustClose(5)
995 mustPanicWith(t, "journal: close must move forward", func() { b.MustClose(5) })
996}
997
998// --- invariants -----------------------------------------------------------
999
1000func TestInvariantsHoldAcrossALongSequence(t *testing.T) {
1001 b := newBook(t)
1002 b.MustPost(1, "capital", pair(cash, capital, 10000))
1003 b.MustPost(2, "sale on credit", pair(ar, revenue, 2500))
1004 b.MustPost(3, "collect", pair(cash, ar, 2500))
1005 b.MustPost(4, "rent accrued", pair(rent, payable, 800))
1006 b.MustPost(5, "rent paid", pair(payable, cash, 800))
1007 bad := b.MustPost(6, "mistake", pair(rent, cash, 999))
1008 b.MustReverse(bad, 7, "mistake reversed")
1009
1010 if !b.IsBalanced() {
1011 t.Fatal("IsBalanced")
1012 }
1013 d, c, agrees := b.RederiveFromJournal()
1014 if !agrees {
1015 t.Fatal("RederiveFromJournal must agree")
1016 }
1017 if d != c || d != b.TotalDebits() {
1018 t.Fatalf("rederived %d/%d vs totals %d/%d", d, c, b.TotalDebits(), b.TotalCredits())
1019 }
1020 if _, _, offsets := b.AccountingEquation(); !offsets {
1021 t.Fatal("AccountingEquation must offset")
1022 }
1023
1024 // Spot-check the economics, so this is a test of the ledger and not only
1025 // of its self-checks: 10000 in, 2500 collected, 800 rent paid, and the
1026 // 999 mistake reversed back out.
1027 if got, _ := b.Balance(cash); got != 10000+2500-800 {
1028 t.Fatalf("cash = %d, want 11700", got)
1029 }
1030 if got, _ := b.Balance(ar); got != 0 {
1031 t.Fatalf("AR = %d, want 0 after collection", got)
1032 }
1033 if got, _ := b.Balance(payable); got != 0 {
1034 t.Fatalf("payable = %d, want 0 after payment", got)
1035 }
1036 if got, _ := b.Balance(revenue); got != 2500 {
1037 t.Fatalf("revenue = %d, want 2500", got)
1038 }
1039 if got, _ := b.Balance(rent); got != 800 {
1040 t.Fatalf("rent = %d, want 800 -- the 999 error must be reversed out", got)
1041 }
1042}
1043
1044func TestAccountingEquationHalvesAreReported(t *testing.T) {
1045 b := newBook(t)
1046 b.MustPost(1, "", pair(cash, capital, 10000))
1047 b.MustPost(2, "", pair(rent, payable, 400))
1048 // debit-normal raw delta: cash +10000, rent +400 = 10400
1049 // credit-normal raw delta: capital -10000, payable -400 = -10400
1050 dn, cn, offsets := b.AccountingEquation()
1051 if dn != 10400 || cn != -10400 || !offsets {
1052 t.Fatalf("AccountingEquation = %d,%d,%v want 10400,-10400,true", dn, cn, offsets)
1053 }
1054}
1055
1056func TestIsBalancedCatchesADriftedAccumulator(t *testing.T) {
1057 // The sabotage that proves IsBalanced can fail. Reaching past the API
1058 // into the running total is exactly the drift it exists to detect; if
1059 // this book still read balanced, the check would be decorative.
1060 b := newBook(t)
1061 b.MustPost(1, "", pair(cash, revenue, 100))
1062 if !b.IsBalanced() {
1063 t.Fatal("precondition")
1064 }
1065 b.totalDebits += 1
1066 if b.IsBalanced() {
1067 t.Fatal("IsBalanced must fail when the running total drifts")
1068 }
1069 if _, _, agrees := b.RederiveFromJournal(); agrees {
1070 t.Fatal("RederiveFromJournal must fail on the same drift")
1071 }
1072 b.totalDebits -= 1
1073
1074 // And the other representation: drift a per-account accumulator.
1075 a, _ := b.Account(cash)
1076 a.Debits += 7
1077 b.accounts.Set(a.Code, a)
1078 if b.IsBalanced() {
1079 t.Fatal("IsBalanced must fail when an account total drifts")
1080 }
1081 if _, _, offsets := b.AccountingEquation(); offsets {
1082 t.Fatal("AccountingEquation must fail on an account drift")
1083 }
1084}
1085
1086func TestRederiveFromJournalCatchesACorruptedEntry(t *testing.T) {
1087 // The drift the cheap check cannot see: corrupt the JOURNAL while both
1088 // accumulators stay consistent with each other. IsBalanced passes --
1089 // which is the documented ceiling -- and only the replay catches it.
1090 b := newBook(t)
1091 id := b.MustPost(1, "", pair(cash, revenue, 100))
1092 e, _ := b.entry(id)
1093 e.Postings = []Posting{
1094 {Account: cash, Side: Debit, Amount: 999},
1095 {Account: revenue, Side: Credit, Amount: 999},
1096 }
1097 b.entries.Set(idKey(id), e)
1098
1099 if !b.IsBalanced() {
1100 t.Fatal("IsBalanced is documented as blind to this; if it now catches it, update the doc")
1101 }
1102 d, c, agrees := b.RederiveFromJournal()
1103 if agrees {
1104 t.Fatal("RederiveFromJournal must catch a journal that disagrees with the accumulators")
1105 }
1106 if d != 999 || c != 999 {
1107 t.Fatalf("rederived = %d/%d, want 999/999 from the journal itself", d, c)
1108 }
1109}
1110
1111func TestIsBalancedComparesTheTwoColumnsToEachOther(t *testing.T) {
1112 // d == b.totalDebits and c == b.totalCredits together do NOT imply
1113 // d == c: a book whose two representations agree with each other can
1114 // still have a debit column that does not equal its credit column.
1115 // Drifting an account and its matching running total by the SAME
1116 // amount leaves the d == c conjunct as the only one that can notice,
1117 // which is what makes that conjunct provable rather than redundant.
1118 b := newBook(t)
1119 b.MustPost(1, "", pair(cash, revenue, 100))
1120 if !b.IsBalanced() {
1121 t.Fatal("precondition")
1122 }
1123 a, _ := b.Account(cash)
1124 a.Debits += 7
1125 b.accounts.Set(a.Code, a)
1126 b.totalDebits += 7
1127
1128 d, c, ok := b.sumAccounts()
1129 if !ok {
1130 t.Fatal("precondition: the chart must still sum")
1131 }
1132 if d != b.totalDebits || c != b.totalCredits {
1133 t.Fatalf("precondition: both representations must agree: chart %d/%d, totals %d/%d",
1134 d, c, b.totalDebits, b.totalCredits)
1135 }
1136 if d == c {
1137 t.Fatal("precondition: the two columns must differ")
1138 }
1139 if b.IsBalanced() {
1140 t.Fatal("IsBalanced must fail when the debit column does not equal the credit column")
1141 }
1142}
1143
1144func TestIsBalancedRefusesAnOverflowedChartSum(t *testing.T) {
1145 // sumAccounts documents that callers must not read its (0, 0) on
1146 // overflow as an empty chart, and this is the reason: a fresh Book's
1147 // running totals are also 0/0, so discarding the ok makes an
1148 // overflowed chart read exactly like a book with nothing posted.
1149 const maxInt64 = int64(9223372036854775807)
1150 b := newBook(t)
1151 for _, code := range []string{cash, ar} {
1152 a, _ := b.Account(code)
1153 a.Debits = maxInt64
1154 b.accounts.Set(a.Code, a)
1155 }
1156
1157 if _, _, ok := b.sumAccounts(); ok {
1158 t.Fatal("precondition: the chart sum must overflow")
1159 }
1160 if b.TotalDebits() != 0 || b.TotalCredits() != 0 {
1161 t.Fatal("precondition: the running totals must still be zero, or the test proves something else")
1162 }
1163 if b.IsBalanced() {
1164 t.Fatal("IsBalanced must refuse a chart sum it could not compute")
1165 }
1166 if _, _, agrees := b.RederiveFromJournal(); agrees {
1167 t.Fatal("RederiveFromJournal must refuse the same chart")
1168 }
1169}
1170
1171func TestRederiveFromJournalComparesTheReplayToTheChart(t *testing.T) {
1172 // The chart is the third representation and the one the running totals
1173 // cannot stand in for. Drift one account only: the replay still agrees
1174 // with both running totals, so the d == sd && c == sc conjunct is the
1175 // only thing left that can catch it.
1176 b := newBook(t)
1177 b.MustPost(1, "", pair(cash, revenue, 100))
1178 a, _ := b.Account(cash)
1179 a.Debits += 7
1180 b.accounts.Set(a.Code, a)
1181
1182 d, c, agrees := b.RederiveFromJournal()
1183 if d != 100 || c != 100 {
1184 t.Fatalf("replay = %d/%d, want 100/100 read back from the journal", d, c)
1185 }
1186 if d != b.totalDebits || c != b.totalCredits {
1187 t.Fatalf("precondition: the replay must still agree with both running totals (%d/%d)",
1188 b.totalDebits, b.totalCredits)
1189 }
1190 sd, sc, okSum := b.sumAccounts()
1191 if !okSum {
1192 t.Fatal("precondition: the chart must still sum")
1193 }
1194 if sd == d {
1195 t.Fatalf("precondition: the chart must disagree with the replay: chart %d/%d", sd, sc)
1196 }
1197 if agrees {
1198 t.Fatal("RederiveFromJournal must fail when the chart disagrees with the journal")
1199 }
1200}
1201
1202// --- reads and encapsulation ----------------------------------------------
1203
1204func TestEntryPostingsAreADefensiveCopy(t *testing.T) {
1205 b := newBook(t)
1206 id := b.MustPost(1, "", pair(cash, revenue, 100))
1207
1208 e, _ := b.Entry(id)
1209 e.Postings[0].Amount = 1
1210 e.Postings[0].Account = "hacked"
1211 e.Postings[1].Side = Debit
1212
1213 again, _ := b.Entry(id)
1214 if again.Postings[0].Amount != 100 || again.Postings[0].Account != cash {
1215 t.Fatalf("Entry returned a window into Book state: %+v", again.Postings[0])
1216 }
1217 if again.Postings[1].Side != Credit {
1218 t.Fatal("Entry's postings were mutated through the returned slice")
1219 }
1220 if !b.IsBalanced() {
1221 t.Fatal("book must be unaffected")
1222 }
1223}
1224
1225func TestIterateEntriesAlsoCopies(t *testing.T) {
1226 b := newBook(t)
1227 b.MustPost(1, "", pair(cash, revenue, 100))
1228 b.IterateEntries(func(e Entry) bool {
1229 e.Postings[0].Amount = 42
1230 return false
1231 })
1232 again := b.Entries(1, 1)
1233 if len(again) != 1 || again[0].Postings[0].Amount != 100 {
1234 t.Fatal("IterateEntries handed out a writable window")
1235 }
1236}
1237
1238func TestEntryMissingIsReportedNotZeroed(t *testing.T) {
1239 b := newBook(t)
1240 if _, ok := b.Entry(1); ok {
1241 t.Fatal("Entry on an empty journal must report false")
1242 }
1243 if _, ok := b.Entry(0); ok {
1244 t.Fatal("id 0 is never allocated")
1245 }
1246 if b.IsReversed(1) {
1247 t.Fatal("IsReversed on a missing entry must be false")
1248 }
1249}
1250
1251func TestIterationIsInAscendingIDOrderPastTenEntries(t *testing.T) {
1252 // The padded-key test: with unpadded decimal keys, "10" sorts before
1253 // "2" and this fails at the tenth entry.
1254 b := newBook(t)
1255 for i := 0; i < 12; i++ {
1256 b.MustPost(int64(i+1), "", pair(cash, revenue, int64(i+1)))
1257 }
1258 var seen []uint64
1259 b.IterateEntries(func(e Entry) bool {
1260 seen = append(seen, e.ID)
1261 return false
1262 })
1263 if len(seen) != 12 {
1264 t.Fatalf("iterated %d, want 12", len(seen))
1265 }
1266 for i, id := range seen {
1267 if id != uint64(i+1) {
1268 t.Fatalf("order = %v, want 1..12 ascending", seen)
1269 }
1270 }
1271}
1272
1273func TestIterationStopsEarly(t *testing.T) {
1274 b := newBook(t)
1275 for i := 0; i < 5; i++ {
1276 b.MustPost(int64(i+1), "", pair(cash, revenue, 1))
1277 }
1278 n := 0
1279 b.IterateEntries(func(Entry) bool {
1280 n++
1281 return n >= 2
1282 })
1283 if n != 2 {
1284 t.Fatalf("IterateEntries visited %d, want 2", n)
1285 }
1286
1287 m := 0
1288 b.IterateAccounts(func(Account) bool {
1289 m++
1290 return m >= 3
1291 })
1292 if m != 3 {
1293 t.Fatalf("IterateAccounts visited %d, want 3", m)
1294 }
1295}
1296
1297func TestIterateAccountsCoversTheChartInCodeOrder(t *testing.T) {
1298 b := newBook(t)
1299 var codes []string
1300 b.IterateAccounts(func(a Account) bool {
1301 codes = append(codes, a.Code)
1302 return false
1303 })
1304 want := []string{cash, ar, payable, capital, revenue, rent}
1305 if len(codes) != len(want) {
1306 t.Fatalf("iterated %d accounts, want %d", len(codes), len(want))
1307 }
1308 for i := range want {
1309 if codes[i] != want[i] {
1310 t.Fatalf("order = %v, want %v", codes, want)
1311 }
1312 }
1313}
1314
1315func TestEntriesPagesFromAnID(t *testing.T) {
1316 b := newBook(t)
1317 for i := 0; i < 6; i++ {
1318 b.MustPost(int64(i+1), "", pair(cash, revenue, 1))
1319 }
1320 if got := b.Entries(0, 0); len(got) != 0 {
1321 t.Fatalf("limit 0 returned %d", len(got))
1322 }
1323 if got := b.Entries(0, -1); len(got) != 0 {
1324 t.Fatalf("negative limit returned %d", len(got))
1325 }
1326 page := b.Entries(3, 2)
1327 if len(page) != 2 || page[0].ID != 3 || page[1].ID != 4 {
1328 t.Fatalf("Entries(3,2) = %v", page)
1329 }
1330 tail := b.Entries(5, 10)
1331 if len(tail) != 2 || tail[0].ID != 5 {
1332 t.Fatalf("Entries(5,10) returned %d entries", len(tail))
1333 }
1334 if got := b.Entries(99, 5); len(got) != 0 {
1335 t.Fatalf("Entries past the end returned %d", len(got))
1336 }
1337}
1338
1339func TestIDKeyPadsAndOrders(t *testing.T) {
1340 if got := idKey(1); got != "00000000000000000001" {
1341 t.Fatalf("idKey(1) = %q", got)
1342 }
1343 if len(idKey(1)) != len(idKey(1234567)) {
1344 t.Fatal("keys must be fixed width")
1345 }
1346 if !(idKey(2) < idKey(10)) {
1347 t.Fatal("lexical order must match numeric order")
1348 }
1349 // The 20-digit claim: math.MaxUint64 is exactly 20 digits, so it is the
1350 // one input that uses the full width with no padding.
1351 max := idKey(18446744073709551615)
1352 if len(max) != 20 || max != "18446744073709551615" {
1353 t.Fatalf("idKey(maxUint64) = %q", max)
1354 }
1355}
1356
1357func TestEntryTotalCountsDebitsOnly(t *testing.T) {
1358 e := Entry{Postings: []Posting{
1359 {Account: cash, Side: Debit, Amount: 30},
1360 {Account: ar, Side: Debit, Amount: 70},
1361 {Account: revenue, Side: Credit, Amount: 100},
1362 }}
1363 if e.Total() != 100 {
1364 t.Fatalf("Total = %d, want 100", e.Total())
1365 }
1366 if (Entry{}).Total() != 0 {
1367 t.Fatal("empty entry Total must be 0")
1368 }
1369}
1370
1371func TestAccountBalanceSignByKind(t *testing.T) {
1372 // A contra position is a real signal, not a sign convention: an asset
1373 // with more credits than debits reports NEGATIVE.
1374 a := Account{Kind: Asset, Debits: 10, Credits: 40}
1375 if a.Balance() != -30 || a.RawDelta() != -30 {
1376 t.Fatalf("contra asset = %d/%d, want -30/-30", a.Balance(), a.RawDelta())
1377 }
1378 l := Account{Kind: Liability, Debits: 10, Credits: 40}
1379 if l.Balance() != 30 || l.RawDelta() != -30 {
1380 t.Fatalf("liability = %d/%d, want 30/-30", l.Balance(), l.RawDelta())
1381 }
1382 // An invalid kind reports false from IsDebitNormal, so Balance reads it
1383 // as credit-normal. Asserted rather than left implicit, because a book
1384 // can never hold one -- Open refuses it.
1385 bad := Account{Kind: Kind(0), Debits: 10, Credits: 40}
1386 if bad.Balance() != 30 {
1387 t.Fatalf("invalid kind Balance = %d, want 30 (credit-normal fallback)", bad.Balance())
1388 }
1389}