fx fix
This commit is contained in:
@@ -40,7 +40,7 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction.
|
|||||||
Unique: true,
|
Unique: true,
|
||||||
Name: "quotes_meta_org_idempotency_key",
|
Name: "quotes_meta_org_idempotency_key",
|
||||||
PartialFilter: repository.Query().
|
PartialFilter: repository.Query().
|
||||||
Comparison(repository.Field("meta.idempotencyKey"), builder.Ne, "").
|
Comparison(repository.Field("meta.idempotencyKey"), builder.Gt, "").
|
||||||
Comparison(repository.Field("meta.organizationRef"), builder.Exists, true),
|
Comparison(repository.Field("meta.organizationRef"), builder.Exists, true),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -167,6 +167,69 @@ func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBuildPlanFromTemplate_ProviderSettlementUsesNetAmountWhenFixReceived(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
builder := &defaultPlanBuilder{}
|
||||||
|
|
||||||
|
payment := &model.Payment{
|
||||||
|
PaymentRef: "pay-settle-1",
|
||||||
|
IdempotencyKey: "idem-settle-1",
|
||||||
|
Intent: model.PaymentIntent{
|
||||||
|
Kind: model.PaymentKindPayout,
|
||||||
|
SettlementMode: model.SettlementModeFixReceived,
|
||||||
|
Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"},
|
||||||
|
Source: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeManagedWallet,
|
||||||
|
ManagedWallet: &model.ManagedWalletEndpoint{
|
||||||
|
ManagedWalletRef: "wallet-1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Destination: model.PaymentEndpoint{
|
||||||
|
Type: model.EndpointTypeCard,
|
||||||
|
Card: &model.CardEndpoint{MaskedPan: "4111"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
quote := &orchestratorv1.PaymentQuote{
|
||||||
|
DebitAmount: &moneyv1.Money{Currency: "USDT", Amount: "105"},
|
||||||
|
ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "100"},
|
||||||
|
ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"},
|
||||||
|
}
|
||||||
|
|
||||||
|
template := &model.PaymentPlanTemplate{
|
||||||
|
Steps: []model.OrchestrationStep{
|
||||||
|
{StepID: "settle", Rail: model.RailProviderSettlement, Operation: "send"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
registry := &stubGatewayRegistry{
|
||||||
|
items: []*model.GatewayInstanceDescriptor{
|
||||||
|
{
|
||||||
|
ID: "settlement",
|
||||||
|
InstanceID: "settlement-1",
|
||||||
|
Rail: model.RailProviderSettlement,
|
||||||
|
Currencies: []string{"USDT"},
|
||||||
|
Capabilities: model.RailCapabilities{
|
||||||
|
CanPayOut: true,
|
||||||
|
},
|
||||||
|
Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"},
|
||||||
|
IsEnabled: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
plan, err := builder.buildPlanFromTemplate(ctx, payment, quote, template, model.RailCrypto, model.RailProviderSettlement, "TRON", "", registry)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("expected plan, got error: %v", err)
|
||||||
|
}
|
||||||
|
if len(plan.Steps) != 1 {
|
||||||
|
t.Fatalf("expected 1 step, got %d", len(plan.Steps))
|
||||||
|
}
|
||||||
|
|
||||||
|
assertPlanStep(t, plan.Steps[0], "settle", model.RailProviderSettlement, model.RailOperationSend, "settlement", "settlement-1", "USDT", "95")
|
||||||
|
}
|
||||||
|
|
||||||
func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) {
|
func TestDefaultPlanBuilder_UsesSourceCurrencyForCryptoSendWithFX(t *testing.T) {
|
||||||
ctx := context.Background()
|
ctx := context.Background()
|
||||||
builder := &defaultPlanBuilder{}
|
builder := &defaultPlanBuilder{}
|
||||||
|
|||||||
@@ -34,6 +34,13 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
providerSettlementAmount := settlementAmount
|
||||||
|
if payment.Intent.SettlementMode == model.SettlementModeFixReceived && feeRequired {
|
||||||
|
providerSettlementAmount, err = netSettlementAmount(settlementAmount, feeAmount, quote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
payoutAmount := settlementAmount
|
payoutAmount := settlementAmount
|
||||||
if destRail == model.RailCardPayout {
|
if destRail == model.RailCardPayout {
|
||||||
@@ -72,6 +79,9 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement {
|
||||||
|
amount = cloneMoney(providerSettlementAmount)
|
||||||
|
}
|
||||||
if amount == nil && action != model.RailOperationObserveConfirm {
|
if amount == nil && action != model.RailOperationObserveConfirm {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -100,6 +110,9 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment
|
|||||||
checkAmount := amount
|
checkAmount := amount
|
||||||
if action == model.RailOperationObserveConfirm {
|
if action == model.RailOperationObserveConfirm {
|
||||||
checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount)
|
checkAmount = observeAmountForRail(tpl.Rail, sourceSendAmount, settlementAmount, payoutAmount)
|
||||||
|
if tpl.Rail == model.RailProviderSettlement {
|
||||||
|
checkAmount = cloneMoney(providerSettlementAmount)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail))
|
gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, tpl.Rail, network, checkAmount, action, instanceID, sendDirectionForRail(tpl.Rail))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -286,6 +299,48 @@ func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *orchest
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func netSettlementAmount(settlementAmount, feeAmount *paymenttypes.Money, quote *orchestratorv1.PaymentQuote) (*paymenttypes.Money, error) {
|
||||||
|
if settlementAmount == nil {
|
||||||
|
return nil, merrors.InvalidArgument("plan builder: settlement amount is required")
|
||||||
|
}
|
||||||
|
netAmount := cloneMoney(settlementAmount)
|
||||||
|
if !isPositiveMoney(feeAmount) {
|
||||||
|
return netAmount, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
currency := strings.TrimSpace(settlementAmount.GetCurrency())
|
||||||
|
if currency == "" {
|
||||||
|
return netAmount, nil
|
||||||
|
}
|
||||||
|
var fxQuote *oraclev1.Quote
|
||||||
|
if quote != nil {
|
||||||
|
fxQuote = quote.GetFxQuote()
|
||||||
|
}
|
||||||
|
convertedFee, err := ensureCurrency(protoMoney(feeAmount), currency, fxQuote)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if convertedFee == nil {
|
||||||
|
return netAmount, nil
|
||||||
|
}
|
||||||
|
settlementValue, err := decimalFromMoney(settlementAmount)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
feeValue, err := decimalFromMoney(convertedFee)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
netValue := settlementValue.Sub(feeValue)
|
||||||
|
if netValue.IsNegative() {
|
||||||
|
return nil, merrors.InvalidArgument("plan builder: fee exceeds settlement amount")
|
||||||
|
}
|
||||||
|
return &paymenttypes.Money{
|
||||||
|
Currency: currency,
|
||||||
|
Amount: netValue.String(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) {
|
func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) {
|
||||||
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" {
|
||||||
return nil, merrors.InvalidArgument("plan builder: " + label + " is required")
|
return nil, merrors.InvalidArgument("plan builder: " + label + " is required")
|
||||||
|
|||||||
@@ -189,6 +189,9 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1
|
|||||||
|
|
||||||
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
|
func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*oraclev1.Quote, error) {
|
||||||
if !s.deps.oracle.available() {
|
if !s.deps.oracle.available() {
|
||||||
|
if req.GetIntent().GetRequiresFx() {
|
||||||
|
return nil, merrors.Internal("fx_oracle_unavailable")
|
||||||
|
}
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
intent := req.GetIntent()
|
intent := req.GetIntent()
|
||||||
@@ -243,6 +246,12 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches
|
|||||||
s.logger.Warn("fx oracle quote failed", zap.Error(err))
|
s.logger.Warn("fx oracle quote failed", zap.Error(err))
|
||||||
return nil, merrors.Internal("fx_quote_failed")
|
return nil, merrors.Internal("fx_quote_failed")
|
||||||
}
|
}
|
||||||
|
if quote == nil {
|
||||||
|
if intent.GetRequiresFx() {
|
||||||
|
return nil, merrors.Internal("fx_quote_missing")
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
return quoteToProto(quote), nil
|
return quoteToProto(quote), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,10 +2,13 @@ package orchestrator
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||||
|
"github.com/tech/sendico/pkg/merrors"
|
||||||
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1"
|
||||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||||
@@ -65,3 +68,81 @@ func TestRequestFXQuoteUsesQuoteAmountWhenCurrencyMatchesQuote(t *testing.T) {
|
|||||||
t.Fatalf("expected quote amount currency USD, got %s", captured.QuoteAmount.GetCurrency())
|
t.Fatalf("expected quote amount currency USD, got %s", captured.QuoteAmount.GetCurrency())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRequestFXQuoteFailsWhenRequiredAndOracleUnavailable(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc := &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
clock: testClock{now: time.Now()},
|
||||||
|
deps: serviceDependencies{
|
||||||
|
oracle: oracleDependency{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &orchestratorv1.QuotePaymentRequest{
|
||||||
|
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||||
|
Intent: &orchestratorv1.PaymentIntent{
|
||||||
|
RequiresFx: true,
|
||||||
|
Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"},
|
||||||
|
SettlementCurrency: "RUB",
|
||||||
|
Fx: &orchestratorv1.FXIntent{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||||
|
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.requestFXQuote(ctx, "org", req); err == nil {
|
||||||
|
t.Fatal("expected error when FX is required and oracle is unavailable")
|
||||||
|
} else {
|
||||||
|
if !errors.Is(err, merrors.ErrInternal) {
|
||||||
|
t.Fatalf("expected internal error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "fx_oracle_unavailable") {
|
||||||
|
t.Fatalf("expected fx_oracle_unavailable error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequestFXQuoteFailsWhenRequiredAndQuoteMissing(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
svc := &Service{
|
||||||
|
logger: zap.NewNop(),
|
||||||
|
clock: testClock{now: time.Now()},
|
||||||
|
deps: serviceDependencies{
|
||||||
|
oracle: oracleDependency{
|
||||||
|
client: &oracleclient.Fake{
|
||||||
|
GetQuoteFn: func(ctx context.Context, params oracleclient.GetQuoteParams) (*oracleclient.Quote, error) {
|
||||||
|
return nil, nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
req := &orchestratorv1.QuotePaymentRequest{
|
||||||
|
Meta: &orchestratorv1.RequestMeta{OrganizationRef: "org"},
|
||||||
|
Intent: &orchestratorv1.PaymentIntent{
|
||||||
|
RequiresFx: true,
|
||||||
|
Amount: &moneyv1.Money{Currency: "USDT", Amount: "1"},
|
||||||
|
SettlementCurrency: "RUB",
|
||||||
|
Fx: &orchestratorv1.FXIntent{
|
||||||
|
Pair: &fxv1.CurrencyPair{Base: "USDT", Quote: "RUB"},
|
||||||
|
Side: fxv1.Side_SELL_BASE_BUY_QUOTE,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := svc.requestFXQuote(ctx, "org", req); err == nil {
|
||||||
|
t.Fatal("expected error when FX quote is missing")
|
||||||
|
} else {
|
||||||
|
if !errors.Is(err, merrors.ErrInternal) {
|
||||||
|
t.Fatalf("expected internal error, got %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(err.Error(), "fx_quote_missing") {
|
||||||
|
t.Fatalf("expected fx_quote_missing error, got %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user