Improved payment handling

This commit is contained in:
Stephan D
2026-02-25 19:25:51 +01:00
parent da11be526a
commit af4b68f4c7
65 changed files with 3890 additions and 259 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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 {

View File

@@ -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 {