Fixes + stable gateway ids

This commit is contained in:
Stephan D
2026-02-18 20:38:08 +01:00
parent 4dc182bfa2
commit 770c7b9da9
119 changed files with 3000 additions and 734 deletions

View File

@@ -0,0 +1,144 @@
package model
import "strings"
// SelectGatewayByPreference picks a gateway candidate using persisted affinity hints.
// Matching order:
// 1) gateway ID + instance ID
// 2) invoke URI
// 3) gateway ID
// 4) instance ID
// 5) first candidate fallback
func SelectGatewayByPreference(
candidates []*GatewayInstanceDescriptor,
gatewayID string,
instanceID string,
invokeURI string,
) (*GatewayInstanceDescriptor, string) {
if len(candidates) == 0 {
return nil, ""
}
gatewayID = strings.TrimSpace(gatewayID)
instanceID = strings.TrimSpace(instanceID)
invokeURI = strings.TrimSpace(invokeURI)
if gatewayID != "" && instanceID != "" {
for _, entry := range candidates {
if entry == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.ID), gatewayID) &&
strings.EqualFold(strings.TrimSpace(entry.InstanceID), instanceID) {
return entry, "exact"
}
}
}
if invokeURI != "" {
for _, entry := range candidates {
if entry == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.InvokeURI), invokeURI) {
return entry, "invoke_uri"
}
}
}
if gatewayID != "" {
for _, entry := range candidates {
if entry == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.ID), gatewayID) {
return entry, "gateway_id"
}
}
}
if instanceID != "" {
for _, entry := range candidates {
if entry == nil {
continue
}
if strings.EqualFold(strings.TrimSpace(entry.InstanceID), instanceID) {
return entry, "instance_id"
}
}
}
for _, entry := range candidates {
if entry != nil {
return entry, "rail_fallback"
}
}
return nil, ""
}
// GatewayDescriptorIdentityKey returns a stable dedupe key for gateway entries.
// The key is composed from logical gateway ID + instance identity; invoke URI is
// used as a fallback identity when instance ID is missing.
func GatewayDescriptorIdentityKey(entry *GatewayInstanceDescriptor) string {
if entry == nil {
return ""
}
return GatewayIdentityKey(entry.ID, entry.InstanceID, entry.InvokeURI)
}
// GatewayIdentityKey composes a stable identity key from gateway affinity fields.
func GatewayIdentityKey(gatewayID string, instanceID string, invokeURI string) string {
id := strings.ToLower(strings.TrimSpace(gatewayID))
if id == "" {
return ""
}
instance := strings.ToLower(strings.TrimSpace(instanceID))
if instance == "" {
instance = strings.ToLower(strings.TrimSpace(invokeURI))
}
if instance == "" {
return id
}
return id + "|" + instance
}
// LessGatewayDescriptor orders gateway descriptors deterministically.
func LessGatewayDescriptor(left *GatewayInstanceDescriptor, right *GatewayInstanceDescriptor) bool {
if left == nil {
return right != nil
}
if right == nil {
return false
}
if cmp := compareFolded(left.ID, right.ID); cmp != 0 {
return cmp < 0
}
if cmp := compareFolded(left.InstanceID, right.InstanceID); cmp != 0 {
return cmp < 0
}
if cmp := compareFolded(left.InvokeURI, right.InvokeURI); cmp != 0 {
return cmp < 0
}
if cmp := compareFolded(string(left.Rail), string(right.Rail)); cmp != 0 {
return cmp < 0
}
if cmp := compareFolded(left.Network, right.Network); cmp != 0 {
return cmp < 0
}
return compareFolded(left.Version, right.Version) < 0
}
func compareFolded(left string, right string) int {
l := strings.ToLower(strings.TrimSpace(left))
r := strings.ToLower(strings.TrimSpace(right))
switch {
case l < r:
return -1
case l > r:
return 1
default:
return 0
}
}

View File

@@ -0,0 +1,29 @@
package model
import "testing"
func TestGatewayIdentityKey(t *testing.T) {
if got, want := GatewayIdentityKey(" gw ", "inst-1", "grpc://one"), "gw|inst-1"; got != want {
t.Fatalf("unexpected gateway identity key: got=%q want=%q", got, want)
}
if got, want := GatewayIdentityKey("gw", "", " grpc://one "), "gw|grpc://one"; got != want {
t.Fatalf("unexpected gateway identity key with invoke fallback: got=%q want=%q", got, want)
}
if got, want := GatewayIdentityKey(" gw ", "", ""), "gw"; got != want {
t.Fatalf("unexpected gateway identity key with id only: got=%q want=%q", got, want)
}
if got := GatewayIdentityKey("", "inst-1", "grpc://one"); got != "" {
t.Fatalf("expected empty key when gateway id missing, got=%q", got)
}
}
func TestLessGatewayDescriptor(t *testing.T) {
a := &GatewayInstanceDescriptor{ID: "gw", InstanceID: "inst-a", InvokeURI: "grpc://a"}
b := &GatewayInstanceDescriptor{ID: "gw", InstanceID: "inst-b", InvokeURI: "grpc://b"}
if !LessGatewayDescriptor(a, b) {
t.Fatalf("expected inst-a to sort before inst-b")
}
if LessGatewayDescriptor(b, a) {
t.Fatalf("expected inst-b not to sort before inst-a")
}
}

View File

@@ -278,17 +278,19 @@ type ExecutionRefs struct {
// PaymentStep is an explicit action within a payment plan.
type PaymentStep struct {
StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"`
Rail Rail `bson:"rail" json:"rail"`
GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
Action RailOperation `bson:"action" json:"action"`
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"`
FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"`
ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"`
StepID string `bson:"stepId,omitempty" json:"stepId,omitempty"`
Rail Rail `bson:"rail" json:"rail"`
GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"`
InstanceID string `bson:"instanceId,omitempty" json:"instanceId,omitempty"`
GatewayInvokeURI string `bson:"gatewayInvokeUri,omitempty" json:"gatewayInvokeUri,omitempty"`
Action RailOperation `bson:"action" json:"action"`
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"`
FromRole *account_role.AccountRole `bson:"fromRole,omitempty" json:"fromRole,omitempty"`
ToRole *account_role.AccountRole `bson:"toRole,omitempty" json:"toRole,omitempty"`
}
// PaymentPlan captures the ordered list of steps to execute a payment.
@@ -311,6 +313,7 @@ type ExecutionStep struct {
DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"`
TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"`
OperationRef string `bson:"operationRef,omitempty" json:"operationRef,omitempty"`
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
Error string `bson:"error,omitempty" json:"error,omitempty"`
State OperationState `bson:"state,omitempty" json:"state,omitempty"`
Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"`
@@ -437,6 +440,7 @@ func (p *Payment) Normalize() {
step.SourceWalletRef = strings.TrimSpace(step.SourceWalletRef)
step.DestinationRef = strings.TrimSpace(step.DestinationRef)
step.TransferRef = strings.TrimSpace(step.TransferRef)
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
if step.Metadata != nil {
for k, v := range step.Metadata {
step.Metadata[k] = strings.TrimSpace(v)
@@ -455,7 +459,9 @@ func (p *Payment) Normalize() {
step.Rail = Rail(strings.TrimSpace(string(step.Rail)))
step.GatewayID = strings.TrimSpace(step.GatewayID)
step.InstanceID = strings.TrimSpace(step.InstanceID)
step.GatewayInvokeURI = strings.TrimSpace(step.GatewayInvokeURI)
step.Action = RailOperation(strings.TrimSpace(string(step.Action)))
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
step.DependsOn = normalizeStringList(step.DependsOn)
step.CommitAfter = normalizeStringList(step.CommitAfter)

View File

@@ -13,6 +13,7 @@ type OrchestrationStep struct {
StepID string `bson:"stepId" json:"stepId"`
Rail Rail `bson:"rail" json:"rail"`
Operation string `bson:"operation" json:"operation"`
ReportVisibility ReportVisibility `bson:"reportVisibility,omitempty" json:"reportVisibility,omitempty"`
DependsOn []string `bson:"dependsOn,omitempty" json:"dependsOn,omitempty"`
CommitPolicy CommitPolicy `bson:"commitPolicy,omitempty" json:"commitPolicy,omitempty"`
CommitAfter []string `bson:"commitAfter,omitempty" json:"commitAfter,omitempty"`
@@ -52,6 +53,7 @@ func (t *PaymentPlanTemplate) Normalize() {
step.StepID = strings.TrimSpace(step.StepID)
step.Rail = Rail(strings.ToUpper(strings.TrimSpace(string(step.Rail))))
step.Operation = strings.ToLower(strings.TrimSpace(step.Operation))
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)
step.DependsOn = normalizeStringList(step.DependsOn)
step.CommitAfter = normalizeStringList(step.CommitAfter)

View File

@@ -1,21 +1,14 @@
package model
// QuoteKind captures v2 quote kind metadata for persistence.
type QuoteKind string
// QuoteState captures v2 quote state metadata for persistence.
type QuoteState string
const (
QuoteKindUnspecified QuoteKind = "unspecified"
QuoteKindExecutable QuoteKind = "executable"
QuoteKindIndicative QuoteKind = "indicative"
)
// QuoteLifecycle captures v2 quote lifecycle metadata for persistence.
type QuoteLifecycle string
const (
QuoteLifecycleUnspecified QuoteLifecycle = "unspecified"
QuoteLifecycleActive QuoteLifecycle = "active"
QuoteLifecycleExpired QuoteLifecycle = "expired"
QuoteStateUnspecified QuoteState = "unspecified"
QuoteStateIndicative QuoteState = "indicative"
QuoteStateExecutable QuoteState = "executable"
QuoteStateBlocked QuoteState = "blocked"
QuoteStateExpired QuoteState = "expired"
)
// QuoteBlockReason captures v2 non-executability reason for persistence.
@@ -34,8 +27,6 @@ const (
// QuoteStatusV2 stores execution status metadata from quotation v2.
type QuoteStatusV2 struct {
Kind QuoteKind `bson:"kind,omitempty" json:"kind,omitempty"`
Lifecycle QuoteLifecycle `bson:"lifecycle,omitempty" json:"lifecycle,omitempty"`
Executable *bool `bson:"executable,omitempty" json:"executable,omitempty"`
State QuoteState `bson:"state,omitempty" json:"state,omitempty"`
BlockReason QuoteBlockReason `bson:"blockReason,omitempty" json:"blockReason,omitempty"`
}

View File

@@ -0,0 +1,44 @@
package model
import "strings"
// ReportVisibility controls which audience should see a step in reports/timelines.
type ReportVisibility string
const (
ReportVisibilityUnspecified ReportVisibility = ""
ReportVisibilityHidden ReportVisibility = "hidden"
ReportVisibilityUser ReportVisibility = "user"
ReportVisibilityBackoffice ReportVisibility = "backoffice"
ReportVisibilityAudit ReportVisibility = "audit"
)
// NormalizeReportVisibility trims and lowercases the visibility value.
func NormalizeReportVisibility(value ReportVisibility) ReportVisibility {
return ReportVisibility(strings.ToLower(strings.TrimSpace(string(value))))
}
// IsValidReportVisibility reports whether the value is a supported enum variant.
func IsValidReportVisibility(value ReportVisibility) bool {
switch NormalizeReportVisibility(value) {
case ReportVisibilityUnspecified,
ReportVisibilityHidden,
ReportVisibilityUser,
ReportVisibilityBackoffice,
ReportVisibilityAudit:
return true
default:
return false
}
}
// IsUserVisible returns true when the step should be shown to end users.
// Unspecified is treated as user-visible for backward compatibility.
func (value ReportVisibility) IsUserVisible() bool {
switch NormalizeReportVisibility(value) {
case ReportVisibilityUnspecified, ReportVisibilityUser:
return true
default:
return false
}
}