Fixes + stable gateway ids

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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