Fixes + stable gateway ids
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -18,34 +18,27 @@ import (
|
||||
|
||||
func statusInputFromStatus(status quote_response_mapper_v2.QuoteStatus) *quote_persistence_service.StatusInput {
|
||||
return "e_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
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 := "ationv2.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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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 := "ationv2.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 := "ationv2.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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 := "ationv2.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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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{
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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 "ationv2.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 ""
|
||||
}
|
||||
@@ -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 := "ationv2.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, "|")
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = "ationv2.PaymentQuote_Executable{Executable: true}
|
||||
return
|
||||
}
|
||||
quote.ExecutionStatus = "ationv2.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,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 := "ationv2.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())
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@ import (
|
||||
)
|
||||
|
||||
type StatusInput struct {
|
||||
Kind quotationv2.QuoteKind
|
||||
Lifecycle quotationv2.QuoteLifecycle
|
||||
Executable *bool
|
||||
State quotationv2.QuoteState
|
||||
BlockReason quotationv2.QuoteBlockReason
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 := "ationv2.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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -23,8 +23,8 @@ func (m *QuoteResponseMapperV2) Map(in MapInput) (*MapOutput, error) {
|
||||
|
||||
result := "ationv2.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 = "ationv2.PaymentQuote_Executable{Executable: true}
|
||||
} else {
|
||||
result.ExecutionStatus = "ationv2.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
|
||||
}
|
||||
|
||||
|
||||
@@ -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: "ationv2.RouteSpecification{
|
||||
Rail: "CARD_PAYOUT",
|
||||
Provider: "monetix",
|
||||
PayoutMethod: "CARD",
|
||||
SettlementAsset: "USD",
|
||||
SettlementModel: "FIX_SOURCE",
|
||||
Rail: "CARD_PAYOUT",
|
||||
Provider: "monetix",
|
||||
PayoutMethod: "CARD",
|
||||
Settlement: "ationv2.RouteSettlement{
|
||||
Asset: &paymentv1.ChainAsset{
|
||||
Key: &paymentv1.ChainAssetKey{
|
||||
TokenSymbol: "USD",
|
||||
},
|
||||
},
|
||||
Model: "FIX_SOURCE",
|
||||
},
|
||||
},
|
||||
Conditions: "ationv2.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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user