diff --git a/application/reconciliation/service.go b/application/reconciliation/service.go new file mode 100644 index 0000000..c979868 --- /dev/null +++ b/application/reconciliation/service.go @@ -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 +} diff --git a/application/reconciliation/service_test.go b/application/reconciliation/service_test.go new file mode 100644 index 0000000..2f2aea5 --- /dev/null +++ b/application/reconciliation/service_test.go @@ -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) + } +}