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

@@ -107,9 +107,6 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
eligible := make([]*model.GatewayInstanceDescriptor, 0)
var lastErr error
for _, gw := range all {
if instanceID != "" && !strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
continue
}
if err := isGatewayEligible(gw, rail, network, currency, action, dir, amt); err != nil {
lastErr = err
continue
@@ -125,6 +122,13 @@ func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rai
sort.Slice(eligible, func(i, j int) bool {
return eligible[i].ID < eligible[j].ID
})
if instanceID != "" {
for _, gw := range eligible {
if strings.EqualFold(strings.TrimSpace(gw.InstanceID), instanceID) {
return gw, nil
}
}
}
return eligible[0], nil
}

View File

@@ -14,11 +14,12 @@ func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) {
return nil, merrors.InvalidArgument("plan builder: payment is required")
}
step := &model.PaymentStep{
StepID: "fx_convert",
Rail: model.RailLedger,
Action: model.RailOperationFXConvert,
CommitPolicy: model.CommitPolicyImmediate,
Amount: cloneMoney(payment.Intent.Amount),
StepID: "fx_convert",
Rail: model.RailLedger,
Action: model.RailOperationFXConvert,
ReportVisibility: model.ReportVisibilityUser,
CommitPolicy: model.CommitPolicyImmediate,
Amount: cloneMoney(payment.Intent.Amount),
}
return &model.PaymentPlan{
ID: payment.PaymentRef,

View File

@@ -132,6 +132,7 @@ func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *mod
StepID: stepID,
Rail: tpl.Rail,
Action: action,
ReportVisibility: tpl.ReportVisibility,
DependsOn: cloneStringList(tpl.DependsOn),
CommitPolicy: policy,
CommitAfter: cloneStringList(tpl.CommitAfter),
@@ -179,6 +180,7 @@ func (b *defaultBuilder) buildPlanFromTemplate(ctx context.Context, payment *mod
}
step.GatewayID = strings.TrimSpace(gw.ID)
step.InstanceID = strings.TrimSpace(gw.InstanceID)
step.GatewayInvokeURI = strings.TrimSpace(gw.InvokeURI)
}
logger.Debug("Plan step added",

View File

@@ -154,6 +154,12 @@ func validatePlanTemplate(logger mlogger.Logger, template *model.PaymentPlanTemp
zap.Int("step_index", idx))
return merrors.InvalidArgument("plan builder: plan template operation is required")
}
if !model.IsValidReportVisibility(step.ReportVisibility) {
logger.Warn("Plan template step has invalid report visibility",
zap.String("step_id", id),
zap.String("report_visibility", string(step.ReportVisibility)))
return merrors.InvalidArgument("plan builder: plan template report visibility is invalid")
}
action, err := actionForOperation(step.Operation)
if err != nil {
logger.Warn("Plan template step has invalid operation", zap.String("step_id", id),

View File

@@ -47,10 +47,11 @@ func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayIn
continue
}
for _, entry := range list {
if entry == nil || entry.ID == "" {
key := model.GatewayDescriptorIdentityKey(entry)
if key == "" {
continue
}
items[entry.ID] = entry
items[key] = entry
}
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
@@ -58,7 +59,7 @@ func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayIn
result = append(result, entry)
}
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
return model.LessGatewayDescriptor(result[i], result[j])
})
return result, nil
}

View File

@@ -57,7 +57,7 @@ func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInst
})
}
sort.Slice(items, func(i, j int) bool {
return items[i].ID < items[j].ID
return model.LessGatewayDescriptor(items[i], items[j])
})
return items, nil
}

View File

@@ -31,14 +31,11 @@ func NewGatewayRegistry(logger mlogger.Logger, static []*model.GatewayInstanceDe
func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) {
items := map[string]*model.GatewayInstanceDescriptor{}
for _, gw := range r.static {
if gw == nil {
key := model.GatewayDescriptorIdentityKey(gw)
if key == "" {
continue
}
id := strings.TrimSpace(gw.ID)
if id == "" {
continue
}
items[id] = cloneGatewayDescriptor(gw)
items[key] = cloneGatewayDescriptor(gw)
}
result := make([]*model.GatewayInstanceDescriptor, 0, len(items))
@@ -46,7 +43,7 @@ func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDes
result = append(result, gw)
}
sort.Slice(result, func(i, j int) bool {
return result[i].ID < result[j].ID
return model.LessGatewayDescriptor(result[i], result[j])
})
return result, nil
}

View File

@@ -0,0 +1,72 @@
package quotation
import (
"context"
"testing"
"github.com/tech/sendico/payments/storage/model"
)
type identityGatewayRegistryStub struct {
items []*model.GatewayInstanceDescriptor
}
func (s identityGatewayRegistryStub) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
return s.items, nil
}
func TestGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) {
registry := NewGatewayRegistry(nil, []*model.GatewayInstanceDescriptor{
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"},
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"},
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a-new"},
})
if registry == nil {
t.Fatalf("expected registry to be created")
}
items, err := registry.List(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := len(items), 2; got != want {
t.Fatalf("unexpected items count: got=%d want=%d", got, want)
}
if got, want := items[0].InstanceID, "inst-a"; got != want {
t.Fatalf("unexpected first instance id: got=%q want=%q", got, want)
}
if got, want := items[0].InvokeURI, "grpc://a-new"; got != want {
t.Fatalf("expected latest duplicate to win for same gateway+instance: got=%q want=%q", got, want)
}
if got, want := items[1].InstanceID, "inst-b"; got != want {
t.Fatalf("unexpected second instance id: got=%q want=%q", got, want)
}
}
func TestCompositeGatewayRegistry_ListKeepsDistinctInstancesPerGatewayID(t *testing.T) {
registry := NewCompositeGatewayRegistry(nil,
identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-b", InvokeURI: "grpc://b"},
}},
identityGatewayRegistryStub{items: []*model.GatewayInstanceDescriptor{
{ID: "crypto_rail_gateway_tron", InstanceID: "inst-a", InvokeURI: "grpc://a"},
}},
)
if registry == nil {
t.Fatalf("expected registry to be created")
}
items, err := registry.List(context.Background())
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got, want := len(items), 2; got != want {
t.Fatalf("unexpected items count: got=%d want=%d", got, want)
}
if got, want := items[0].InstanceID, "inst-a"; got != want {
t.Fatalf("unexpected first instance id: got=%q want=%q", got, want)
}
if got, want := items[1].InstanceID, "inst-b"; got != want {
t.Fatalf("unexpected second instance id: got=%q want=%q", got, want)
}
}

View File

@@ -91,9 +91,6 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
if entry.Rail != rail {
continue
}
if instanceID != "" && !strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) {
continue
}
ok := true
for _, action := range actions {
if err := isGatewayEligible(entry, rail, network, currency, action, dir, amt); err != nil {
@@ -117,6 +114,13 @@ func selectGatewayForActions(ctx context.Context, registry GatewayRegistry, rail
sort.Slice(eligible, func(i, j int) bool {
return eligible[i].ID < eligible[j].ID
})
if instanceID != "" {
for _, entry := range eligible {
if strings.EqualFold(strings.TrimSpace(entry.InstanceID), strings.TrimSpace(instanceID)) {
return entry, nil
}
}
}
return eligible[0], nil
}

View File

@@ -0,0 +1,76 @@
package graph_path_finder
import (
"sort"
"strings"
"github.com/tech/sendico/payments/storage/model"
)
func buildAdjacency(edges []Edge, network string) map[model.Rail][]normalizedEdge {
adjacency := map[model.Rail][]normalizedEdge{}
for _, edge := range edges {
from := normalizeRail(edge.FromRail)
to := normalizeRail(edge.ToRail)
if from == model.RailUnspecified || to == model.RailUnspecified {
continue
}
en := normalizeNetwork(edge.Network)
if !matchesNetwork(en, network) {
continue
}
adjacency[from] = append(adjacency[from], normalizedEdge{
from: from,
to: to,
network: en,
})
}
for from := range adjacency {
sort.Slice(adjacency[from], func(i, j int) bool {
left := adjacency[from][i]
right := adjacency[from][j]
if left.to != right.to {
return left.to < right.to
}
lp := networkPriority(left.network, network)
rp := networkPriority(right.network, network)
if lp != rp {
return lp < rp
}
return left.network < right.network
})
}
return adjacency
}
func matchesNetwork(edgeNetwork, requested string) bool {
if requested == "" {
return true
}
if edgeNetwork == "" {
return true
}
return edgeNetwork == requested
}
func networkPriority(edgeNetwork, requested string) int {
if requested != "" && edgeNetwork == requested {
return 0
}
if edgeNetwork == "" {
return 1
}
return 2
}
func normalizeRail(value model.Rail) model.Rail {
normalized := model.Rail(strings.ToUpper(strings.TrimSpace(string(value))))
if normalized == "" {
return model.RailUnspecified
}
return normalized
}
func normalizeNetwork(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}

View File

@@ -0,0 +1,24 @@
package graph_path_finder
import "github.com/tech/sendico/payments/storage/model"
// Edge describes a directed route transition between rails.
type Edge struct {
FromRail model.Rail
ToRail model.Rail
Network string
}
// FindInput defines the graph query for path discovery.
type FindInput struct {
SourceRail model.Rail
DestinationRail model.Rail
Network string
Edges []Edge
}
// Path is an ordered list of rails and transitions selected by path finder.
type Path struct {
Rails []model.Rail
Edges []Edge
}

View File

@@ -0,0 +1,120 @@
package graph_path_finder
import "github.com/tech/sendico/payments/storage/model"
func shortestPath(
source model.Rail,
destination model.Rail,
network string,
adjacency map[model.Rail][]normalizedEdge,
) (*Path, bool) {
best := map[model.Rail]score{
source: {hops: 0, wildcardHops: 0, signature: string(source)},
}
parent := map[model.Rail]model.Rail{}
parentEdge := map[model.Rail]normalizedEdge{}
visited := map[model.Rail]bool{}
for {
current, ok := nextRail(best, visited)
if !ok {
break
}
if current == destination {
break
}
visited[current] = true
currentScore := best[current]
for _, edge := range adjacency[current] {
candidate := score{
hops: currentScore.hops + 1,
wildcardHops: currentScore.wildcardHops,
signature: currentScore.signature + ">" + string(edge.to),
}
if network != "" && edge.network == "" {
candidate.wildcardHops++
}
prev, seen := best[edge.to]
if !seen || better(candidate, prev) {
best[edge.to] = candidate
parent[edge.to] = current
parentEdge[edge.to] = edge
}
}
}
if _, ok := best[destination]; !ok {
return nil, false
}
rails := []model.Rail{destination}
edges := make([]Edge, 0, len(rails))
for cursor := destination; cursor != source; {
prev, hasParent := parent[cursor]
edge, hasEdge := parentEdge[cursor]
if !hasParent || !hasEdge {
return nil, false
}
rails = append(rails, prev)
edges = append(edges, Edge{
FromRail: edge.from,
ToRail: edge.to,
Network: edge.network,
})
cursor = prev
}
reverseRails(rails)
reverseEdges(edges)
return &Path{
Rails: rails,
Edges: edges,
}, true
}
func nextRail(best map[model.Rail]score, visited map[model.Rail]bool) (model.Rail, bool) {
selected := model.RailUnspecified
selectedScore := score{}
found := false
for rail, railScore := range best {
if visited[rail] {
continue
}
if !found || better(railScore, selectedScore) || (equal(railScore, selectedScore) && rail < selected) {
found = true
selected = rail
selectedScore = railScore
}
}
return selected, found
}
func better(left, right score) bool {
if left.hops != right.hops {
return left.hops < right.hops
}
if left.wildcardHops != right.wildcardHops {
return left.wildcardHops < right.wildcardHops
}
return left.signature < right.signature
}
func equal(left, right score) bool {
return left.hops == right.hops &&
left.wildcardHops == right.wildcardHops &&
left.signature == right.signature
}
func reverseRails(items []model.Rail) {
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
items[left], items[right] = items[right], items[left]
}
}
func reverseEdges(items []Edge) {
for left, right := 0, len(items)-1; left < right; left, right = left+1, right-1 {
items[left], items[right] = items[right], items[left]
}
}

View File

@@ -0,0 +1,50 @@
package graph_path_finder
import (
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
type GraphPathFinder struct{}
func New() *GraphPathFinder {
return &GraphPathFinder{}
}
type normalizedEdge struct {
from model.Rail
to model.Rail
network string
}
type score struct {
hops int
wildcardHops int
signature string
}
func (f *GraphPathFinder) Find(in FindInput) (*Path, error) {
source := normalizeRail(in.SourceRail)
destination := normalizeRail(in.DestinationRail)
if source == model.RailUnspecified {
return nil, merrors.InvalidArgument("source_rail is required")
}
if destination == model.RailUnspecified {
return nil, merrors.InvalidArgument("destination_rail is required")
}
if source == destination {
return &Path{Rails: []model.Rail{source}}, nil
}
network := normalizeNetwork(in.Network)
adjacency := buildAdjacency(in.Edges, network)
if len(adjacency) == 0 {
return nil, merrors.InvalidArgument("route graph has no usable edges")
}
path, ok := shortestPath(source, destination, network, adjacency)
if !ok {
return nil, merrors.InvalidArgument("route path is unavailable")
}
return path, nil
}

View File

@@ -0,0 +1,92 @@
package graph_path_finder
import (
"testing"
"github.com/tech/sendico/payments/storage/model"
)
func TestFind_NetworkFiltersEdges(t *testing.T) {
finder := New()
path, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCardPayout,
Network: "TRON",
Edges: []Edge{
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "ETH"},
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON"},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout, Network: "TRON"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
if got, want := path.Edges[0].Network, "TRON"; got != want {
t.Fatalf("unexpected first edge network: got=%q want=%q", got, want)
}
}
func TestFind_PrefersExactNetworkOverWildcard(t *testing.T) {
finder := New()
path, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCardPayout,
Network: "TRON",
Edges: []Edge{
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: ""},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout, Network: ""},
{FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON"},
{FromRail: model.RailProviderSettlement, ToRail: model.RailCardPayout, Network: "TRON"},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "PROVIDER_SETTLEMENT", "CARD_PAYOUT"})
}
func TestFind_DeterministicTieBreak(t *testing.T) {
finder := New()
path, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCardPayout,
Edges: []Edge{
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
{FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement},
{FromRail: model.RailProviderSettlement, ToRail: model.RailCardPayout},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Both routes have equal length; lexical tie-break chooses LEDGER branch.
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
}
func TestFind_IgnoresInvalidEdges(t *testing.T) {
finder := New()
path, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCardPayout,
Edges: []Edge{
{FromRail: model.RailUnspecified, ToRail: model.RailLedger},
{FromRail: model.RailCrypto, ToRail: model.RailUnspecified},
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
}

View File

@@ -0,0 +1,153 @@
package graph_path_finder
import (
"errors"
"testing"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func TestFind_ValidatesInput(t *testing.T) {
finder := New()
_, err := finder.Find(FindInput{
DestinationRail: model.RailCardPayout,
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for missing source rail, got %v", err)
}
_, err = finder.Find(FindInput{
SourceRail: model.RailCrypto,
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for missing destination rail, got %v", err)
}
}
func TestFind_SourceEqualsDestination(t *testing.T) {
finder := New()
path, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCrypto,
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if path == nil {
t.Fatalf("expected path")
}
if got, want := railsToStrings(path.Rails), []string{"CRYPTO"}; !equalStrings(got, want) {
t.Fatalf("unexpected rails: got=%v want=%v", got, want)
}
if len(path.Edges) != 0 {
t.Fatalf("expected no edges for same source and destination")
}
}
func TestFind_FindsIndirectPath(t *testing.T) {
finder := New()
path, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCardPayout,
Edges: []Edge{
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
if got, want := len(path.Edges), 2; got != want {
t.Fatalf("unexpected edge count: got=%d want=%d", got, want)
}
}
func TestFind_PrefersShortestPath(t *testing.T) {
finder := New()
path, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCardPayout,
Edges: []Edge{
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout},
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "CARD_PAYOUT"})
}
func TestFind_HandlesCycles(t *testing.T) {
finder := New()
path, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCardPayout,
Edges: []Edge{
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
{FromRail: model.RailLedger, ToRail: model.RailCrypto},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout},
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
}
func TestFind_ReturnsErrorWhenPathUnavailable(t *testing.T) {
finder := New()
_, err := finder.Find(FindInput{
SourceRail: model.RailCrypto,
DestinationRail: model.RailCardPayout,
Edges: []Edge{
{FromRail: model.RailCrypto, ToRail: model.RailLedger},
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for unavailable path, got %v", err)
}
}
func assertPathRails(t *testing.T, path *Path, want []string) {
t.Helper()
if path == nil {
t.Fatalf("expected non-nil path")
}
got := railsToStrings(path.Rails)
if !equalStrings(got, want) {
t.Fatalf("unexpected rails: got=%v want=%v", got, want)
}
}
func railsToStrings(rails []model.Rail) []string {
result := make([]string, 0, len(rails))
for _, rail := range rails {
result = append(result, string(rail))
}
return result
}
func equalStrings(got, want []string) bool {
if len(got) != len(want) {
return false
}
for i := range got {
if got[i] != want[i] {
return false
}
}
return true
}

View File

@@ -93,17 +93,18 @@ func cloneStoredPaymentPlan(src *model.PaymentPlan) *model.PaymentPlan {
continue
}
stepClone := &model.PaymentStep{
StepID: strings.TrimSpace(step.StepID),
Rail: step.Rail,
GatewayID: strings.TrimSpace(step.GatewayID),
InstanceID: strings.TrimSpace(step.InstanceID),
Action: step.Action,
DependsOn: cloneStringList(step.DependsOn),
CommitPolicy: step.CommitPolicy,
CommitAfter: cloneStringList(step.CommitAfter),
Amount: cloneMoney(step.Amount),
FromRole: shared.CloneAccountRole(step.FromRole),
ToRole: shared.CloneAccountRole(step.ToRole),
StepID: strings.TrimSpace(step.StepID),
Rail: step.Rail,
GatewayID: strings.TrimSpace(step.GatewayID),
InstanceID: strings.TrimSpace(step.InstanceID),
GatewayInvokeURI: strings.TrimSpace(step.GatewayInvokeURI),
Action: step.Action,
DependsOn: cloneStringList(step.DependsOn),
CommitPolicy: step.CommitPolicy,
CommitAfter: cloneStringList(step.CommitAfter),
Amount: cloneMoney(step.Amount),
FromRole: shared.CloneAccountRole(step.FromRole),
ToRole: shared.CloneAccountRole(step.ToRole),
}
clone.Steps = append(clone.Steps, stepClone)
}

View File

@@ -18,34 +18,27 @@ import (
func statusInputFromStatus(status quote_response_mapper_v2.QuoteStatus) *quote_persistence_service.StatusInput {
return &quote_persistence_service.StatusInput{
Kind: status.Kind,
Lifecycle: status.Lifecycle,
Executable: cloneBool(status.Executable),
State: status.State,
BlockReason: status.BlockReason,
}
}
func statusFromStored(input *model.QuoteStatusV2) quote_response_mapper_v2.QuoteStatus {
if input == nil {
status := quote_response_mapper_v2.QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
return quote_response_mapper_v2.QuoteStatus{
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
}
status.Executable = boolPtr(true)
return status
}
status := quote_response_mapper_v2.QuoteStatus{
Kind: quoteKindToProto(input.Kind),
Lifecycle: quoteLifecycleToProto(input.Lifecycle),
Executable: cloneBool(input.Executable),
State: quoteStateToProto(input.State),
BlockReason: quoteBlockReasonToProto(input.BlockReason),
}
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED {
status.Kind = quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
if status.State == quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED {
status.State = quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE
}
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED {
status.Lifecycle = quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
if status.State != quotationv2.QuoteState_QUOTE_STATE_BLOCKED {
status.BlockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
}
return status
}
@@ -191,25 +184,18 @@ func sideToProto(side paymenttypes.FXSide) fxv1.Side {
}
}
func quoteKindToProto(kind model.QuoteKind) quotationv2.QuoteKind {
switch kind {
case model.QuoteKindExecutable:
return quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
case model.QuoteKindIndicative:
return quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE
func quoteStateToProto(state model.QuoteState) quotationv2.QuoteState {
switch state {
case model.QuoteStateIndicative:
return quotationv2.QuoteState_QUOTE_STATE_INDICATIVE
case model.QuoteStateExecutable:
return quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE
case model.QuoteStateBlocked:
return quotationv2.QuoteState_QUOTE_STATE_BLOCKED
case model.QuoteStateExpired:
return quotationv2.QuoteState_QUOTE_STATE_EXPIRED
default:
return quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED
}
}
func quoteLifecycleToProto(lifecycle model.QuoteLifecycle) quotationv2.QuoteLifecycle {
switch lifecycle {
case model.QuoteLifecycleActive:
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
case model.QuoteLifecycleExpired:
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED
default:
return quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED
return quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED
}
}
@@ -233,11 +219,3 @@ func quoteBlockReasonToProto(reason model.QuoteBlockReason) quotationv2.QuoteBlo
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
}
}
func cloneBool(src *bool) *bool {
if src == nil {
return nil
}
value := *src
return &value
}

View File

@@ -7,11 +7,6 @@ import (
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func boolPtr(value bool) *bool {
v := value
return &v
}
func minExpiry(values []time.Time) (time.Time, bool) {
var min time.Time
for _, value := range values {

View File

@@ -4,6 +4,7 @@ import (
"strings"
paymenttypes "github.com/tech/sendico/pkg/payments/types"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
@@ -15,8 +16,7 @@ func modelRouteFromProto(src *quotationv2.RouteSpecification) *paymenttypes.Quot
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Settlement: modelSettlementFromProto(src.GetSettlement()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
@@ -51,11 +51,10 @@ func protoRouteFromModel(src *paymenttypes.QuoteRouteSpecification) *quotationv2
Rail: strings.TrimSpace(src.Rail),
Provider: strings.TrimSpace(src.Provider),
PayoutMethod: strings.TrimSpace(src.PayoutMethod),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.SettlementAsset)),
SettlementModel: strings.TrimSpace(src.SettlementModel),
Network: strings.TrimSpace(src.Network),
RouteRef: strings.TrimSpace(src.RouteRef),
PricingProfileRef: strings.TrimSpace(src.PricingProfileRef),
Settlement: protoSettlementFromModel(src.Settlement),
}
if len(src.Hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(src.Hops))
@@ -79,6 +78,52 @@ func protoRouteFromModel(src *paymenttypes.QuoteRouteSpecification) *quotationv2
return result
}
func modelSettlementFromProto(src *quotationv2.RouteSettlement) *paymenttypes.QuoteRouteSettlement {
if src == nil {
return nil
}
result := &paymenttypes.QuoteRouteSettlement{
Model: strings.TrimSpace(src.GetModel()),
}
if asset := src.GetAsset(); asset != nil {
key := asset.GetKey()
result.Asset = &paymenttypes.Asset{
Chain: strings.ToUpper(strings.TrimSpace(key.GetChain())),
TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
ContractAddress: strings.TrimSpace(asset.GetContractAddress()),
}
}
if result.Asset == nil && result.Model == "" {
return nil
}
return result
}
func protoSettlementFromModel(src *paymenttypes.QuoteRouteSettlement) *quotationv2.RouteSettlement {
if src == nil {
return nil
}
result := &quotationv2.RouteSettlement{
Model: strings.TrimSpace(src.Model),
}
if src.Asset != nil {
result.Asset = &paymentv1.ChainAsset{
Key: &paymentv1.ChainAssetKey{
Chain: strings.ToUpper(strings.TrimSpace(src.Asset.Chain)),
TokenSymbol: strings.ToUpper(strings.TrimSpace(src.Asset.TokenSymbol)),
},
}
if contract := strings.TrimSpace(src.Asset.ContractAddress); contract != "" {
result.Asset.ContractAddress = &contract
}
}
if result.Asset == nil && result.Model == "" {
return nil
}
return result
}
func modelExecutionConditionsFromProto(src *quotationv2.ExecutionConditions) *paymenttypes.QuoteExecutionConditions {
if src == nil {
return nil

View File

@@ -115,13 +115,6 @@ func (s *QuotationServiceV2) validateDependencies() error {
return nil
}
func quoteKindForPreview(previewOnly bool) quotationv2.QuoteKind {
if previewOnly {
return quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE
}
return quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE
}
func normalizeQuoteRef(value string) string {
return strings.TrimSpace(value)
}

View File

@@ -18,6 +18,7 @@ import (
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
endpointv1 "github.com/tech/sendico/pkg/proto/payments/endpoint/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
@@ -67,14 +68,11 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
if got, want := quote.GetQuoteRef(), "quote-single-usdt-rub"; got != want {
t.Fatalf("unexpected quote_ref: got=%q want=%q", got, want)
}
if got, want := quote.GetKind(), quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE; got != want {
t.Fatalf("unexpected kind: got=%s want=%s", got.String(), want.String())
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
if got, want := quote.GetLifecycle(), quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE; got != want {
t.Fatalf("unexpected lifecycle: got=%s want=%s", got.String(), want.String())
}
if !quote.GetExecutable() {
t.Fatalf("expected executable=true")
if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
t.Fatalf("expected empty block reason, got=%s", got.String())
}
if got, want := quote.GetDebitAmount().GetAmount(), "100"; got != want {
t.Fatalf("unexpected debit amount: got=%q want=%q", got, want)
@@ -118,11 +116,11 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
if quote.GetRoute() == nil {
t.Fatalf("expected route specification")
}
if got, want := quote.GetRoute().GetRail(), "CARD_PAYOUT"; got != want {
t.Fatalf("unexpected route rail: got=%q want=%q", got, want)
if got := strings.TrimSpace(quote.GetRoute().GetRail()); got != "" {
t.Fatalf("expected route rail header to be empty, got=%q", got)
}
if got, want := quote.GetRoute().GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected route provider: got=%q want=%q", got, want)
if got := strings.TrimSpace(quote.GetRoute().GetProvider()); got != "" {
t.Fatalf("expected route provider header to be empty, got=%q", got)
}
if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" {
t.Fatalf("expected route_ref")
@@ -133,6 +131,12 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
t.Fatalf("expected route settlement asset")
}
if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
}
if quote.GetExecutionConditions() == nil {
t.Fatalf("expected execution conditions")
}
@@ -157,8 +161,8 @@ func TestQuotePayment_USDTToRUB_EndToEnd(t *testing.T) {
if got, want := len(reused.Response.GetQuote().GetFeeRules()), 1; got != want {
t.Fatalf("unexpected idempotent fee rules count: got=%d want=%d", got, want)
}
if got, want := reused.Response.GetQuote().GetRoute().GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected idempotent route provider: got=%q want=%q", got, want)
if got := strings.TrimSpace(reused.Response.GetQuote().GetRoute().GetProvider()); got != "" {
t.Fatalf("expected idempotent route provider header to be empty, got=%q", got)
}
t.Logf("single request:\n%s", mustProtoJSON(t, req))
@@ -217,8 +221,8 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
if quote.GetQuoteRef() != "quote-batch-usdt-rub" {
t.Fatalf("unexpected quote_ref for item %d: %q", i, quote.GetQuoteRef())
}
if !quote.GetExecutable() {
t.Fatalf("expected executable quote for item %d", i)
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
t.Fatalf("unexpected quote state for item %d: got=%s want=%s", i, got.String(), want.String())
}
if quote.GetDebitAmount().GetCurrency() != "USDT" {
t.Fatalf("unexpected debit currency for item %d: %q", i, quote.GetDebitAmount().GetCurrency())
@@ -229,8 +233,8 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
if quote.GetRoute() == nil {
t.Fatalf("expected route for item %d", i)
}
if got, want := quote.GetRoute().GetRail(), "CARD_PAYOUT"; got != want {
t.Fatalf("unexpected route rail for item %d: got=%q want=%q", i, got, want)
if got := strings.TrimSpace(quote.GetRoute().GetRail()); got != "" {
t.Fatalf("expected route rail header for item %d to be empty, got=%q", i, got)
}
if got := strings.TrimSpace(quote.GetRoute().GetRouteRef()); got == "" {
t.Fatalf("expected route_ref for item %d", i)
@@ -241,6 +245,12 @@ func TestQuotePayments_USDTToRUB_ThreeItems_EndToEnd(t *testing.T) {
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
t.Fatalf("unexpected route hops count for item %d: got=%d want=%d", i, got, want)
}
if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
t.Fatalf("expected route settlement asset for item %d", i)
}
if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
t.Fatalf("unexpected route settlement token for item %d: got=%q want=%q", i, got, want)
}
if quote.GetExecutionConditions() == nil {
t.Fatalf("expected execution conditions for item %d", i)
}
@@ -384,8 +394,8 @@ func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T)
if quote.GetRoute() == nil {
t.Fatalf("expected route")
}
if got, want := quote.GetRoute().GetProvider(), "payout-gw-1"; got != want {
t.Fatalf("unexpected selected provider: got=%q want=%q", got, want)
if got := strings.TrimSpace(quote.GetRoute().GetProvider()); got != "" {
t.Fatalf("expected route provider header to be empty, got=%q", got)
}
if got, want := len(quote.GetRoute().GetHops()), 3; got != want {
t.Fatalf("unexpected hops count: got=%d want=%d", got, want)
@@ -399,6 +409,12 @@ func TestQuotePayment_SelectsEligibleGatewaysAndIgnoresIrrelevant(t *testing.T)
if got, want := quote.GetRoute().GetHops()[2].GetGateway(), "payout-gw-1"; got != want {
t.Fatalf("unexpected destination hop gateway: got=%q want=%q", got, want)
}
if quote.GetRoute().GetSettlement() == nil || quote.GetRoute().GetSettlement().GetAsset() == nil {
t.Fatalf("expected route settlement asset")
}
if got, want := quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
}
if got, want := quote.GetTotalCost().GetAmount(), "102.4"; got != want {
t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want)
}
@@ -513,7 +529,7 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
Meta: map[string]string{
"component": "platform_fee",
"provider": strings.TrimSpace(in.Route.GetProvider()),
"provider": routeDestinationGateway(in.Route),
},
},
{
@@ -523,7 +539,7 @@ func (f *fakeQuoteCore) BuildQuote(_ context.Context, in quote_computation_servi
Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT,
Meta: map[string]string{
"component": "vat",
"provider": strings.TrimSpace(in.Route.GetProvider()),
"provider": routeDestinationGateway(in.Route),
},
},
},
@@ -567,14 +583,9 @@ func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.Rou
return nil
}
result := &quotationv2.RouteSpecification{
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
Settlement: cloneRouteSettlementForTest(src.GetSettlement()),
}
if hops := src.GetHops(); len(hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
@@ -598,6 +609,31 @@ func cloneRouteSpecForTest(src *quotationv2.RouteSpecification) *quotationv2.Rou
return result
}
func cloneRouteSettlementForTest(src *quotationv2.RouteSettlement) *quotationv2.RouteSettlement {
if src == nil {
return nil
}
result := &quotationv2.RouteSettlement{
Model: strings.TrimSpace(src.GetModel()),
}
if asset := src.GetAsset(); asset != nil {
key := asset.GetKey()
result.Asset = &paymentv1.ChainAsset{
Key: &paymentv1.ChainAssetKey{
Chain: strings.ToUpper(strings.TrimSpace(key.GetChain())),
TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
},
}
if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
result.Asset.ContractAddress = &contract
}
}
if result.Asset == nil && result.Model == "" {
return nil
}
return result
}
func cloneExecutionConditionsForTest(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
if src == nil {
return nil
@@ -631,15 +667,28 @@ func routeFeeClass(route *quotationv2.RouteSpecification) string {
return ""
}
hops := route.GetHops()
destRail := ""
destGateway := ""
if n := len(hops); n > 0 && hops[n-1] != nil {
destRail = strings.ToLower(strings.TrimSpace(hops[n-1].GetRail()))
destGateway = strings.ToLower(strings.TrimSpace(hops[n-1].GetGateway()))
}
return strings.ToLower(strings.TrimSpace(route.GetRail())) +
return destRail +
":" + fmt.Sprintf("%d_hops", len(hops)) +
":" + destGateway
}
func routeDestinationGateway(route *quotationv2.RouteSpecification) string {
if route == nil {
return ""
}
hops := route.GetHops()
if n := len(hops); n > 0 && hops[n-1] != nil {
return strings.TrimSpace(hops[n-1].GetGateway())
}
return ""
}
type inMemoryQuotesStore struct {
byRef map[string]*model.PaymentQuoteRecord
byKey map[string]*model.PaymentQuoteRecord

View File

@@ -12,7 +12,6 @@ import (
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
type itemProcessDetail struct {
@@ -128,19 +127,10 @@ func (p *singleIntentProcessorV2) Process(
return nil, merrors.InvalidArgument("incomplete computation output")
}
kind := quoteKindForPreview(in.Context.PreviewOnly)
lifecycle := quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE
execution := p.classifier.BuildExecutionStatus(kind, lifecycle, result.BlockReason)
state := p.classifier.BuildState(in.Context.PreviewOnly, result.BlockReason)
status := quote_response_mapper_v2.QuoteStatus{
Kind: kind,
Lifecycle: lifecycle,
}
if execution.IsSet() {
if execution.IsExecutable() {
status.Executable = boolPtr(true)
} else {
status.BlockReason = execution.BlockReason()
}
State: state.State(),
BlockReason: state.BlockReason(),
}
canonical := quote_response_mapper_v2.CanonicalQuote{

View File

@@ -3,6 +3,7 @@ package quote_computation_service
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -71,8 +72,8 @@ func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) {
if item.Route == nil {
t.Fatalf("expected route specification")
}
if got, want := item.Route.GetRail(), "CARD_PAYOUT"; got != want {
t.Fatalf("unexpected route rail: got=%q want=%q", got, want)
if got := strings.TrimSpace(item.Route.GetRail()); got != "" {
t.Fatalf("expected route rail header to be empty, got %q", got)
}
if got := item.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
@@ -83,6 +84,12 @@ func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) {
if got, want := len(item.Route.GetHops()), 2; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if item.Route.GetSettlement() == nil || item.Route.GetSettlement().GetAsset() == nil {
t.Fatalf("expected route settlement asset")
}
if got, want := item.Route.GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
t.Fatalf("unexpected settlement asset token: got=%q want=%q", got, want)
}
if item.ExecutionConditions == nil {
t.Fatalf("expected execution conditions")
}
@@ -246,8 +253,8 @@ func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
if item.Route == nil {
t.Fatalf("expected route")
}
if got, want := item.Route.GetProvider(), "payout-gw-1"; got != want {
t.Fatalf("unexpected selected provider: got=%q want=%q", got, want)
if got := strings.TrimSpace(item.Route.GetProvider()); got != "" {
t.Fatalf("expected route provider header to be empty, got %q", got)
}
if got, want := len(item.Route.GetHops()), 3; got != want {
t.Fatalf("unexpected route hop count: got=%d want=%d", got, want)
@@ -258,6 +265,15 @@ func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
if got, want := item.Route.GetHops()[1].GetRole(), quotationv2.RouteHopRole_ROUTE_HOP_ROLE_TRANSIT; got != want {
t.Fatalf("unexpected bridge role: got=%s want=%s", got.String(), want.String())
}
if item.Route.GetSettlement() == nil || item.Route.GetSettlement().GetAsset() == nil {
t.Fatalf("expected route settlement asset")
}
if got, want := item.Route.GetSettlement().GetAsset().GetKey().GetChain(), "TRON"; got != want {
t.Fatalf("unexpected settlement asset chain: got=%q want=%q", got, want)
}
if got, want := item.Route.GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USDT"; got != want {
t.Fatalf("unexpected settlement asset token: got=%q want=%q", got, want)
}
if got := item.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
}
@@ -307,8 +323,8 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
if result.Quote.Route == nil {
t.Fatalf("expected route specification")
}
if got, want := result.Quote.Route.GetPayoutMethod(), "CARD"; got != want {
t.Fatalf("unexpected payout method: got=%q want=%q", got, want)
if got := strings.TrimSpace(result.Quote.Route.GetPayoutMethod()); got != "" {
t.Fatalf("expected payout method header to be empty, got %q", got)
}
if got := result.Quote.Route.GetRouteRef(); got == "" {
t.Fatalf("expected route_ref")
@@ -319,6 +335,12 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
if got, want := len(result.Quote.Route.GetHops()), 2; got != want {
t.Fatalf("unexpected route hops count: got=%d want=%d", got, want)
}
if result.Quote.Route.GetSettlement() == nil || result.Quote.Route.GetSettlement().GetAsset() == nil {
t.Fatalf("expected route settlement asset")
}
if got, want := result.Quote.Route.GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
t.Fatalf("unexpected route settlement token: got=%q want=%q", got, want)
}
if result.Quote.ExecutionConditions == nil {
t.Fatalf("expected execution conditions")
}
@@ -334,8 +356,12 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
if core.lastQuoteIn.Route == nil {
t.Fatalf("expected selected route to be passed into build quote input")
}
if got, want := core.lastQuoteIn.Route.GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected selected route provider in build input: got=%q want=%q", got, want)
hops := core.lastQuoteIn.Route.GetHops()
if got, want := len(hops), 2; got != want {
t.Fatalf("unexpected route hops in build input: got=%d want=%d", got, want)
}
if got, want := hops[1].GetGateway(), "monetix"; got != want {
t.Fatalf("unexpected destination gateway in build input route: got=%q want=%q", got, want)
}
if core.lastQuoteIn.ExecutionConditions == nil {
t.Fatalf("expected execution conditions to be passed into build quote input")

View File

@@ -7,6 +7,7 @@ import (
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
@@ -76,11 +77,10 @@ func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.R
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
Settlement: cloneRouteSettlement(src.GetSettlement()),
}
if hops := src.GetHops(); len(hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
@@ -96,6 +96,31 @@ func cloneRouteSpecification(src *quotationv2.RouteSpecification) *quotationv2.R
return result
}
func cloneRouteSettlement(src *quotationv2.RouteSettlement) *quotationv2.RouteSettlement {
if src == nil {
return nil
}
result := &quotationv2.RouteSettlement{
Model: strings.TrimSpace(src.GetModel()),
}
if asset := src.GetAsset(); asset != nil {
key := asset.GetKey()
result.Asset = &paymentv1.ChainAsset{
Key: &paymentv1.ChainAssetKey{
Chain: strings.ToUpper(strings.TrimSpace(key.GetChain())),
TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
},
}
if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
result.Asset.ContractAddress = &contract
}
}
if result.Asset == nil && result.Model == "" {
return nil
}
return result
}
func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
if src == nil {
return nil

View File

@@ -37,18 +37,16 @@ func (s *QuoteComputationService) resolveStepGateways(
}
}
sort.Slice(sorted, func(i, j int) bool {
return strings.TrimSpace(sorted[i].ID) < strings.TrimSpace(sorted[j].ID)
return model.LessGatewayDescriptor(sorted[i], sorted[j])
})
for idx, step := range steps {
if step == nil {
continue
}
if strings.TrimSpace(step.GatewayID) != "" {
continue
}
if step.Rail == model.RailLedger {
step.GatewayID = "internal"
step.GatewayInvokeURI = ""
continue
}
@@ -57,9 +55,8 @@ func (s *QuoteComputationService) resolveStepGateways(
return fmt.Errorf("Step[%d] %s: %w", idx, strings.TrimSpace(step.StepID), selectErr)
}
step.GatewayID = strings.TrimSpace(selected.ID)
if strings.TrimSpace(step.InstanceID) == "" {
step.InstanceID = strings.TrimSpace(selected.InstanceID)
}
step.InstanceID = strings.TrimSpace(selected.InstanceID)
step.GatewayInvokeURI = strings.TrimSpace(selected.InvokeURI)
}
return nil
@@ -89,20 +86,29 @@ func selectGatewayForStep(
direction := plan.SendDirectionForRail(step.Rail)
network := networkForGatewaySelection(step.Rail, routeNetwork)
eligible := make([]*model.GatewayInstanceDescriptor, 0, len(gateways))
var lastErr error
for _, gw := range gateways {
if gw == nil {
continue
}
if strings.TrimSpace(step.InstanceID) != "" &&
!strings.EqualFold(strings.TrimSpace(gw.InstanceID), strings.TrimSpace(step.InstanceID)) {
continue
}
if err := plan.IsGatewayEligible(gw, step.Rail, network, currency, action, direction, amount); err != nil {
lastErr = err
continue
}
return gw, nil
eligible = append(eligible, gw)
}
if selected, _ := model.SelectGatewayByPreference(
eligible,
step.GatewayID,
step.InstanceID,
step.GatewayInvokeURI,
); selected != nil {
return selected, nil
}
if len(eligible) > 0 {
return eligible[0], nil
}
if lastErr != nil {
@@ -160,6 +166,7 @@ func clearImplicitDestinationGateway(steps []*QuoteComputationStep) {
return
}
last.GatewayID = ""
last.GatewayInvokeURI = ""
}
func destinationGatewayFromSteps(steps []*QuoteComputationStep) string {

View File

@@ -0,0 +1,126 @@
package quote_computation_service
import (
"context"
"testing"
"github.com/tech/sendico/payments/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
type selectorGatewayRegistry struct {
items []*model.GatewayInstanceDescriptor
}
func (s selectorGatewayRegistry) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
return s.items, nil
}
func TestResolveStepGateways_FallsBackToInvokeURI(t *testing.T) {
svc := New(nil, WithGatewayRegistry(selectorGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "aaa",
InstanceID: "inst-a",
InvokeURI: "grpc://gw-a:50051",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "bbb",
InstanceID: "inst-b",
InvokeURI: "grpc://gw-b:50051",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
},
}))
steps := []*QuoteComputationStep{
{
StepID: "i0.destination",
Rail: model.RailCrypto,
Operation: model.RailOperationExternalCredit,
GatewayID: "legacy-id",
InstanceID: "legacy-instance",
GatewayInvokeURI: "grpc://gw-b:50051",
Amount: &moneyv1.Money{Currency: "USDT", Amount: "10"},
},
}
if err := svc.resolveStepGateways(context.Background(), steps, "TRON"); err != nil {
t.Fatalf("resolveStepGateways returned error: %v", err)
}
if got, want := steps[0].GatewayID, "bbb"; got != want {
t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want)
}
if got, want := steps[0].InstanceID, "inst-b"; got != want {
t.Fatalf("unexpected instance_id: got=%q want=%q", got, want)
}
if got, want := steps[0].GatewayInvokeURI, "grpc://gw-b:50051"; got != want {
t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want)
}
}
func TestResolveStepGateways_FallsBackToGatewayIDWhenInstanceChanges(t *testing.T) {
svc := New(nil, WithGatewayRegistry(selectorGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "aaa",
InstanceID: "inst-a",
InvokeURI: "grpc://gw-a:50051",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "crypto_rail_gateway_tron",
InstanceID: "inst-new",
InvokeURI: "grpc://gw-tron:50051",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
},
}))
steps := []*QuoteComputationStep{
{
StepID: "i0.destination",
Rail: model.RailCrypto,
Operation: model.RailOperationExternalCredit,
GatewayID: "crypto_rail_gateway_tron",
InstanceID: "inst-old",
Amount: &moneyv1.Money{Currency: "USDT", Amount: "10"},
},
}
if err := svc.resolveStepGateways(context.Background(), steps, "TRON"); err != nil {
t.Fatalf("resolveStepGateways returned error: %v", err)
}
if got, want := steps[0].GatewayID, "crypto_rail_gateway_tron"; got != want {
t.Fatalf("unexpected gateway_id: got=%q want=%q", got, want)
}
if got, want := steps[0].InstanceID, "inst-new"; got != want {
t.Fatalf("unexpected instance_id: got=%q want=%q", got, want)
}
if got, want := steps[0].GatewayInvokeURI, "grpc://gw-tron:50051"; got != want {
t.Fatalf("unexpected gateway_invoke_uri: got=%q want=%q", got, want)
}
}

View File

@@ -78,6 +78,7 @@ type QuoteComputationStep struct {
Operation model.RailOperation
GatewayID string
InstanceID string
GatewayInvokeURI string
DependsOn []string
Amount *moneyv1.Money
FromRole *account_role.AccountRole

View File

@@ -78,7 +78,7 @@ func (s *QuoteComputationService) buildPlanItem(
source := clonePaymentEndpoint(modelIntent.Source)
destination := clonePaymentEndpoint(modelIntent.Destination)
_, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
if err != nil {
return nil, err
}
@@ -91,7 +91,12 @@ func (s *QuoteComputationService) buildPlanItem(
return nil, err
}
steps := buildComputationSteps(index, modelIntent, destination)
routeRails, err := s.resolveRouteRails(ctx, sourceRail, destRail, firstNonEmpty(routeNetwork, destNetwork, sourceNetwork))
if err != nil {
return nil, err
}
steps := buildComputationSteps(index, modelIntent, destination, routeRails)
if modelIntent.Destination.Type == model.EndpointTypeCard &&
s.gatewayRegistry != nil &&
!hasExplicitDestinationGateway(modelIntent.Attributes) {
@@ -132,14 +137,11 @@ func (s *QuoteComputationService) buildPlanItem(
}
route := buildRouteSpecification(
modelIntent,
destination,
destRail,
firstNonEmpty(routeNetwork, destNetwork, sourceNetwork),
provider,
steps,
)
conditions, blockReason := buildExecutionConditions(in.PreviewOnly, steps, funding)
if route == nil || strings.TrimSpace(route.GetRail()) == "" || route.GetRail() == string(model.RailUnspecified) {
if route == nil || len(route.GetHops()) == 0 {
blockReason = quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE
}
quoteInput := BuildQuoteInput{

View File

@@ -0,0 +1,109 @@
package quote_computation_service
import (
"context"
"strings"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
)
func (s *QuoteComputationService) resolveRouteRails(
ctx context.Context,
sourceRail model.Rail,
destinationRail model.Rail,
network string,
) ([]model.Rail, error) {
if sourceRail == model.RailUnspecified {
return nil, merrors.InvalidArgument("source rail is required")
}
if destinationRail == model.RailUnspecified {
return nil, merrors.InvalidArgument("destination rail is required")
}
if sourceRail == destinationRail {
return []model.Rail{sourceRail}, nil
}
strictGraph := s != nil && s.routeStore != nil
edges, err := s.routeGraphEdges(ctx)
if err != nil {
return nil, err
}
if len(edges) == 0 {
if strictGraph {
return nil, merrors.InvalidArgument("route graph has no edges")
}
return fallbackRouteRails(sourceRail, destinationRail), nil
}
pathFinder := s.pathFinder
if pathFinder == nil {
pathFinder = graph_path_finder.New()
}
path, findErr := pathFinder.Find(graph_path_finder.FindInput{
SourceRail: sourceRail,
DestinationRail: destinationRail,
Network: strings.ToUpper(strings.TrimSpace(network)),
Edges: edges,
})
if findErr != nil {
if strictGraph {
return nil, findErr
}
return fallbackRouteRails(sourceRail, destinationRail), nil
}
if path == nil || len(path.Rails) == 0 {
if strictGraph {
return nil, merrors.InvalidArgument("route path is empty")
}
return fallbackRouteRails(sourceRail, destinationRail), nil
}
return append([]model.Rail(nil), path.Rails...), nil
}
func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_path_finder.Edge, error) {
if s == nil || s.routeStore == nil {
return nil, nil
}
enabled := true
routes, err := s.routeStore.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled})
if err != nil {
return nil, err
}
if routes == nil || len(routes.Items) == 0 {
return nil, nil
}
edges := make([]graph_path_finder.Edge, 0, len(routes.Items))
for _, route := range routes.Items {
if route == nil || !route.IsEnabled {
continue
}
from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail))))
to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail))))
if from == model.RailUnspecified || to == model.RailUnspecified {
continue
}
edges = append(edges, graph_path_finder.Edge{
FromRail: from,
ToRail: to,
Network: strings.ToUpper(strings.TrimSpace(route.Network)),
})
}
return edges, nil
}
func fallbackRouteRails(sourceRail, destinationRail model.Rail) []model.Rail {
if sourceRail == destinationRail {
return []model.Rail{sourceRail}
}
if requiresTransitBridgeStep(sourceRail, destinationRail) {
return []model.Rail{sourceRail, model.RailLedger, destinationRail}
}
return []model.Rail{sourceRail, destinationRail}
}

View File

@@ -0,0 +1,165 @@
package quote_computation_service
import (
"context"
"errors"
"strings"
"testing"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
"go.mongodb.org/mongo-driver/v2/bson"
)
func TestBuildPlan_UsesRouteGraphPath(t *testing.T) {
svc := New(nil,
WithRouteStore(staticRouteStore{items: []*model.PaymentRoute{
{FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON", IsEnabled: true},
{FromRail: model.RailProviderSettlement, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true},
}}),
WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-gw",
InstanceID: "crypto-gw",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "provider-gw",
InstanceID: "provider-gw",
Rail: model.RailProviderSettlement,
Network: "TRON",
Currencies: []string{"USDT"},
IsEnabled: true,
},
{
ID: "card-gw",
InstanceID: "card-gw",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
},
}),
)
orgID := bson.NewObjectID()
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-graph",
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil {
t.Fatalf("expected one plan item")
}
item := planModel.Items[0]
if got, want := len(item.Steps), 3; got != want {
t.Fatalf("unexpected step count: got=%d want=%d", got, want)
}
if got, want := string(item.Steps[1].Rail), string(model.RailProviderSettlement); got != want {
t.Fatalf("unexpected transit rail: got=%q want=%q", got, want)
}
if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "PROVIDER_SETTLEMENT" {
t.Fatalf("unexpected route transit hop rail: %q", got)
}
}
func TestBuildPlan_RouteGraphNoPathReturnsError(t *testing.T) {
svc := New(nil, WithRouteStore(staticRouteStore{items: []*model.PaymentRoute{
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON", IsEnabled: true},
}}))
orgID := bson.NewObjectID()
_, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-graph-no-path",
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument when no path exists in route graph, got %v", err)
}
}
func TestBuildPlan_RouteGraphPrefersDirectPath(t *testing.T) {
svc := New(nil,
WithRouteStore(staticRouteStore{items: []*model.PaymentRoute{
{FromRail: model.RailCrypto, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true},
{FromRail: model.RailCrypto, ToRail: model.RailLedger, Network: "TRON", IsEnabled: true},
{FromRail: model.RailLedger, ToRail: model.RailCardPayout, Network: "TRON", IsEnabled: true},
}}),
WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
{
ID: "crypto-gw",
InstanceID: "crypto-gw",
Rail: model.RailCrypto,
Network: "TRON",
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
{
ID: "card-gw",
InstanceID: "card-gw",
Rail: model.RailCardPayout,
Currencies: []string{"USDT"},
Capabilities: model.RailCapabilities{
CanPayOut: true,
},
IsEnabled: true,
},
},
}),
)
orgID := bson.NewObjectID()
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-graph-direct",
Intents: []*transfer_intent_hydrator.QuoteIntent{sampleCryptoToCardQuoteIntent()},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if planModel == nil || len(planModel.Items) != 1 || planModel.Items[0] == nil {
t.Fatalf("expected one plan item")
}
if got, want := len(planModel.Items[0].Steps), 2; got != want {
t.Fatalf("expected direct two-step path, got %d steps", got)
}
}
type staticRouteStore struct {
items []*model.PaymentRoute
}
func (s staticRouteStore) List(context.Context, *model.PaymentRouteFilter) (*model.PaymentRouteList, error) {
out := make([]*model.PaymentRoute, 0, len(s.items))
for _, item := range s.items {
if item == nil {
continue
}
cloned := *item
out = append(out, &cloned)
}
return &model.PaymentRouteList{Items: out}, nil
}

View File

@@ -11,6 +11,7 @@ func buildComputationSteps(
index int,
intent model.PaymentIntent,
destination model.PaymentEndpoint,
routeRails []model.Rail,
) []*QuoteComputationStep {
if intent.Amount == nil {
return nil
@@ -20,6 +21,7 @@ func buildComputationSteps(
amount := protoMoneyFromModel(intent.Amount)
sourceRail := sourceRailForIntent(intent)
destinationRail := destinationRailForIntent(intent)
rails := normalizeRouteRails(sourceRail, destinationRail, routeRails)
sourceGatewayID := strings.TrimSpace(lookupAttr(attrs,
"source_gateway",
"sourceGateway",
@@ -37,8 +39,8 @@ func buildComputationSteps(
steps := []*QuoteComputationStep{
{
StepID: sourceStepID,
Rail: sourceRail,
Operation: sourceOperationForRail(sourceRail),
Rail: rails[0],
Operation: sourceOperationForRail(rails[0]),
GatewayID: sourceGatewayID,
InstanceID: sourceInstanceID,
Amount: cloneProtoMoney(amount),
@@ -48,39 +50,54 @@ func buildComputationSteps(
}
lastStepID := sourceStepID
fxAssigned := false
if intent.RequiresFX {
fxStepID := fmt.Sprintf("i%d.fx", index)
steps = append(steps, &QuoteComputationStep{
StepID: fxStepID,
Rail: model.RailProviderSettlement,
Operation: model.RailOperationFXConvert,
DependsOn: []string{sourceStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
})
lastStepID = fxStepID
if len(rails) > 1 && rails[1] == model.RailProviderSettlement {
fxAssigned = true
} else {
fxStepID := fmt.Sprintf("i%d.fx", index)
steps = append(steps, &QuoteComputationStep{
StepID: fxStepID,
Rail: model.RailProviderSettlement,
Operation: model.RailOperationFXConvert,
DependsOn: []string{sourceStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
})
lastStepID = fxStepID
fxAssigned = true
}
}
if requiresTransitBridgeStep(sourceRail, destinationRail) {
bridgeStepID := fmt.Sprintf("i%d.bridge", index)
transitIndex := 1
for i := 1; i < len(rails)-1; i++ {
rail := rails[i]
stepID := fmt.Sprintf("i%d.transit%d", index, transitIndex)
operation := model.RailOperationMove
if intent.RequiresFX && !fxAssigned && rail == model.RailProviderSettlement {
stepID = fmt.Sprintf("i%d.fx", index)
operation = model.RailOperationFXConvert
fxAssigned = true
}
steps = append(steps, &QuoteComputationStep{
StepID: bridgeStepID,
Rail: model.RailLedger,
Operation: model.RailOperationMove,
StepID: stepID,
Rail: rail,
Operation: operation,
DependsOn: []string{lastStepID},
Amount: cloneProtoMoney(amount),
Optional: false,
IncludeInAggregate: false,
})
lastStepID = bridgeStepID
lastStepID = stepID
transitIndex++
}
destinationStepID := fmt.Sprintf("i%d.destination", index)
steps = append(steps, &QuoteComputationStep{
StepID: destinationStepID,
Rail: destinationRail,
Operation: destinationOperationForRail(destinationRail),
Rail: rails[len(rails)-1],
Operation: destinationOperationForRail(rails[len(rails)-1]),
GatewayID: destinationGatewayID,
InstanceID: destinationInstanceID,
DependsOn: []string{lastStepID},
@@ -92,6 +109,40 @@ func buildComputationSteps(
return steps
}
func normalizeRouteRails(sourceRail, destinationRail model.Rail, routeRails []model.Rail) []model.Rail {
if len(routeRails) == 0 {
if requiresTransitBridgeStep(sourceRail, destinationRail) {
return []model.Rail{sourceRail, model.RailLedger, destinationRail}
}
return []model.Rail{sourceRail, destinationRail}
}
result := make([]model.Rail, 0, len(routeRails))
for _, rail := range routeRails {
if rail == model.RailUnspecified {
continue
}
if len(result) > 0 && result[len(result)-1] == rail {
continue
}
result = append(result, rail)
}
if len(result) == 0 {
return []model.Rail{sourceRail, destinationRail}
}
if result[0] != sourceRail {
result = append([]model.Rail{sourceRail}, result...)
}
if result[len(result)-1] != destinationRail {
result = append(result, destinationRail)
}
if len(result) == 1 {
result = append(result, destinationRail)
}
return result
}
func requiresTransitBridgeStep(sourceRail, destinationRail model.Rail) bool {
if sourceRail == model.RailUnspecified || destinationRail == model.RailUnspecified {
return false

View File

@@ -27,17 +27,37 @@ func sameRouteSpecification(left, right *quotationv2.RouteSpecification) bool {
if left == nil || right == nil {
return left == right
}
return normalizeRail(left.GetRail()) == normalizeRail(right.GetRail()) &&
normalizeProvider(left.GetProvider()) == normalizeProvider(right.GetProvider()) &&
normalizePayoutMethod(left.GetPayoutMethod()) == normalizePayoutMethod(right.GetPayoutMethod()) &&
normalizeAsset(left.GetSettlementAsset()) == normalizeAsset(right.GetSettlementAsset()) &&
normalizeSettlementModel(left.GetSettlementModel()) == normalizeSettlementModel(right.GetSettlementModel()) &&
normalizeNetwork(left.GetNetwork()) == normalizeNetwork(right.GetNetwork()) &&
return sameRouteSettlement(left.GetSettlement(), right.GetSettlement()) &&
sameRouteReference(left.GetRouteRef(), right.GetRouteRef()) &&
samePricingProfileReference(left.GetPricingProfileRef(), right.GetPricingProfileRef()) &&
sameRouteHops(left.GetHops(), right.GetHops())
}
func sameRouteSettlement(
left *quotationv2.RouteSettlement,
right *quotationv2.RouteSettlement,
) bool {
leftChain, leftToken, leftContract, leftModel := normalizeSettlementParts(left)
rightChain, rightToken, rightContract, rightModel := normalizeSettlementParts(right)
return leftChain == rightChain &&
leftToken == rightToken &&
leftContract == rightContract &&
leftModel == rightModel
}
func normalizeSettlementParts(src *quotationv2.RouteSettlement) (chain, token, contract, model string) {
if src != nil {
if asset := src.GetAsset(); asset != nil {
key := asset.GetKey()
chain = normalizeAsset(key.GetChain())
token = normalizeAsset(key.GetTokenSymbol())
contract = strings.TrimSpace(asset.GetContractAddress())
}
model = normalizeSettlementModel(src.GetModel())
}
return chain, token, contract, model
}
func normalizeRail(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
}

View File

@@ -0,0 +1,93 @@
package quote_computation_service
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func buildRouteSettlement(
intent model.PaymentIntent,
network string,
hops []*quotationv2.RouteHop,
) *quotationv2.RouteSettlement {
modelValue := normalizeSettlementModel(settlementModelString(intent.SettlementMode))
asset := buildRouteSettlementAsset(intent, network, hops)
if asset == nil && modelValue == "" {
return nil
}
return &quotationv2.RouteSettlement{
Asset: asset,
Model: modelValue,
}
}
func buildRouteSettlementAsset(
intent model.PaymentIntent,
network string,
hops []*quotationv2.RouteHop,
) *paymentv1.ChainAsset {
chain, token, contract := settlementAssetFromIntent(intent)
if token == "" {
token = normalizeAsset(intent.SettlementCurrency)
}
if token == "" && intent.Amount != nil {
token = normalizeAsset(intent.Amount.GetCurrency())
}
if chain == "" {
chain = normalizeAsset(firstNonEmpty(network, routeNetworkFromHops(hops)))
}
if chain == "" && token == "" && contract == "" {
return nil
}
asset := &paymentv1.ChainAsset{
Key: &paymentv1.ChainAssetKey{
Chain: chain,
TokenSymbol: token,
},
}
if contract != "" {
asset.ContractAddress = &contract
}
return asset
}
func settlementAssetFromIntent(intent model.PaymentIntent) (chain, token, contract string) {
candidates := []*model.PaymentEndpoint{
&intent.Source,
&intent.Destination,
}
for _, endpoint := range candidates {
if endpoint == nil {
continue
}
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
return normalizedAssetFields(endpoint.ManagedWallet.Asset.Chain, endpoint.ManagedWallet.Asset.TokenSymbol, endpoint.ManagedWallet.Asset.ContractAddress)
}
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
return normalizedAssetFields(endpoint.ExternalChain.Asset.Chain, endpoint.ExternalChain.Asset.TokenSymbol, endpoint.ExternalChain.Asset.ContractAddress)
}
}
return "", "", ""
}
func normalizedAssetFields(chain, token, contract string) (string, string, string) {
return normalizeAsset(chain), normalizeAsset(token), strings.TrimSpace(contract)
}
func routeNetworkFromHops(hops []*quotationv2.RouteHop) string {
for _, hop := range hops {
if hop == nil {
continue
}
network := strings.TrimSpace(hop.GetNetwork())
if network != "" {
return network
}
}
return ""
}

View File

@@ -14,27 +14,13 @@ import (
func buildRouteSpecification(
intent model.PaymentIntent,
destination model.PaymentEndpoint,
destinationRail model.Rail,
network string,
provider string,
steps []*QuoteComputationStep,
) *quotationv2.RouteSpecification {
hops := buildRouteHops(steps, network)
if strings.TrimSpace(provider) == "" {
provider = providerFromHops(hops)
}
route := &quotationv2.RouteSpecification{
Rail: normalizeRail(string(destinationRail)),
Provider: normalizeProvider(provider),
PayoutMethod: normalizePayoutMethod(payoutMethodFromEndpoint(destination)),
SettlementAsset: normalizeAsset(intent.SettlementCurrency),
SettlementModel: normalizeSettlementModel(settlementModelString(intent.SettlementMode)),
Network: normalizeNetwork(network),
Hops: hops,
}
if route.SettlementAsset == "" && intent.Amount != nil {
route.SettlementAsset = normalizeAsset(intent.Amount.GetCurrency())
Settlement: buildRouteSettlement(intent, network, hops),
Hops: hops,
}
route.RouteRef = buildRouteReference(route)
route.PricingProfileRef = buildPricingProfileReference(route)
@@ -88,21 +74,6 @@ func buildExecutionConditions(
return conditions, blockReason
}
func payoutMethodFromEndpoint(endpoint model.PaymentEndpoint) string {
switch endpoint.Type {
case model.EndpointTypeCard:
return "CARD"
case model.EndpointTypeExternalChain:
return "CRYPTO_ADDRESS"
case model.EndpointTypeManagedWallet:
return "MANAGED_WALLET"
case model.EndpointTypeLedger:
return "LEDGER"
default:
return "UNSPECIFIED"
}
}
func settlementModelString(mode model.SettlementMode) string {
switch mode {
case model.SettlementModeFixSource:
@@ -164,18 +135,6 @@ func roleForHopIndex(index, last int) quotationv2.RouteHopRole {
}
}
func providerFromHops(hops []*quotationv2.RouteHop) string {
for i := len(hops) - 1; i >= 0; i-- {
if hops[i] == nil {
continue
}
if gateway := normalizeProvider(hops[i].GetGateway()); gateway != "" {
return gateway
}
}
return ""
}
func buildRouteReference(route *quotationv2.RouteSpecification) string {
signature := routeTopologySignature(route, true)
if signature == "" {
@@ -198,13 +157,23 @@ func routeTopologySignature(route *quotationv2.RouteSpecification, includeInstan
if route == nil {
return ""
}
parts := []string{
normalizeRail(route.GetRail()),
normalizeProvider(route.GetProvider()),
normalizePayoutMethod(route.GetPayoutMethod()),
normalizeAsset(route.GetSettlementAsset()),
normalizeSettlementModel(route.GetSettlementModel()),
normalizeNetwork(route.GetNetwork()),
parts := make([]string, 0, 8)
if settlement := route.GetSettlement(); settlement != nil {
if asset := settlement.GetAsset(); asset != nil {
key := asset.GetKey()
if chain := normalizeAsset(key.GetChain()); chain != "" {
parts = append(parts, chain)
}
if token := normalizeAsset(key.GetTokenSymbol()); token != "" {
parts = append(parts, token)
}
if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
parts = append(parts, strings.ToLower(contract))
}
}
if model := normalizeSettlementModel(settlement.GetModel()); model != "" {
parts = append(parts, model)
}
}
hops := route.GetHops()
@@ -232,5 +201,8 @@ func routeTopologySignature(route *quotationv2.RouteSpecification, includeInstan
parts = append(parts, strings.Join(hopParts, ":"))
}
}
if len(parts) == 0 {
return ""
}
return strings.Join(parts, "|")
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/payments/quotation/internal/service/plan"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/gateway_funding_profile"
"github.com/tech/sendico/payments/quotation/internal/service/quotation/graph_path_finder"
)
type Core interface {
@@ -18,11 +19,14 @@ type QuoteComputationService struct {
core Core
fundingResolver gateway_funding_profile.FundingProfileResolver
gatewayRegistry plan.GatewayRegistry
routeStore plan.RouteStore
pathFinder *graph_path_finder.GraphPathFinder
}
func New(core Core, opts ...Option) *QuoteComputationService {
svc := &QuoteComputationService{
core: core,
core: core,
pathFinder: graph_path_finder.New(),
}
for _, opt := range opts {
if opt != nil {
@@ -47,3 +51,19 @@ func WithGatewayRegistry(registry plan.GatewayRegistry) Option {
}
}
}
func WithRouteStore(store plan.RouteStore) Option {
return func(svc *QuoteComputationService) {
if svc != nil {
svc.routeStore = store
}
}
}
func WithPathFinder(pathFinder *graph_path_finder.GraphPathFinder) Option {
return func(svc *QuoteComputationService) {
if svc != nil && pathFinder != nil {
svc.pathFinder = pathFinder
}
}
}

View File

@@ -87,42 +87,28 @@ func Extract(err error) (quotationv2.QuoteBlockReason, bool) {
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED, false
}
type ExecutionStatus struct {
set bool
executable bool
type QuoteState struct {
state quotationv2.QuoteState
blockReason quotationv2.QuoteBlockReason
}
func (s ExecutionStatus) IsSet() bool {
return s.set
func (s QuoteState) State() quotationv2.QuoteState {
return s.state
}
func (s ExecutionStatus) IsExecutable() bool {
return s.set && s.executable
}
func (s ExecutionStatus) BlockReason() quotationv2.QuoteBlockReason {
if !s.set || s.executable {
func (s QuoteState) BlockReason() quotationv2.QuoteBlockReason {
if s.state != quotationv2.QuoteState_QUOTE_STATE_BLOCKED {
return quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
}
return s.blockReason
}
func (s ExecutionStatus) Apply(quote *quotationv2.PaymentQuote) {
func (s QuoteState) Apply(quote *quotationv2.PaymentQuote) {
if quote == nil {
return
}
if !s.set {
quote.ExecutionStatus = nil
return
}
if s.executable {
quote.ExecutionStatus = &quotationv2.PaymentQuote_Executable{Executable: true}
return
}
quote.ExecutionStatus = &quotationv2.PaymentQuote_BlockReason{
BlockReason: s.blockReason,
}
quote.State = s.state
quote.BlockReason = s.BlockReason()
}
type QuoteExecutabilityClassifier struct{}
@@ -131,24 +117,22 @@ func New() *QuoteExecutabilityClassifier {
return &QuoteExecutabilityClassifier{}
}
func (c *QuoteExecutabilityClassifier) BuildExecutionStatus(
kind quotationv2.QuoteKind,
lifecycle quotationv2.QuoteLifecycle,
func (c *QuoteExecutabilityClassifier) BuildState(
previewOnly bool,
blockReason quotationv2.QuoteBlockReason,
) ExecutionStatus {
if kind != quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE ||
lifecycle != quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE {
return ExecutionStatus{}
}
if blockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return ExecutionStatus{
set: true,
executable: true,
) QuoteState {
if previewOnly {
return QuoteState{
state: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
}
}
return ExecutionStatus{
set: true,
executable: false,
if blockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return QuoteState{
state: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
}
}
return QuoteState{
state: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
blockReason: blockReason,
}
}

View File

@@ -75,52 +75,40 @@ func TestBlockReasonFromError(t *testing.T) {
}
}
func TestBuildExecutionStatus(t *testing.T) {
func TestBuildState(t *testing.T) {
classifier := New()
activeExecutable := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
activeExecutable := classifier.BuildState(
false,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
)
if !activeExecutable.IsSet() {
t.Fatalf("expected status to be set")
if got, want := activeExecutable.State(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
if !activeExecutable.IsExecutable() {
t.Fatalf("expected executable status")
if got := activeExecutable.BlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
t.Fatalf("expected empty block reason, got=%s", got.String())
}
blocked := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
blocked := classifier.BuildState(
false,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE,
)
if !blocked.IsSet() {
t.Fatalf("expected blocked status to be set")
}
if blocked.IsExecutable() {
t.Fatalf("expected blocked status")
if got, want := blocked.State(), quotationv2.QuoteState_QUOTE_STATE_BLOCKED; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
if blocked.BlockReason() != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_PRICE_STALE {
t.Fatalf("unexpected block reason: %s", blocked.BlockReason().String())
}
indicative := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
indicative := classifier.BuildState(
true,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
)
if indicative.IsSet() {
t.Fatalf("expected no execution status for indicative quote")
if got, want := indicative.State(), quotationv2.QuoteState_QUOTE_STATE_INDICATIVE; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
expired := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
)
if expired.IsSet() {
t.Fatalf("expected no execution status for expired quote")
if got := indicative.BlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
t.Fatalf("expected empty block reason for indicative state, got=%s", got.String())
}
}
@@ -128,32 +116,32 @@ func TestApply(t *testing.T) {
classifier := New()
quote := &quotationv2.PaymentQuote{}
unset := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
indicative := classifier.BuildState(
true,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
)
unset.Apply(quote)
if quote.GetExecutionStatus() != nil {
t.Fatalf("expected unset execution status")
indicative.Apply(quote)
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_INDICATIVE; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
executable := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
executable := classifier.BuildState(
false,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
)
executable.Apply(quote)
if !quote.GetExecutable() {
t.Fatalf("expected executable=true")
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
blocked := classifier.BuildExecutionStatus(
quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
blocked := classifier.BuildState(
false,
quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY,
)
blocked.Apply(quote)
if got, want := quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_BLOCKED; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
if got := quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_INSUFFICIENT_LIQUIDITY {
t.Fatalf("unexpected block reason: %s", got.String())
}

View File

@@ -2,14 +2,6 @@ package quote_persistence_service
import "strconv"
func cloneBoolPtr(src *bool) *bool {
if src == nil {
return nil
}
value := *src
return &value
}
func itoa(value int) string {
return strconv.Itoa(value)
}

View File

@@ -9,9 +9,7 @@ import (
)
type StatusInput struct {
Kind quotationv2.QuoteKind
Lifecycle quotationv2.QuoteLifecycle
Executable *bool
State quotationv2.QuoteState
BlockReason quotationv2.QuoteBlockReason
}

View File

@@ -17,7 +17,6 @@ func TestPersistSingle(t *testing.T) {
svc := New()
store := &fakeQuotesStore{}
orgID := bson.NewObjectID()
trueValue := true
record, err := svc.Persist(context.Background(), store, PersistInput{
OrganizationID: orgID,
@@ -30,9 +29,7 @@ func TestPersistSingle(t *testing.T) {
QuoteRef: "quote-1",
},
Status: &StatusInput{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &trueValue,
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
},
})
if err != nil {
@@ -50,11 +47,11 @@ func TestPersistSingle(t *testing.T) {
if store.created.StatusV2 == nil {
t.Fatalf("expected v2 status metadata")
}
if store.created.StatusV2.Kind != model.QuoteKindExecutable {
t.Fatalf("unexpected kind: %q", store.created.StatusV2.Kind)
if store.created.StatusV2.State != model.QuoteStateExecutable {
t.Fatalf("unexpected state: %q", store.created.StatusV2.State)
}
if store.created.StatusV2.Executable == nil || !*store.created.StatusV2.Executable {
t.Fatalf("expected executable=true in persisted status")
if store.created.StatusV2.BlockReason != model.QuoteBlockReasonUnspecified {
t.Fatalf("unexpected block_reason: %q", store.created.StatusV2.BlockReason)
}
}
@@ -79,13 +76,11 @@ func TestPersistBatch(t *testing.T) {
},
Statuses: []*StatusInput{
{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
},
{
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
State: quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
},
},
})
@@ -122,13 +117,12 @@ func TestPersistValidation(t *testing.T) {
Intent: &model.PaymentIntent{Ref: "intent"},
Quote: &model.PaymentQuoteSnapshot{QuoteRef: "q"},
Status: &StatusInput{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: boolPtr(false),
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for executable=false, got %v", err)
t.Fatalf("expected invalid argument for blocked without reason, got %v", err)
}
_, err = svc.Persist(context.Background(), store, PersistInput{
@@ -170,7 +164,3 @@ func (f *fakeQuotesStore) GetByRef(context.Context, bson.ObjectID, string) (*mod
func (f *fakeQuotesStore) GetByIdempotencyKey(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
return nil, quotestorage.ErrQuoteNotFound
}
func boolPtr(v bool) *bool {
return &v
}

View File

@@ -11,18 +11,19 @@ func mapStatusInput(input *StatusInput) (*model.QuoteStatusV2, error) {
return nil, merrors.InvalidArgument("status is required")
}
if input.Executable != nil && !*input.Executable {
return nil, merrors.InvalidArgument("status.executable must be true when set")
if input.State == quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED {
return nil, merrors.InvalidArgument("status.state is required")
}
if input.Executable != nil &&
input.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return nil, merrors.InvalidArgument("status.executable and status.block_reason are mutually exclusive")
if input.State == quotationv2.QuoteState_QUOTE_STATE_BLOCKED {
if input.BlockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return nil, merrors.InvalidArgument("status.block_reason is required for blocked quote")
}
} else if input.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return nil, merrors.InvalidArgument("status.block_reason is only valid for blocked quote")
}
return &model.QuoteStatusV2{
Kind: mapQuoteKind(input.Kind),
Lifecycle: mapQuoteLifecycle(input.Lifecycle),
Executable: cloneBoolPtr(input.Executable),
State: mapQuoteState(input.State),
BlockReason: mapQuoteBlockReason(input.BlockReason),
}, nil
}
@@ -43,25 +44,18 @@ func mapStatusInputs(inputs []*StatusInput) ([]*model.QuoteStatusV2, error) {
return result, nil
}
func mapQuoteKind(kind quotationv2.QuoteKind) model.QuoteKind {
switch kind {
case quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE:
return model.QuoteKindExecutable
case quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE:
return model.QuoteKindIndicative
func mapQuoteState(state quotationv2.QuoteState) model.QuoteState {
switch state {
case quotationv2.QuoteState_QUOTE_STATE_INDICATIVE:
return model.QuoteStateIndicative
case quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE:
return model.QuoteStateExecutable
case quotationv2.QuoteState_QUOTE_STATE_BLOCKED:
return model.QuoteStateBlocked
case quotationv2.QuoteState_QUOTE_STATE_EXPIRED:
return model.QuoteStateExpired
default:
return model.QuoteKindUnspecified
}
}
func mapQuoteLifecycle(lifecycle quotationv2.QuoteLifecycle) model.QuoteLifecycle {
switch lifecycle {
case quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE:
return model.QuoteLifecycleActive
case quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED:
return model.QuoteLifecycleExpired
default:
return model.QuoteLifecycleUnspecified
return model.QuoteStateUnspecified
}
}

View File

@@ -5,6 +5,7 @@ import (
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
"google.golang.org/protobuf/proto"
@@ -83,11 +84,10 @@ func cloneRoute(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecifica
Rail: strings.TrimSpace(src.GetRail()),
Provider: strings.TrimSpace(src.GetProvider()),
PayoutMethod: strings.TrimSpace(src.GetPayoutMethod()),
SettlementAsset: strings.ToUpper(strings.TrimSpace(src.GetSettlementAsset())),
SettlementModel: strings.TrimSpace(src.GetSettlementModel()),
Network: strings.TrimSpace(src.GetNetwork()),
RouteRef: strings.TrimSpace(src.GetRouteRef()),
PricingProfileRef: strings.TrimSpace(src.GetPricingProfileRef()),
Settlement: cloneRouteSettlement(src.GetSettlement()),
}
if hops := src.GetHops(); len(hops) > 0 {
result.Hops = make([]*quotationv2.RouteHop, 0, len(hops))
@@ -111,6 +111,31 @@ func cloneRoute(src *quotationv2.RouteSpecification) *quotationv2.RouteSpecifica
return result
}
func cloneRouteSettlement(src *quotationv2.RouteSettlement) *quotationv2.RouteSettlement {
if src == nil {
return nil
}
result := &quotationv2.RouteSettlement{
Model: strings.TrimSpace(src.GetModel()),
}
if asset := src.GetAsset(); asset != nil {
key := asset.GetKey()
result.Asset = &paymentv1.ChainAsset{
Key: &paymentv1.ChainAssetKey{
Chain: strings.ToUpper(strings.TrimSpace(key.GetChain())),
TokenSymbol: strings.ToUpper(strings.TrimSpace(key.GetTokenSymbol())),
},
}
if contract := strings.TrimSpace(asset.GetContractAddress()); contract != "" {
result.Asset.ContractAddress = &contract
}
}
if result.Asset == nil && result.Model == "" {
return nil
}
return result
}
func cloneExecutionConditions(src *quotationv2.ExecutionConditions) *quotationv2.ExecutionConditions {
if src == nil {
return nil

View File

@@ -30,9 +30,7 @@ type CanonicalQuote struct {
}
type QuoteStatus struct {
Kind quotationv2.QuoteKind
Lifecycle quotationv2.QuoteLifecycle
Executable *bool
State quotationv2.QuoteState
BlockReason quotationv2.QuoteBlockReason
}
@@ -43,8 +41,7 @@ type MapInput struct {
}
type MapOutput struct {
Quote *quotationv2.PaymentQuote
HasExecutionStatus bool
Executable bool
BlockReason quotationv2.QuoteBlockReason
Quote *quotationv2.PaymentQuote
State quotationv2.QuoteState
BlockReason quotationv2.QuoteBlockReason
}

View File

@@ -6,67 +6,28 @@ import (
)
type executionDecision struct {
hasStatus bool
executable bool
state quotationv2.QuoteState
blockReason quotationv2.QuoteBlockReason
}
func validateStatusInvariants(status QuoteStatus) (executionDecision, error) {
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_UNSPECIFIED {
return executionDecision{}, merrors.InvalidArgument("status.kind is required")
if status.State == quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED {
return executionDecision{}, merrors.InvalidArgument("status.state is required")
}
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_UNSPECIFIED {
return executionDecision{}, merrors.InvalidArgument("status.lifecycle is required")
}
hasExecutable := status.Executable != nil
hasBlockReason := status.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED
if status.Kind == quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE {
if hasExecutable || hasBlockReason {
return executionDecision{}, merrors.InvalidArgument("execution_status must be unset for indicative quote")
if status.State == quotationv2.QuoteState_QUOTE_STATE_BLOCKED {
if status.BlockReason == quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return executionDecision{}, merrors.InvalidArgument("status.block_reason is required for blocked quote")
}
return executionDecision{}, nil
}
if status.Lifecycle == quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED {
if hasExecutable || hasBlockReason {
return executionDecision{}, merrors.InvalidArgument("execution_status must be unset for expired quote")
}
return executionDecision{}, nil
}
if status.Kind != quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE ||
status.Lifecycle != quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE {
if hasExecutable || hasBlockReason {
return executionDecision{}, merrors.InvalidArgument("execution_status is only valid for executable active quote")
}
return executionDecision{}, nil
}
if hasExecutable == hasBlockReason {
return executionDecision{}, merrors.InvalidArgument("exactly one execution status is required")
}
if hasExecutable && !status.ExecutableValue() {
return executionDecision{}, merrors.InvalidArgument("execution_status.executable must be true")
}
if hasExecutable {
return executionDecision{
hasStatus: true,
executable: true,
state: status.State,
blockReason: status.BlockReason,
}, nil
}
if status.BlockReason != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
return executionDecision{}, merrors.InvalidArgument("status.block_reason is only valid for blocked quote")
}
return executionDecision{
hasStatus: true,
executable: false,
blockReason: status.BlockReason,
state: status.State,
blockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED,
}, nil
}
func (s QuoteStatus) ExecutableValue() bool {
if s.Executable == nil {
return false
}
return *s.Executable
}

View File

@@ -23,8 +23,8 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
result := &quotationv2.PaymentQuote{
Storable: mapStorable(in.Meta),
Kind: in.Status.Kind,
Lifecycle: in.Status.Lifecycle,
State: decision.state,
BlockReason: decision.blockReason,
DebitAmount: cloneMoney(in.Quote.DebitAmount),
CreditAmount: cloneMoney(in.Quote.CreditAmount),
TotalCost: cloneMoney(in.Quote.TotalCost),
@@ -38,21 +38,10 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
PricedAt: tsOrNil(in.Quote.PricedAt),
}
if decision.hasStatus {
if decision.executable {
result.ExecutionStatus = &quotationv2.PaymentQuote_Executable{Executable: true}
} else {
result.ExecutionStatus = &quotationv2.PaymentQuote_BlockReason{
BlockReason: decision.blockReason,
}
}
}
return &MapOutput{
Quote: result,
HasExecutionStatus: decision.hasStatus,
Executable: decision.executable,
BlockReason: decision.blockReason,
Quote: result,
State: decision.state,
BlockReason: decision.blockReason,
}, nil
}

View File

@@ -7,12 +7,12 @@ import (
"github.com/tech/sendico/pkg/merrors"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
paymentv1 "github.com/tech/sendico/pkg/proto/common/payment/v1"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
func TestMap_ExecutableActiveQuote(t *testing.T) {
func TestMap_ExecutableQuote(t *testing.T) {
mapper := New()
trueValue := true
createdAt := time.Unix(100, 0)
updatedAt := time.Unix(120, 0)
expiresAt := time.Unix(200, 0)
@@ -39,11 +39,17 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
Currency: "USD",
},
Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT",
Provider: "monetix",
PayoutMethod: "CARD",
SettlementAsset: "USD",
SettlementModel: "FIX_SOURCE",
Rail: "CARD_PAYOUT",
Provider: "monetix",
PayoutMethod: "CARD",
Settlement: &quotationv2.RouteSettlement{
Asset: &paymentv1.ChainAsset{
Key: &paymentv1.ChainAssetKey{
TokenSymbol: "USD",
},
},
Model: "FIX_SOURCE",
},
},
Conditions: &quotationv2.ExecutionConditions{
Readiness: quotationv2.QuoteExecutionReadiness_QUOTE_EXECUTION_READINESS_LIQUIDITY_READY,
@@ -55,9 +61,7 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
PricedAt: pricedAt,
},
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &trueValue,
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
},
})
if err != nil {
@@ -66,14 +70,17 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
if out == nil || out.Quote == nil {
t.Fatalf("expected mapped quote")
}
if !out.HasExecutionStatus || !out.Executable {
t.Fatalf("expected executable status")
if got, want := out.State, quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
if !out.Quote.GetExecutable() {
t.Fatalf("expected proto executable=true")
if got := out.BlockReason; got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
t.Fatalf("expected empty block reason, got=%s", got.String())
}
if got, want := out.Quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE; got != want {
t.Fatalf("unexpected proto state: got=%s want=%s", got.String(), want.String())
}
if out.Quote.GetBlockReason() != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
t.Fatalf("expected empty block reason")
t.Fatalf("expected empty proto block reason")
}
if out.Quote.GetStorable().GetId() != "rec-1" {
t.Fatalf("expected storable id rec-1, got %q", out.Quote.GetStorable().GetId())
@@ -93,20 +100,22 @@ func TestMap_ExecutableActiveQuote(t *testing.T) {
if got, want := out.Quote.GetRoute().GetProvider(), "monetix"; got != want {
t.Fatalf("unexpected route provider: got=%q want=%q", got, want)
}
if got, want := out.Quote.GetRoute().GetSettlement().GetAsset().GetKey().GetTokenSymbol(), "USD"; got != want {
t.Fatalf("unexpected settlement token: got=%q want=%q", got, want)
}
if got, want := out.Quote.GetTotalCost().GetAmount(), "10.2"; got != want {
t.Fatalf("unexpected total_cost amount: got=%q want=%q", got, want)
}
}
func TestMap_BlockedExecutableQuote(t *testing.T) {
func TestMap_BlockedQuote(t *testing.T) {
mapper := New()
out, err := mapper.Map(MapInput{
Quote: CanonicalQuote{
QuoteRef: "q-2",
},
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE,
},
})
@@ -116,92 +125,66 @@ func TestMap_BlockedExecutableQuote(t *testing.T) {
if out == nil || out.Quote == nil {
t.Fatalf("expected mapped quote")
}
if !out.HasExecutionStatus || out.Executable {
t.Fatalf("expected blocked status")
if got, want := out.Quote.GetState(), quotationv2.QuoteState_QUOTE_STATE_BLOCKED; got != want {
t.Fatalf("unexpected state: got=%s want=%s", got.String(), want.String())
}
if got := out.Quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_ROUTE_UNAVAILABLE {
t.Fatalf("unexpected block reason: %s", got.String())
}
}
func TestMap_IndicativeAndExpiredMustHaveNoExecutionStatus(t *testing.T) {
func TestMap_IndicativeAndExpiredAreValidWithoutBlockReason(t *testing.T) {
mapper := New()
trueValue := true
_, err := mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &trueValue,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg for indicative with execution status, got %v", err)
states := []quotationv2.QuoteState{
quotationv2.QuoteState_QUOTE_STATE_INDICATIVE,
quotationv2.QuoteState_QUOTE_STATE_EXPIRED,
}
_, err = mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_EXPIRED,
Executable: &trueValue,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg for expired with execution status, got %v", err)
}
out, err := mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_INDICATIVE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if out.HasExecutionStatus {
t.Fatalf("expected unset execution status")
}
if out.Quote.GetExecutionStatus() != nil {
t.Fatalf("expected no execution_status oneof")
for _, state := range states {
out, err := mapper.Map(MapInput{
Status: QuoteStatus{
State: state,
},
})
if err != nil {
t.Fatalf("unexpected error for state=%s: %v", state.String(), err)
}
if out == nil || out.Quote == nil {
t.Fatalf("expected mapped quote for state=%s", state.String())
}
if got := out.Quote.GetBlockReason(); got != quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_UNSPECIFIED {
t.Fatalf("expected empty block reason for state=%s, got=%s", state.String(), got.String())
}
}
}
func TestMap_ExecutableActiveRequiresExactlyOneExecutionStatus(t *testing.T) {
func TestMap_StateInvariants(t *testing.T) {
mapper := New()
trueValue := true
_, err := mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
State: quotationv2.QuoteState_QUOTE_STATE_UNSPECIFIED,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg when execution status is missing, got %v", err)
t.Fatalf("expected invalid arg for unspecified state, got %v", err)
}
_, err = mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &trueValue,
State: quotationv2.QuoteState_QUOTE_STATE_BLOCKED,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg for blocked without reason, got %v", err)
}
_, err = mapper.Map(MapInput{
Status: QuoteStatus{
State: quotationv2.QuoteState_QUOTE_STATE_EXECUTABLE,
BlockReason: quotationv2.QuoteBlockReason_QUOTE_BLOCK_REASON_LIMIT_BLOCKED,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg when both executable and block_reason are set, got %v", err)
}
falseValue := false
_, err = mapper.Map(MapInput{
Status: QuoteStatus{
Kind: quotationv2.QuoteKind_QUOTE_KIND_EXECUTABLE,
Lifecycle: quotationv2.QuoteLifecycle_QUOTE_LIFECYCLE_ACTIVE,
Executable: &falseValue,
},
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid arg for executable=false, got %v", err)
t.Fatalf("expected invalid arg for non-blocked with block reason, got %v", err)
}
}

View File

@@ -136,7 +136,7 @@ Calls existing core for quote and plan building.
Returns quote + expiry + optional plan.
QuoteExecutabilityClassifier
Converts plan/build errors to QuoteBlockReason.
Produces execution_status (executable=true or block_reason).
Produces quote state (executable/blocked/indicative + block_reason when blocked).
QuotePersistenceService
Persists quote record with v2 status metadata.
Keeps legacy ExecutionNote for backward compatibility.