From d5c9b338a44c2082d2343acfaf1d1eaf809983eb7bdc9b70e426feb4bc3be291 Mon Sep 17 00:00:00 2001 From: nfel Date: Sat, 29 Aug 2026 16:32:40 +0330 Subject: [PATCH] feat(gl): compare external settlement evidence --- application/reconciliation/evidence.go | 85 +++++++++++++++++++++ application/reconciliation/evidence_test.go | 39 ++++++++++ application/reconciliation/service.go | 2 +- 3 files changed, 125 insertions(+), 1 deletion(-) create mode 100644 application/reconciliation/evidence.go create mode 100644 application/reconciliation/evidence_test.go diff --git a/application/reconciliation/evidence.go b/application/reconciliation/evidence.go new file mode 100644 index 0000000..4192259 --- /dev/null +++ b/application/reconciliation/evidence.go @@ -0,0 +1,85 @@ +package reconciliation + +import ( + "context" + "fmt" + + "gl/domain/ledger" +) + +// Evidence is a normalized record supplied by Wallet, Kuknos, or another +// authoritative settlement source. Adapters remain outside the GL domain. +type Evidence struct { + SourceService string + SourceTransactionID string + EventVersion uint32 + BlockchainNetwork string + TransactionHash string +} + +type EvidenceReport struct { + Compared int + Missing []Evidence + Duplicates []Evidence + Mismatched []EvidenceMismatch +} + +type EvidenceMismatch struct { + Evidence Evidence + JournalID string + Field string + Expected string + Actual string +} + +// CompareEvidence compares normalized external evidence with immutable GL +// journals. It is read-only and safe to run repeatedly after outages. +func CompareEvidence(ctx context.Context, repository Repository, evidence []Evidence, pageSize int) (EvidenceReport, error) { + if repository == nil { + return EvidenceReport{}, fmt.Errorf("reconciliation repository is required") + } + if pageSize <= 0 || pageSize > 200 { + pageSize = 100 + } + journals := make(map[string]ledger.Journal) + for offset := 0; ; offset += pageSize { + page, err := repository.List(ctx, ledger.JournalFilter{Limit: pageSize, Offset: offset}) + if err != nil { + return EvidenceReport{}, fmt.Errorf("list journals at offset %d: %w", offset, err) + } + for _, journal := range page { + journals[evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion)] = journal + } + if len(page) < pageSize { + break + } + } + + report := EvidenceReport{Missing: make([]Evidence, 0), Duplicates: make([]Evidence, 0), Mismatched: make([]EvidenceMismatch, 0)} + seen := make(map[string]struct{}, len(evidence)) + for _, item := range evidence { + report.Compared++ + key := evidenceKey(item.SourceService, item.SourceTransactionID, item.EventVersion) + if _, exists := seen[key]; exists { + report.Duplicates = append(report.Duplicates, item) + continue + } + seen[key] = struct{}{} + journal, exists := journals[key] + if !exists { + report.Missing = append(report.Missing, item) + continue + } + if item.BlockchainNetwork != "" && item.BlockchainNetwork != journal.Blockchain.Network { + report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "blockchain_network", Expected: item.BlockchainNetwork, Actual: journal.Blockchain.Network}) + } + if item.TransactionHash != "" && item.TransactionHash != journal.Blockchain.TransactionHash { + report.Mismatched = append(report.Mismatched, EvidenceMismatch{Evidence: item, JournalID: journal.ID, Field: "transaction_hash", Expected: item.TransactionHash, Actual: journal.Blockchain.TransactionHash}) + } + } + return report, nil +} + +func evidenceKey(sourceService, sourceTransactionID string, eventVersion uint32) string { + return sourceService + "\x00" + sourceTransactionID + "\x00" + fmt.Sprint(eventVersion) +} diff --git a/application/reconciliation/evidence_test.go b/application/reconciliation/evidence_test.go new file mode 100644 index 0000000..6884870 --- /dev/null +++ b/application/reconciliation/evidence_test.go @@ -0,0 +1,39 @@ +package reconciliation + +import ( + "context" + "testing" + + "gl/domain/ledger" +) + +func TestCompareEvidenceReportsMissingDuplicateAndBlockchainMismatch(t *testing.T) { + journal := validJournal() + journal.Blockchain = ledger.BlockchainReference{Network: "kuknos", TransactionHash: "hash-1"} + matching := Evidence{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "hash-1"} + evidence := []Evidence{ + matching, + matching, + {SourceService: "wallet", SourceTransactionID: "missing", EventVersion: 1}, + {SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, BlockchainNetwork: "kuknos", TransactionHash: "wrong"}, + } + report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, evidence, 10) + if err != nil { + t.Fatal(err) + } + if report.Compared != 4 || len(report.Missing) != 1 || len(report.Duplicates) != 2 || len(report.Mismatched) != 0 { + t.Fatalf("unexpected report: %+v", report) + } +} + +func TestCompareEvidenceReportsHashMismatch(t *testing.T) { + journal := validJournal() + journal.Blockchain.TransactionHash = "actual" + report, err := CompareEvidence(context.Background(), repositoryStub{pages: [][]ledger.Journal{{journal}}}, []Evidence{{SourceService: "wallet", SourceTransactionID: "t1", EventVersion: 1, TransactionHash: "expected"}}, 10) + if err != nil { + t.Fatal(err) + } + if len(report.Mismatched) != 1 || report.Mismatched[0].Field != "transaction_hash" { + t.Fatalf("unexpected report: %+v", report) + } +} diff --git a/application/reconciliation/service.go b/application/reconciliation/service.go index 875442a..7b635de 100644 --- a/application/reconciliation/service.go +++ b/application/reconciliation/service.go @@ -44,7 +44,7 @@ func Run(ctx context.Context, repository Repository, pageSize int) (Report, erro report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()}) continue } - key := journal.SourceService + "\x00" + journal.SourceTransactionID + "\x00" + fmt.Sprint(journal.EventVersion) + key := evidenceKey(journal.SourceService, journal.SourceTransactionID, journal.EventVersion) if previous, exists := seen[key]; exists { report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: fmt.Sprintf("duplicate source transaction/version; first journal %s", previous)}) continue