feat(gl): add read-only ledger reconciliation scan
This commit is contained in:
@@ -0,0 +1,53 @@
|
|||||||
|
// Package reconciliation provides read-only integrity checks for the GL.
|
||||||
|
package reconciliation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Repository interface {
|
||||||
|
List(context.Context, ledger.JournalFilter) ([]ledger.Journal, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
type Report struct {
|
||||||
|
Scanned int
|
||||||
|
Valid int
|
||||||
|
Invalid []Issue
|
||||||
|
}
|
||||||
|
|
||||||
|
type Issue struct {
|
||||||
|
JournalID string
|
||||||
|
Error string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run scans all posted journals without mutating the ledger.
|
||||||
|
func Run(ctx context.Context, repository Repository, pageSize int) (Report, error) {
|
||||||
|
if repository == nil {
|
||||||
|
return Report{}, fmt.Errorf("reconciliation repository is required")
|
||||||
|
}
|
||||||
|
if pageSize <= 0 || pageSize > 200 {
|
||||||
|
pageSize = 100
|
||||||
|
}
|
||||||
|
report := Report{Invalid: make([]Issue, 0)}
|
||||||
|
for offset := 0; ; offset += pageSize {
|
||||||
|
journals, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset})
|
||||||
|
if err != nil {
|
||||||
|
return Report{}, fmt.Errorf("list journals at offset %d: %w", offset, err)
|
||||||
|
}
|
||||||
|
for _, journal := range journals {
|
||||||
|
report.Scanned++
|
||||||
|
if err := journal.Validate(); err != nil {
|
||||||
|
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
report.Valid++
|
||||||
|
}
|
||||||
|
if len(journals) < pageSize {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return report, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
package reconciliation
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"gl/domain/ledger"
|
||||||
|
)
|
||||||
|
|
||||||
|
type repositoryStub struct {
|
||||||
|
pages [][]ledger.Journal
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r repositoryStub) List(_ context.Context, filter ledger.JournalFilter) ([]ledger.Journal, error) {
|
||||||
|
index := filter.Offset / filter.Limit
|
||||||
|
if index >= len(r.pages) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return r.pages[index], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func validJournal() ledger.Journal {
|
||||||
|
return ledger.Journal{ID: "j1", SourceService: "wallet", IdempotencyKey: "k1", SourceTransactionID: "t1", EffectKind: "transfer", EventVersion: 1, OccurredAt: ledgerTestTime(), PayloadHash: "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", Entries: []ledger.Entry{{LineNumber: 1, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("-1")}, {LineNumber: 2, Account: ledger.AccountReference{Class: ledger.AccountClassTreasury, AssetID: 1}, Amount: mustAmount("1")}}}
|
||||||
|
}
|
||||||
|
|
||||||
|
func ledgerTestTime() (t time.Time) { return time.Unix(1, 0).UTC() }
|
||||||
|
func mustAmount(value string) ledger.Amount { amount, _ := ledger.ParseAmount(value); return amount }
|
||||||
|
|
||||||
|
func TestRunScansAndReportsInvalidJournals(t *testing.T) {
|
||||||
|
valid := validJournal()
|
||||||
|
invalid := valid
|
||||||
|
invalid.Entries = append([]ledger.Entry(nil), valid.Entries...)
|
||||||
|
invalid.ID = "bad"
|
||||||
|
invalid.Entries[1].Amount = mustAmount("2")
|
||||||
|
report, err := Run(context.Background(), repositoryStub{pages: [][]ledger.Journal{{valid}, {invalid}}}, 1)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if report.Scanned != 2 || report.Valid != 1 || len(report.Invalid) != 1 || report.Invalid[0].JournalID != "bad" {
|
||||||
|
t.Fatalf("unexpected report: %+v", report)
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user