feat(gl): compare external settlement evidence
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user