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.

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}