Improved payment handling
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
chainpkg "github.com/tech/sendico/pkg/chain"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
type managedWalletNetworkResolver struct {
|
||||
resolver ChainGatewayResolver
|
||||
gatewayRegistry GatewayRegistry
|
||||
gatewayInvokeResolver GatewayInvokeResolver
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
func newManagedWalletNetworkResolver(core *Service) *managedWalletNetworkResolver {
|
||||
if core == nil {
|
||||
return nil
|
||||
}
|
||||
logger := core.logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &managedWalletNetworkResolver{
|
||||
resolver: core.deps.gateway.resolver,
|
||||
gatewayRegistry: core.deps.gatewayRegistry,
|
||||
gatewayInvokeResolver: core.deps.gatewayInvokeResolver,
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error) {
|
||||
if r == nil {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
walletRef := strings.TrimSpace(managedWalletRef)
|
||||
if walletRef == "" {
|
||||
return "", merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
}
|
||||
|
||||
var discoveryErr error
|
||||
if r.gatewayRegistry != nil && r.gatewayInvokeResolver != nil {
|
||||
network, err := r.resolveFromDiscoveredGateways(ctx, walletRef)
|
||||
if err == nil {
|
||||
return network, nil
|
||||
}
|
||||
discoveryErr = err
|
||||
if r.logger != nil {
|
||||
r.logger.Warn("Managed wallet network lookup via discovery failed",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.Error(err),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
if r.resolver == nil {
|
||||
if discoveryErr != nil {
|
||||
return "", discoveryErr
|
||||
}
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
|
||||
client, err := r.resolver.Resolve(ctx, "")
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if client == nil {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
resp, err := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return managedWalletNetworkFromResponse(resp)
|
||||
}
|
||||
|
||||
func (r *managedWalletNetworkResolver) resolveFromDiscoveredGateways(ctx context.Context, walletRef string) (string, error) {
|
||||
entries, err := r.gatewayRegistry.List(ctx)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
gatewayID string
|
||||
instanceID string
|
||||
network string
|
||||
invokeURI string
|
||||
}
|
||||
candidates := make([]candidate, 0, len(entries))
|
||||
seenInvokeURI := map[string]struct{}{}
|
||||
for _, entry := range entries {
|
||||
if entry == nil || !entry.IsEnabled || entry.Rail != model.RailCrypto {
|
||||
continue
|
||||
}
|
||||
invokeURI := strings.TrimSpace(entry.InvokeURI)
|
||||
if invokeURI == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(invokeURI)
|
||||
if _, exists := seenInvokeURI[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenInvokeURI[key] = struct{}{}
|
||||
candidates = append(candidates, candidate{
|
||||
gatewayID: strings.TrimSpace(entry.ID),
|
||||
instanceID: strings.TrimSpace(entry.InstanceID),
|
||||
network: strings.ToUpper(strings.TrimSpace(entry.Network)),
|
||||
invokeURI: invokeURI,
|
||||
})
|
||||
}
|
||||
|
||||
if len(candidates) == 0 {
|
||||
return "", merrors.NoData("chain gateway unavailable")
|
||||
}
|
||||
sort.Slice(candidates, func(i, j int) bool {
|
||||
if candidates[i].gatewayID != candidates[j].gatewayID {
|
||||
return candidates[i].gatewayID < candidates[j].gatewayID
|
||||
}
|
||||
if candidates[i].instanceID != candidates[j].instanceID {
|
||||
return candidates[i].instanceID < candidates[j].instanceID
|
||||
}
|
||||
return candidates[i].invokeURI < candidates[j].invokeURI
|
||||
})
|
||||
|
||||
var firstErr error
|
||||
for _, candidate := range candidates {
|
||||
client, resolveErr := r.gatewayInvokeResolver.Resolve(ctx, candidate.invokeURI)
|
||||
if resolveErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = resolveErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
resp, lookupErr := client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef})
|
||||
if lookupErr != nil {
|
||||
if isManagedWalletNotFound(lookupErr) {
|
||||
continue
|
||||
}
|
||||
if firstErr == nil {
|
||||
firstErr = lookupErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
network, extractErr := managedWalletNetworkFromResponse(resp)
|
||||
if extractErr != nil {
|
||||
if firstErr == nil {
|
||||
firstErr = extractErr
|
||||
}
|
||||
continue
|
||||
}
|
||||
if r.logger != nil {
|
||||
r.logger.Debug("Resolved managed wallet network from discovered gateway",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("gateway_id", candidate.gatewayID),
|
||||
zap.String("instance_id", candidate.instanceID),
|
||||
zap.String("gateway_network", candidate.network),
|
||||
zap.String("resolved_network", network),
|
||||
)
|
||||
}
|
||||
return network, nil
|
||||
}
|
||||
|
||||
if firstErr != nil {
|
||||
return "", firstErr
|
||||
}
|
||||
return "", merrors.NoData("managed wallet not found in discovered gateways")
|
||||
}
|
||||
|
||||
func managedWalletNetworkFromResponse(resp *chainv1.GetManagedWalletResponse) (string, error) {
|
||||
wallet := resp.GetWallet()
|
||||
if wallet == nil || wallet.GetAsset() == nil {
|
||||
return "", merrors.NoData("managed wallet asset is missing")
|
||||
}
|
||||
network := strings.ToUpper(strings.TrimSpace(chainpkg.NetworkAlias(wallet.GetAsset().GetChain())))
|
||||
if network == "" || network == "UNSPECIFIED" {
|
||||
return "", merrors.NoData("managed wallet network is missing")
|
||||
}
|
||||
return network, nil
|
||||
}
|
||||
|
||||
func isManagedWalletNotFound(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return true
|
||||
}
|
||||
if st, ok := status.FromError(err); ok && st.Code() == codes.NotFound {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(strings.TrimSpace(err.Error()))
|
||||
return strings.Contains(msg, "not_found")
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
package quotation
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
"go.uber.org/zap"
|
||||
"google.golang.org/grpc/codes"
|
||||
"google.golang.org/grpc/status"
|
||||
)
|
||||
|
||||
func TestManagedWalletNetworkResolver_ResolvesAcrossDiscoveredGateways(t *testing.T) {
|
||||
invokeResolver := &fakeGatewayInvokeResolver{
|
||||
clients: map[string]chainclient.Client{
|
||||
"gw-a:50053": &chainclient.Fake{
|
||||
GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
|
||||
return nil, status.Error(codes.NotFound, "not_found")
|
||||
},
|
||||
},
|
||||
"gw-b:50053": &chainclient.Fake{
|
||||
GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
|
||||
return &chainv1.GetManagedWalletResponse{
|
||||
Wallet: &chainv1.ManagedWallet{
|
||||
Asset: &chainv1.Asset{Chain: chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
resolver := &managedWalletNetworkResolver{
|
||||
gatewayRegistry: fakeGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{ID: "gw-a", Rail: model.RailCrypto, IsEnabled: true, InvokeURI: "gw-a:50053"},
|
||||
{ID: "gw-b", Rail: model.RailCrypto, IsEnabled: true, InvokeURI: "gw-b:50053"},
|
||||
},
|
||||
},
|
||||
gatewayInvokeResolver: invokeResolver,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
network, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := network, "TRON_NILE"; got != want {
|
||||
t.Fatalf("network mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := invokeResolver.calls, []string{"gw-a:50053", "gw-b:50053"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("invoke calls mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedWalletNetworkResolver_FallbacksToChainResolver(t *testing.T) {
|
||||
chainResolver := &fakeChainResolver{
|
||||
client: &chainclient.Fake{
|
||||
GetManagedWalletFn: func(context.Context, *chainv1.GetManagedWalletRequest) (*chainv1.GetManagedWalletResponse, error) {
|
||||
return &chainv1.GetManagedWalletResponse{
|
||||
Wallet: &chainv1.ManagedWallet{
|
||||
Asset: &chainv1.Asset{Chain: chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_SEPOLIA},
|
||||
},
|
||||
}, nil
|
||||
},
|
||||
},
|
||||
}
|
||||
resolver := &managedWalletNetworkResolver{
|
||||
resolver: chainResolver,
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
network, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got, want := network, "ARBITRUM_SEPOLIA"; got != want {
|
||||
t.Fatalf("network mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := chainResolver.args, []string{""}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("resolver args mismatch: got=%v want=%v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestManagedWalletNetworkResolver_ReturnsNoDataWhenNoGateways(t *testing.T) {
|
||||
resolver := &managedWalletNetworkResolver{
|
||||
gatewayRegistry: fakeGatewayRegistry{},
|
||||
gatewayInvokeResolver: &fakeGatewayInvokeResolver{
|
||||
clients: map[string]chainclient.Client{},
|
||||
},
|
||||
logger: zap.NewNop(),
|
||||
}
|
||||
|
||||
_, err := resolver.ResolveManagedWalletNetwork(context.Background(), "wallet-ref")
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
t.Fatalf("expected no_data error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeGatewayRegistry struct {
|
||||
items []*model.GatewayInstanceDescriptor
|
||||
err error
|
||||
}
|
||||
|
||||
func (f fakeGatewayRegistry) List(context.Context) ([]*model.GatewayInstanceDescriptor, error) {
|
||||
return f.items, f.err
|
||||
}
|
||||
|
||||
type fakeGatewayInvokeResolver struct {
|
||||
clients map[string]chainclient.Client
|
||||
err error
|
||||
calls []string
|
||||
}
|
||||
|
||||
func (f *fakeGatewayInvokeResolver) Resolve(_ context.Context, invokeURI string) (chainclient.Client, error) {
|
||||
f.calls = append(f.calls, invokeURI)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.clients[invokeURI], nil
|
||||
}
|
||||
|
||||
type fakeChainResolver struct {
|
||||
client chainclient.Client
|
||||
err error
|
||||
args []string
|
||||
}
|
||||
|
||||
func (f *fakeChainResolver) Resolve(_ context.Context, network string) (chainclient.Client, error) {
|
||||
f.args = append(f.args, network)
|
||||
if f.err != nil {
|
||||
return nil, f.err
|
||||
}
|
||||
return f.client, nil
|
||||
}
|
||||
@@ -74,14 +74,6 @@ func (o oracleDependency) available() bool {
|
||||
return true
|
||||
}
|
||||
|
||||
type staticChainGatewayResolver struct {
|
||||
client chainclient.Client
|
||||
}
|
||||
|
||||
func (r staticChainGatewayResolver) Resolve(context.Context, string) (chainclient.Client, error) {
|
||||
return r.client, nil
|
||||
}
|
||||
|
||||
// WithFeeEngine wires the fee engine client.
|
||||
func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option {
|
||||
return func(s *Service) {
|
||||
@@ -96,13 +88,6 @@ func WithOracleClient(client oracleclient.Client) Option {
|
||||
}
|
||||
}
|
||||
|
||||
// WithChainGatewayClient wires the chain gateway client.
|
||||
func WithChainGatewayClient(client chainclient.Client) Option {
|
||||
return func(s *Service) {
|
||||
s.deps.gateway = gatewayDependency{resolver: staticChainGatewayResolver{client: client}}
|
||||
}
|
||||
}
|
||||
|
||||
// WithChainGatewayResolver wires a resolver for chain gateway clients.
|
||||
func WithChainGatewayResolver(resolver ChainGatewayResolver) Option {
|
||||
return func(s *Service) {
|
||||
|
||||
@@ -53,6 +53,9 @@ func newQuoteComputationService(core *Service) *quote_computation_service.QuoteC
|
||||
if core != nil && core.deps.gatewayRegistry != nil {
|
||||
opts = append(opts, quote_computation_service.WithGatewayRegistry(core.deps.gatewayRegistry))
|
||||
}
|
||||
if resolver := newManagedWalletNetworkResolver(core); resolver != nil {
|
||||
opts = append(opts, quote_computation_service.WithManagedWalletNetworkResolver(resolver))
|
||||
}
|
||||
if resolver := fundingProfileResolver(core); resolver != nil {
|
||||
opts = append(opts, quote_computation_service.WithFundingProfileResolver(resolver))
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
ctx context.Context,
|
||||
endpoint *model.PaymentEndpoint,
|
||||
cache map[string]string,
|
||||
) error {
|
||||
if s == nil || endpoint == nil {
|
||||
return nil
|
||||
}
|
||||
if endpoint.Type != model.EndpointTypeManagedWallet || endpoint.ManagedWallet == nil {
|
||||
return nil
|
||||
}
|
||||
walletRef := strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef)
|
||||
if walletRef == "" {
|
||||
return merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
}
|
||||
if endpoint.ManagedWallet.Asset != nil && strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()) != "" {
|
||||
return nil
|
||||
}
|
||||
if s.managedWalletNetworkResolver == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
network := ""
|
||||
if cache != nil {
|
||||
network = strings.ToUpper(strings.TrimSpace(cache[walletRef]))
|
||||
}
|
||||
if network == "" {
|
||||
resolved, err := s.managedWalletNetworkResolver.ResolveManagedWalletNetwork(ctx, walletRef)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
network = strings.ToUpper(strings.TrimSpace(resolved))
|
||||
if network == "" {
|
||||
return merrors.NoData("managed wallet network is missing")
|
||||
}
|
||||
if cache != nil {
|
||||
cache[walletRef] = network
|
||||
}
|
||||
}
|
||||
|
||||
if s.logger != nil {
|
||||
s.logger.Debug("Managed wallet network resolved for quote planning",
|
||||
zap.String("wallet_ref", walletRef),
|
||||
zap.String("network", network),
|
||||
)
|
||||
}
|
||||
|
||||
asset := endpoint.ManagedWallet.Asset
|
||||
if asset == nil {
|
||||
asset = &paymenttypes.Asset{}
|
||||
endpoint.ManagedWallet.Asset = asset
|
||||
}
|
||||
asset.Chain = network
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.TokenSymbol))
|
||||
asset.ContractAddress = strings.TrimSpace(asset.ContractAddress)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"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_ResolvesManagedWalletNetworkFromResolver(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
networks: map[string]string{
|
||||
"wallet-usdt-source": "TRON_NILE",
|
||||
},
|
||||
}
|
||||
svc := New(nil,
|
||||
WithManagedWalletNetworkResolver(resolver),
|
||||
WithGatewayRegistry(staticGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto-arbitrum",
|
||||
InstanceID: "crypto-arbitrum",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "ARBITRUM_SEPOLIA",
|
||||
Currencies: []string{"USDT"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "crypto-tron",
|
||||
InstanceID: "crypto-tron",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON_NILE",
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
intent := sampleCryptoToCardQuoteIntent()
|
||||
intent.Source.ManagedWallet.Asset = nil
|
||||
orgID := bson.NewObjectID()
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-wallet-network",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
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 := item.Steps[0].GatewayID, "crypto-tron"; got != want {
|
||||
t.Fatalf("unexpected source gateway: got=%q want=%q", got, want)
|
||||
}
|
||||
if item.Route == nil || len(item.Route.GetHops()) == 0 {
|
||||
t.Fatalf("expected route hops")
|
||||
}
|
||||
if got, want := item.Route.GetHops()[0].GetNetwork(), "tron_nile"; got != want {
|
||||
t.Fatalf("unexpected source hop network: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resolver.calls, 1; got != want {
|
||||
t.Fatalf("unexpected resolver calls: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_ManagedWalletNetworkResolverCachesByWalletRef(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
networks: map[string]string{
|
||||
"wallet-usdt-source": "TRON_NILE",
|
||||
},
|
||||
}
|
||||
svc := New(nil,
|
||||
WithManagedWalletNetworkResolver(resolver),
|
||||
WithGatewayRegistry(staticGatewayRegistry{
|
||||
items: []*model.GatewayInstanceDescriptor{
|
||||
{
|
||||
ID: "crypto-tron",
|
||||
InstanceID: "crypto-tron",
|
||||
Rail: model.RailCrypto,
|
||||
Network: "TRON_NILE",
|
||||
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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
intentA := sampleCryptoToCardQuoteIntent()
|
||||
intentA.Ref = "intent-a"
|
||||
intentA.Source.ManagedWallet.Asset = nil
|
||||
intentB := sampleCryptoToCardQuoteIntent()
|
||||
intentB.Ref = "intent-b"
|
||||
intentB.Source.ManagedWallet.Asset = nil
|
||||
|
||||
orgID := bson.NewObjectID()
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
PreviewOnly: true,
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intentA, intentB},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if planModel == nil || len(planModel.Items) != 2 {
|
||||
t.Fatalf("expected two plan items")
|
||||
}
|
||||
if got, want := resolver.calls, 1; got != want {
|
||||
t.Fatalf("unexpected resolver calls: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
err: merrors.NoData("wallet not found"),
|
||||
}
|
||||
svc := New(nil, WithManagedWalletNetworkResolver(resolver))
|
||||
|
||||
intent := sampleCryptoToCardQuoteIntent()
|
||||
intent.Source.ManagedWallet.Asset = nil
|
||||
orgID := bson.NewObjectID()
|
||||
_, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-wallet-network-fail",
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if !errors.Is(err, merrors.ErrNoData) {
|
||||
t.Fatalf("expected no_data error, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type fakeManagedWalletNetworkResolver struct {
|
||||
networks map[string]string
|
||||
err error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context.Context, managedWalletRef string) (string, error) {
|
||||
f.calls++
|
||||
if f.err != nil {
|
||||
return "", f.err
|
||||
}
|
||||
if f.networks == nil {
|
||||
return "", nil
|
||||
}
|
||||
return f.networks[managedWalletRef], nil
|
||||
}
|
||||
@@ -51,9 +51,10 @@ func (s *QuoteComputationService) BuildPlan(ctx context.Context, in ComputeInput
|
||||
BaseIdempotencyKey: strings.TrimSpace(in.BaseIdempotencyKey),
|
||||
Items: make([]*QuoteComputationPlanItem, 0, len(in.Intents)),
|
||||
}
|
||||
managedWalletNetworks := map[string]string{}
|
||||
|
||||
for i, intent := range in.Intents {
|
||||
item, err := s.buildPlanItem(ctx, in, i, intent)
|
||||
item, err := s.buildPlanItem(ctx, in, i, intent, managedWalletNetworks)
|
||||
if err != nil {
|
||||
s.logger.Warn("Computation plan item build failed",
|
||||
zap.String("org_ref", in.OrganizationRef),
|
||||
@@ -81,6 +82,7 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
in ComputeInput,
|
||||
index int,
|
||||
intent *transfer_intent_hydrator.QuoteIntent,
|
||||
managedWalletNetworks map[string]string,
|
||||
) (*QuoteComputationPlanItem, error) {
|
||||
if intent == nil {
|
||||
s.logger.Warn("Plan item build failed: intent is nil", zap.Int("index", index))
|
||||
@@ -118,6 +120,22 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
|
||||
source := clonePaymentEndpoint(modelIntent.Source)
|
||||
destination := clonePaymentEndpoint(modelIntent.Destination)
|
||||
if err := s.enrichManagedWalletEndpointNetwork(ctx, &source, managedWalletNetworks); err != nil {
|
||||
s.logger.Warn("Plan item build failed: source managed wallet network resolution error",
|
||||
zap.Int("index", index),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
if err := s.enrichManagedWalletEndpointNetwork(ctx, &destination, managedWalletNetworks); err != nil {
|
||||
s.logger.Warn("Plan item build failed: destination managed wallet network resolution error",
|
||||
zap.Int("index", index),
|
||||
zap.Error(err),
|
||||
)
|
||||
return nil, err
|
||||
}
|
||||
modelIntent.Source = clonePaymentEndpoint(source)
|
||||
modelIntent.Destination = clonePaymentEndpoint(destination)
|
||||
|
||||
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
|
||||
if err != nil {
|
||||
|
||||
@@ -15,15 +15,20 @@ type Core interface {
|
||||
BuildQuote(ctx context.Context, in BuildQuoteInput) (*ComputedQuote, time.Time, error)
|
||||
}
|
||||
|
||||
type ManagedWalletNetworkResolver interface {
|
||||
ResolveManagedWalletNetwork(ctx context.Context, managedWalletRef string) (string, error)
|
||||
}
|
||||
|
||||
type Option func(*QuoteComputationService)
|
||||
|
||||
type QuoteComputationService struct {
|
||||
core Core
|
||||
fundingResolver gateway_funding_profile.FundingProfileResolver
|
||||
gatewayRegistry plan.GatewayRegistry
|
||||
routeStore plan.RouteStore
|
||||
pathFinder *graph_path_finder.GraphPathFinder
|
||||
logger mlogger.Logger
|
||||
core Core
|
||||
fundingResolver gateway_funding_profile.FundingProfileResolver
|
||||
gatewayRegistry plan.GatewayRegistry
|
||||
managedWalletNetworkResolver ManagedWalletNetworkResolver
|
||||
routeStore plan.RouteStore
|
||||
pathFinder *graph_path_finder.GraphPathFinder
|
||||
logger mlogger.Logger
|
||||
}
|
||||
|
||||
func New(core Core, opts ...Option) *QuoteComputationService {
|
||||
@@ -56,6 +61,14 @@ func WithGatewayRegistry(registry plan.GatewayRegistry) Option {
|
||||
}
|
||||
}
|
||||
|
||||
func WithManagedWalletNetworkResolver(resolver ManagedWalletNetworkResolver) Option {
|
||||
return func(svc *QuoteComputationService) {
|
||||
if svc != nil {
|
||||
svc.managedWalletNetworkResolver = resolver
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func WithRouteStore(store plan.RouteStore) Option {
|
||||
return func(svc *QuoteComputationService) {
|
||||
if svc != nil {
|
||||
|
||||
Reference in New Issue
Block a user