From d7e029d42161fc1450dcc483a0f3653edb30630c Mon Sep 17 00:00:00 2001 From: Stephan D Date: Fri, 23 Jan 2026 11:50:41 +0100 Subject: [PATCH] fx fix --- api/fx/storage/mongo/store/quotes.go | 2 +- .../orchestrator/plan_builder_default_test.go | 63 +++++++++++++++ .../orchestrator/plan_builder_steps.go | 55 +++++++++++++ .../service/orchestrator/quote_engine.go | 9 +++ .../orchestrator/quote_request_test.go | 81 +++++++++++++++++++ 5 files changed, 209 insertions(+), 1 deletion(-) diff --git a/api/fx/storage/mongo/store/quotes.go b/api/fx/storage/mongo/store/quotes.go index 228a7fec..20390d8a 100644 --- a/api/fx/storage/mongo/store/quotes.go +++ b/api/fx/storage/mongo/store/quotes.go @@ -40,7 +40,7 @@ func NewQuotes(logger mlogger.Logger, db *mongo.Database, txFactory transaction. Unique: true, Name: "quotes_meta_org_idempotency_key", 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), }, { diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go index 23ae5afb..f1789ab1 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go @@ -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) { ctx := context.Background() builder := &defaultPlanBuilder{} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go index 76058c8c..9a4b81b9 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go @@ -34,6 +34,13 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment if err != nil { 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 if destRail == model.RailCardPayout { @@ -72,6 +79,9 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment if err != nil { return nil, err } + if action == model.RailOperationSend && tpl.Rail == model.RailProviderSettlement { + amount = cloneMoney(providerSettlementAmount) + } if amount == nil && action != model.RailOperationObserveConfirm { continue } @@ -100,6 +110,9 @@ func (b *defaultPlanBuilder) buildPlanFromTemplate(ctx context.Context, payment checkAmount := amount if action == model.RailOperationObserveConfirm { 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)) if err != nil { @@ -286,6 +299,48 @@ func netSourceAmount(sourceAmount, feeAmount *paymenttypes.Money, quote *orchest }, 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) { if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { return nil, merrors.InvalidArgument("plan builder: " + label + " is required") diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go index c64d8e03..213ac335 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go @@ -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) { if !s.deps.oracle.available() { + if req.GetIntent().GetRequiresFx() { + return nil, merrors.Internal("fx_oracle_unavailable") + } return nil, nil } 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)) 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 } diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go index 553dfe87..456965f3 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_request_test.go @@ -2,10 +2,13 @@ package orchestrator import ( "context" + "errors" + "strings" "testing" "time" oracleclient "github.com/tech/sendico/fx/oracle/client" + "github.com/tech/sendico/pkg/merrors" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/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()) } } + +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) + } + } +} -- 2.49.1