+source currency pick fix +fx side propagation
This commit is contained in:
@@ -23,7 +23,7 @@ import (
|
||||
func TestBuildPlan_BuildsStepsAndFundingGate(t *testing.T) {
|
||||
svc := New(nil, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
|
||||
GatewayModes: map[string]model.FundingMode{
|
||||
"monetix": model.FundingModeBalanceReserve,
|
||||
paymenttypes.DefaultCardsGatewayID: model.FundingModeBalanceReserve,
|
||||
},
|
||||
})))
|
||||
|
||||
@@ -168,6 +168,69 @@ func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_UsesSourceAssetCurrencyForSourceStep(t *testing.T) {
|
||||
svc := New(nil)
|
||||
orgID := bson.NewObjectID()
|
||||
intent := sampleCryptoToCardQuoteIntent()
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.Amount = &paymenttypes.Money{
|
||||
Amount: "5000",
|
||||
Currency: "RUB",
|
||||
}
|
||||
intent.SettlementCurrency = "RUB"
|
||||
intent.RequiresFX = false
|
||||
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-key",
|
||||
PreviewOnly: false,
|
||||
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(planModel.Items) != 1 {
|
||||
t.Fatalf("expected one plan item")
|
||||
}
|
||||
item := planModel.Items[0]
|
||||
if item == nil {
|
||||
t.Fatalf("expected plan item")
|
||||
}
|
||||
if !item.QuoteInput.Intent.RequiresFX {
|
||||
t.Fatalf("expected derived FX requirement for fix_received cross-currency flow")
|
||||
}
|
||||
if item.QuoteInput.Intent.FX == nil || item.QuoteInput.Intent.FX.Pair == nil {
|
||||
t.Fatalf("expected derived FX pair")
|
||||
}
|
||||
if got, want := strings.TrimSpace(item.QuoteInput.Intent.FX.Pair.GetBase()), "USDT"; got != want {
|
||||
t.Fatalf("unexpected derived FX base currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := strings.TrimSpace(item.QuoteInput.Intent.FX.Pair.GetQuote()), "RUB"; got != want {
|
||||
t.Fatalf("unexpected derived FX quote currency: got=%q want=%q", got, want)
|
||||
}
|
||||
steps := item.Steps
|
||||
if got, want := len(steps), 4; got != want {
|
||||
t.Fatalf("unexpected step count: got=%d want=%d", got, want)
|
||||
}
|
||||
if steps[0] == nil || steps[0].Amount == nil {
|
||||
t.Fatalf("expected source step amount")
|
||||
}
|
||||
if got, want := strings.TrimSpace(steps[0].Amount.GetCurrency()), "USDT"; got != want {
|
||||
t.Fatalf("unexpected source step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
last := steps[len(steps)-1]
|
||||
if last == nil || last.Amount == nil {
|
||||
t.Fatalf("expected destination step amount")
|
||||
}
|
||||
if got, want := strings.TrimSpace(last.Amount.GetCurrency()), "RUB"; got != want {
|
||||
t.Fatalf("unexpected destination step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := steps[1].Operation, model.RailOperationFXConvert; got != want {
|
||||
t.Fatalf("unexpected middle operation: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_ResolvesIndependentEconomicsKnobs(t *testing.T) {
|
||||
svc := New(nil)
|
||||
orgID := bson.NewObjectID()
|
||||
@@ -397,7 +460,7 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
}
|
||||
svc := New(core, WithFundingProfileResolver(gateway_funding_profile.NewStaticFundingProfileResolver(gateway_funding_profile.StaticFundingProfileResolverInput{
|
||||
GatewayModes: map[string]model.FundingMode{
|
||||
"monetix": model.FundingModeBalanceReserve,
|
||||
paymenttypes.DefaultCardsGatewayID: model.FundingModeBalanceReserve,
|
||||
},
|
||||
})))
|
||||
|
||||
@@ -459,7 +522,7 @@ func TestCompute_EnrichesRouteConditionsAndTotalCost(t *testing.T) {
|
||||
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 {
|
||||
if got, want := hops[1].GetGateway(), paymenttypes.DefaultCardsGatewayID; got != want {
|
||||
t.Fatalf("unexpected destination gateway in build input route: got=%q want=%q", got, want)
|
||||
}
|
||||
if core.lastQuoteIn.ExecutionConditions == nil {
|
||||
@@ -611,7 +674,7 @@ func sampleCardQuoteIntent() *transfer_intent_hydrator.QuoteIntent {
|
||||
},
|
||||
SettlementCurrency: "USD",
|
||||
Attributes: map[string]string{
|
||||
"gateway": "monetix",
|
||||
"gateway": paymenttypes.DefaultCardsGatewayID,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model.PaymentIntent {
|
||||
@@ -22,6 +23,7 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
|
||||
Source: modelEndpointFromQuoteEndpoint(src.Source),
|
||||
Destination: modelEndpointFromQuoteEndpoint(src.Destination),
|
||||
Amount: cloneModelMoney(src.Amount),
|
||||
FX: fxIntentFromHydratedIntent(src),
|
||||
RequiresFX: src.RequiresFX,
|
||||
Attributes: cloneStringMap(src.Attributes),
|
||||
SettlementMode: modelSettlementMode(src.SettlementMode),
|
||||
@@ -30,6 +32,72 @@ func modelIntentFromQuoteIntent(src *transfer_intent_hydrator.QuoteIntent) model
|
||||
}
|
||||
}
|
||||
|
||||
func fxIntentFromHydratedIntent(src *transfer_intent_hydrator.QuoteIntent) *model.FXIntent {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
if strings.TrimSpace(string(src.FXSide)) == "" || src.FXSide == paymenttypes.FXSideUnspecified {
|
||||
return nil
|
||||
}
|
||||
return &model.FXIntent{Side: src.FXSide}
|
||||
}
|
||||
|
||||
func ensureDerivedFXIntent(intent *model.PaymentIntent) {
|
||||
if intent == nil {
|
||||
return
|
||||
}
|
||||
|
||||
amountCurrency := ""
|
||||
if intent.Amount != nil {
|
||||
amountCurrency = normalizeAsset(intent.Amount.GetCurrency())
|
||||
}
|
||||
settlementCurrency := normalizeAsset(intent.SettlementCurrency)
|
||||
if settlementCurrency == "" {
|
||||
settlementCurrency = amountCurrency
|
||||
}
|
||||
if intent.SettlementCurrency == "" && settlementCurrency != "" {
|
||||
intent.SettlementCurrency = settlementCurrency
|
||||
}
|
||||
|
||||
sourceCurrency := sourceAssetToken(intent.Source)
|
||||
|
||||
// For FIX_RECEIVED, destination amounts can be provided in payout currency.
|
||||
// Derive FX necessity from source asset currency when available.
|
||||
if !intent.RequiresFX &&
|
||||
intent.SettlementMode == model.SettlementModeFixReceived &&
|
||||
sourceCurrency != "" &&
|
||||
settlementCurrency != "" &&
|
||||
!strings.EqualFold(sourceCurrency, settlementCurrency) {
|
||||
intent.RequiresFX = true
|
||||
}
|
||||
|
||||
if !intent.RequiresFX {
|
||||
return
|
||||
}
|
||||
|
||||
baseCurrency := firstNonEmpty(sourceCurrency, amountCurrency)
|
||||
quoteCurrency := settlementCurrency
|
||||
if baseCurrency == "" || quoteCurrency == "" {
|
||||
return
|
||||
}
|
||||
|
||||
if intent.FX == nil {
|
||||
intent.FX = &model.FXIntent{}
|
||||
}
|
||||
if intent.FX.Pair == nil {
|
||||
intent.FX.Pair = &paymenttypes.CurrencyPair{}
|
||||
}
|
||||
if normalizeAsset(intent.FX.Pair.Base) == "" {
|
||||
intent.FX.Pair.Base = baseCurrency
|
||||
}
|
||||
if normalizeAsset(intent.FX.Pair.Quote) == "" {
|
||||
intent.FX.Pair.Quote = quoteCurrency
|
||||
}
|
||||
if strings.TrimSpace(string(intent.FX.Side)) == "" || intent.FX.Side == paymenttypes.FXSideUnspecified {
|
||||
intent.FX.Side = paymenttypes.FXSideSellBaseBuyQuote
|
||||
}
|
||||
}
|
||||
|
||||
func modelEndpointFromQuoteEndpoint(src transfer_intent_hydrator.QuoteEndpoint) model.PaymentEndpoint {
|
||||
result := model.PaymentEndpoint{
|
||||
Type: model.EndpointTypeUnspecified,
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/quotation/internal/service/quotation/transfer_intent_hydrator"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
)
|
||||
|
||||
func TestEnsureDerivedFXIntent_DefaultsSideWhenEmpty(t *testing.T) {
|
||||
intent := &model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
SettlementCurrency: "RUB",
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
FX: &model.FXIntent{},
|
||||
}
|
||||
|
||||
ensureDerivedFXIntent(intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideSellBaseBuyQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDerivedFXIntent_DefaultsSideWhenUnspecified(t *testing.T) {
|
||||
intent := &model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
SettlementCurrency: "RUB",
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
FX: &model.FXIntent{Side: paymenttypes.FXSideUnspecified},
|
||||
}
|
||||
|
||||
ensureDerivedFXIntent(intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideSellBaseBuyQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureDerivedFXIntent_PreservesExplicitSideFromHydratedIntent(t *testing.T) {
|
||||
hydrated := &transfer_intent_hydrator.QuoteIntent{
|
||||
Source: transfer_intent_hydrator.QuoteEndpoint{
|
||||
Type: transfer_intent_hydrator.QuoteEndpointTypeManagedWallet,
|
||||
ManagedWallet: &transfer_intent_hydrator.QuoteManagedWalletEndpoint{
|
||||
ManagedWalletRef: "mw-src",
|
||||
Asset: &paymenttypes.Asset{TokenSymbol: "USDT"},
|
||||
},
|
||||
},
|
||||
Amount: &paymenttypes.Money{Amount: "100", Currency: "USDT"},
|
||||
SettlementCurrency: "RUB",
|
||||
RequiresFX: true,
|
||||
FXSide: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
}
|
||||
|
||||
intent := modelIntentFromQuoteIntent(hydrated)
|
||||
ensureDerivedFXIntent(&intent)
|
||||
|
||||
if intent.FX == nil {
|
||||
t.Fatal("expected fx intent")
|
||||
}
|
||||
if got, want := intent.FX.Side, paymenttypes.FXSideBuyBaseSellQuote; got != want {
|
||||
t.Fatalf("unexpected side: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,10 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type managedWalletAssetResolver interface {
|
||||
ResolveManagedWalletAsset(ctx context.Context, managedWalletRef string) (*paymenttypes.Asset, error)
|
||||
}
|
||||
|
||||
func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
ctx context.Context,
|
||||
endpoint *model.PaymentEndpoint,
|
||||
@@ -25,7 +29,30 @@ func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
if walletRef == "" {
|
||||
return merrors.InvalidArgument("managed_wallet_ref is required")
|
||||
}
|
||||
if endpoint.ManagedWallet.Asset != nil && strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain()) != "" {
|
||||
asset := endpoint.ManagedWallet.Asset
|
||||
if asset == nil {
|
||||
asset = &paymenttypes.Asset{}
|
||||
endpoint.ManagedWallet.Asset = asset
|
||||
}
|
||||
if resolver, ok := s.managedWalletNetworkResolver.(managedWalletAssetResolver); ok && resolver != nil {
|
||||
if strings.TrimSpace(asset.GetChain()) == "" || strings.TrimSpace(asset.GetTokenSymbol()) == "" {
|
||||
if resolved, err := resolver.ResolveManagedWalletAsset(ctx, walletRef); err == nil && resolved != nil {
|
||||
if strings.TrimSpace(asset.GetChain()) == "" {
|
||||
asset.Chain = strings.ToUpper(strings.TrimSpace(resolved.GetChain()))
|
||||
}
|
||||
if strings.TrimSpace(asset.GetTokenSymbol()) == "" {
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(resolved.GetTokenSymbol()))
|
||||
}
|
||||
if strings.TrimSpace(asset.GetContractAddress()) == "" {
|
||||
asset.ContractAddress = strings.TrimSpace(resolved.GetContractAddress())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(asset.GetChain()) != "" {
|
||||
asset.Chain = strings.ToUpper(strings.TrimSpace(asset.GetChain()))
|
||||
asset.TokenSymbol = strings.ToUpper(strings.TrimSpace(asset.GetTokenSymbol()))
|
||||
asset.ContractAddress = strings.TrimSpace(asset.GetContractAddress())
|
||||
return nil
|
||||
}
|
||||
if s.managedWalletNetworkResolver == nil {
|
||||
@@ -57,11 +84,6 @@ func (s *QuoteComputationService) enrichManagedWalletEndpointNetwork(
|
||||
)
|
||||
}
|
||||
|
||||
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)
|
||||
|
||||
@@ -8,6 +8,7 @@ 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"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
@@ -147,6 +148,93 @@ func TestBuildPlan_ManagedWalletNetworkResolverCachesByWalletRef(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_ResolvesManagedWalletAssetTokenForSourceCurrency(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
networks: map[string]string{
|
||||
"wallet-usdt-source": "TRON_NILE",
|
||||
},
|
||||
assets: map[string]*paymenttypes.Asset{
|
||||
"wallet-usdt-source": {
|
||||
Chain: "TRON_NILE",
|
||||
TokenSymbol: "USDT",
|
||||
},
|
||||
},
|
||||
}
|
||||
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: "fx-tron",
|
||||
InstanceID: "fx-tron",
|
||||
Rail: model.RailProviderSettlement,
|
||||
Network: "TRON_NILE",
|
||||
Currencies: []string{"USDT", "RUB"},
|
||||
Operations: []model.RailOperation{model.RailOperationFXConvert},
|
||||
IsEnabled: true,
|
||||
},
|
||||
{
|
||||
ID: "card-gw",
|
||||
InstanceID: "card-gw",
|
||||
Rail: model.RailCardPayout,
|
||||
Currencies: []string{"RUB"},
|
||||
Capabilities: model.RailCapabilities{
|
||||
CanPayOut: true,
|
||||
},
|
||||
IsEnabled: true,
|
||||
},
|
||||
},
|
||||
}),
|
||||
)
|
||||
|
||||
intent := sampleCryptoToCardQuoteIntent()
|
||||
intent.Source.ManagedWallet.Asset = nil
|
||||
intent.Amount = &paymenttypes.Money{
|
||||
Amount: "5000",
|
||||
Currency: "RUB",
|
||||
}
|
||||
intent.SettlementMode = transfer_intent_hydrator.QuoteSettlementModeFixReceived
|
||||
intent.SettlementCurrency = "RUB"
|
||||
intent.RequiresFX = false
|
||||
orgID := bson.NewObjectID()
|
||||
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
|
||||
OrganizationRef: orgID.Hex(),
|
||||
OrganizationID: orgID,
|
||||
BaseIdempotencyKey: "idem-wallet-asset",
|
||||
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.Steps[0] == nil || item.Steps[0].Amount == nil {
|
||||
t.Fatalf("expected source step amount")
|
||||
}
|
||||
if got, want := item.Steps[0].Amount.GetCurrency(), "USDT"; got != want {
|
||||
t.Fatalf("unexpected source step currency: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := resolver.assetCalls, 1; got != want {
|
||||
t.Fatalf("unexpected asset resolver calls: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) {
|
||||
resolver := &fakeManagedWalletNetworkResolver{
|
||||
err: merrors.NoData("wallet not found"),
|
||||
@@ -168,9 +256,12 @@ func TestBuildPlan_FailsWhenManagedWalletNetworkResolutionFails(t *testing.T) {
|
||||
}
|
||||
|
||||
type fakeManagedWalletNetworkResolver struct {
|
||||
networks map[string]string
|
||||
err error
|
||||
calls int
|
||||
networks map[string]string
|
||||
assets map[string]*paymenttypes.Asset
|
||||
err error
|
||||
assetErr error
|
||||
calls int
|
||||
assetCalls int
|
||||
}
|
||||
|
||||
func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context.Context, managedWalletRef string) (string, error) {
|
||||
@@ -183,3 +274,22 @@ func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletNetwork(_ context
|
||||
}
|
||||
return f.networks[managedWalletRef], nil
|
||||
}
|
||||
|
||||
func (f *fakeManagedWalletNetworkResolver) ResolveManagedWalletAsset(_ context.Context, managedWalletRef string) (*paymenttypes.Asset, error) {
|
||||
f.assetCalls++
|
||||
if f.assetErr != nil {
|
||||
return nil, f.assetErr
|
||||
}
|
||||
if f.assets == nil {
|
||||
return nil, nil
|
||||
}
|
||||
src := f.assets[managedWalletRef]
|
||||
if src == nil {
|
||||
return nil, nil
|
||||
}
|
||||
return &paymenttypes.Asset{
|
||||
Chain: src.GetChain(),
|
||||
TokenSymbol: src.GetTokenSymbol(),
|
||||
ContractAddress: src.GetContractAddress(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -136,6 +136,7 @@ func (s *QuoteComputationService) buildPlanItem(
|
||||
}
|
||||
modelIntent.Source = clonePaymentEndpoint(source)
|
||||
modelIntent.Destination = clonePaymentEndpoint(destination)
|
||||
ensureDerivedFXIntent(&modelIntent)
|
||||
|
||||
sourceRail, sourceNetwork, err := plan.RailFromEndpoint(source, modelIntent.Attributes, true)
|
||||
if err != nil {
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
@@ -20,6 +21,7 @@ func buildComputationSteps(
|
||||
|
||||
attrs := intent.Attributes
|
||||
amount := protoMoneyFromModel(intent.Amount)
|
||||
sourceAmount := sourceStepAmount(intent, amount)
|
||||
destinationAmount := destinationStepAmount(intent, amount)
|
||||
sourceRail := sourceRailForIntent(intent)
|
||||
destinationRail := destinationRailForIntent(intent)
|
||||
@@ -45,7 +47,7 @@ func buildComputationSteps(
|
||||
Operation: sourceOperationForRail(rails[0]),
|
||||
GatewayID: sourceGatewayID,
|
||||
InstanceID: sourceInstanceID,
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(sourceAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
},
|
||||
@@ -63,7 +65,7 @@ func buildComputationSteps(
|
||||
Rail: model.RailProviderSettlement,
|
||||
Operation: model.RailOperationFXConvert,
|
||||
DependsOn: []string{sourceStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(sourceAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
@@ -82,12 +84,16 @@ func buildComputationSteps(
|
||||
operation = model.RailOperationFXConvert
|
||||
fxAssigned = true
|
||||
}
|
||||
stepAmount := amount
|
||||
if operation == model.RailOperationFXConvert {
|
||||
stepAmount = sourceAmount
|
||||
}
|
||||
steps = append(steps, &QuoteComputationStep{
|
||||
StepID: stepID,
|
||||
Rail: rail,
|
||||
Operation: operation,
|
||||
DependsOn: []string{lastStepID},
|
||||
Amount: cloneProtoMoney(amount),
|
||||
Amount: cloneProtoMoney(stepAmount),
|
||||
Optional: false,
|
||||
IncludeInAggregate: false,
|
||||
})
|
||||
@@ -209,11 +215,78 @@ func destinationStepAmount(intent model.PaymentIntent, sourceAmount *moneyv1.Mon
|
||||
}
|
||||
|
||||
settlementCurrency := strings.ToUpper(strings.TrimSpace(intent.SettlementCurrency))
|
||||
if settlementCurrency == "" && intent.FX != nil && intent.FX.Pair != nil {
|
||||
settlementCurrency = strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote))
|
||||
if settlementCurrency == "" {
|
||||
settlementCurrency = settlementCurrencyFromFX(intent.FX)
|
||||
}
|
||||
if settlementCurrency != "" {
|
||||
amount.Currency = settlementCurrency
|
||||
}
|
||||
return amount
|
||||
}
|
||||
|
||||
func settlementCurrencyFromFX(fx *model.FXIntent) string {
|
||||
if fx == nil || fx.Pair == nil {
|
||||
return ""
|
||||
}
|
||||
base := normalizeAsset(fx.Pair.GetBase())
|
||||
quote := normalizeAsset(fx.Pair.GetQuote())
|
||||
switch fx.Side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
return firstNonEmpty(base, quote)
|
||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||
return firstNonEmpty(quote, base)
|
||||
default:
|
||||
return firstNonEmpty(quote, base)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceStepAmount(intent model.PaymentIntent, amount *moneyv1.Money) *moneyv1.Money {
|
||||
result := cloneProtoMoney(amount)
|
||||
if result == nil {
|
||||
return nil
|
||||
}
|
||||
if currency := sourceStepCurrency(intent, result.GetCurrency()); currency != "" {
|
||||
result.Currency = currency
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func sourceStepCurrency(intent model.PaymentIntent, fallback string) string {
|
||||
if currency := sourceCurrencyFromFX(intent.FX); currency != "" {
|
||||
return currency
|
||||
}
|
||||
if currency := sourceAssetToken(intent.Source); currency != "" {
|
||||
return currency
|
||||
}
|
||||
return normalizeAsset(fallback)
|
||||
}
|
||||
|
||||
func sourceCurrencyFromFX(fx *model.FXIntent) string {
|
||||
if fx == nil || fx.Pair == nil {
|
||||
return ""
|
||||
}
|
||||
base := normalizeAsset(fx.Pair.GetBase())
|
||||
quote := normalizeAsset(fx.Pair.GetQuote())
|
||||
switch fx.Side {
|
||||
case paymenttypes.FXSideBuyBaseSellQuote:
|
||||
return quote
|
||||
case paymenttypes.FXSideSellBaseBuyQuote:
|
||||
return base
|
||||
default:
|
||||
return firstNonEmpty(base, quote)
|
||||
}
|
||||
}
|
||||
|
||||
func sourceAssetToken(endpoint model.PaymentEndpoint) string {
|
||||
if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil {
|
||||
if token := normalizeAsset(endpoint.ManagedWallet.Asset.GetTokenSymbol()); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil {
|
||||
if token := normalizeAsset(endpoint.ExternalChain.Asset.GetTokenSymbol()); token != "" {
|
||||
return token
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
package quote_computation_service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
)
|
||||
|
||||
func TestDestinationStepAmount_UsesSideAwareCurrencyFallback(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side paymenttypes.FXSide
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "buy_base_sell_quote uses base settlement currency",
|
||||
side: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
want: "RUB",
|
||||
},
|
||||
{
|
||||
name: "sell_base_buy_quote uses quote settlement currency",
|
||||
side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
want: "USDT",
|
||||
},
|
||||
{
|
||||
name: "unspecified defaults to quote settlement currency",
|
||||
side: paymenttypes.FXSideUnspecified,
|
||||
want: "USDT",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
intent := model.PaymentIntent{
|
||||
RequiresFX: true,
|
||||
FX: &model.FXIntent{
|
||||
Pair: &paymenttypes.CurrencyPair{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
},
|
||||
Side: tt.side,
|
||||
},
|
||||
}
|
||||
|
||||
got := destinationStepAmount(intent, &moneyv1.Money{
|
||||
Amount: "100",
|
||||
Currency: "EUR",
|
||||
})
|
||||
if got == nil {
|
||||
t.Fatal("expected destination amount")
|
||||
}
|
||||
if got.GetCurrency() != tt.want {
|
||||
t.Fatalf("unexpected destination currency: got=%q want=%q", got.GetCurrency(), tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSourceCurrencyFromFX_RespectsSide(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
side paymenttypes.FXSide
|
||||
want string
|
||||
}{
|
||||
{
|
||||
name: "buy_base_sell_quote debits quote currency",
|
||||
side: paymenttypes.FXSideBuyBaseSellQuote,
|
||||
want: "USDT",
|
||||
},
|
||||
{
|
||||
name: "sell_base_buy_quote debits base currency",
|
||||
side: paymenttypes.FXSideSellBaseBuyQuote,
|
||||
want: "RUB",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got := sourceCurrencyFromFX(&model.FXIntent{
|
||||
Pair: &paymenttypes.CurrencyPair{
|
||||
Base: "RUB",
|
||||
Quote: "USDT",
|
||||
},
|
||||
Side: tt.side,
|
||||
})
|
||||
if got != tt.want {
|
||||
t.Fatalf("unexpected source currency: got=%q want=%q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user