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()})
|
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: err.Error()})
|
||||||
continue
|
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 {
|
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)})
|
report.Invalid = append(report.Invalid, Issue{JournalID: journal.ID, Error: fmt.Sprintf("duplicate source transaction/version; first journal %s", previous)})
|
||||||
continue
|
continue
|
||||||
|
|||||||
Reference in New Issue
Block a user