diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go index 3d9126a0..dd9523de 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/indexes.go @@ -19,6 +19,7 @@ func requiredIndexes() []*indexDefinition { Keys: []ri.Key{ {Field: "paymentRef", Sort: ri.Asc}, }, + Unique: true, }, { Keys: []ri.Key{ diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go index a5370604..fc504b3b 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service_test.go @@ -32,7 +32,7 @@ func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) { } assertIndex(t, store.indexes[0], []string{"organizationRef", "paymentRef"}, true) - assertIndex(t, store.indexes[1], []string{"paymentRef"}, false) + assertIndex(t, store.indexes[1], []string{"paymentRef"}, true) assertIndex(t, store.indexes[2], []string{"organizationRef", "idempotencyKey"}, true) assertIndex(t, store.indexes[3], []string{"organizationRef", "quotationRef", "createdAt"}, false) assertIndex(t, store.indexes[4], []string{"organizationRef", "state", "createdAt"}, false) diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go index 053a3aae..6bebc067 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime.go @@ -472,6 +472,25 @@ func (s *Service) pollObserveCandidate(ctx context.Context, payment *agg.Payment } func (s *Service) resolveObserveGateway(ctx context.Context, payment *agg.Payment, candidate runningObserveCandidate) (*model.GatewayInstanceDescriptor, error) { + if gatewayID := strings.TrimSpace(candidate.gatewayInstanceID); gatewayID != "" { + items, err := s.gatewayRegistry.List(ctx) + if err == nil { + for i := range items { + item := items[i] + if item == nil || !item.IsEnabled { + continue + } + if !strings.EqualFold(strings.TrimSpace(item.ID), gatewayID) && !strings.EqualFold(strings.TrimSpace(item.InstanceID), gatewayID) { + continue + } + if strings.TrimSpace(item.InvokeURI) == "" { + continue + } + return item, nil + } + } + } + executor := gatewayCryptoExecutor{ gatewayRegistry: s.gatewayRegistry, } diff --git a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go index 3edb11f1..6c253042 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/external_runtime_test.go @@ -9,8 +9,10 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/prepo" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" + "github.com/tech/sendico/payments/storage/model" pm "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/payments/rail" + paymenttypes "github.com/tech/sendico/pkg/payments/types" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" "go.mongodb.org/mongo-driver/v2/bson" @@ -271,5 +273,59 @@ func TestRunningObserveCandidates(t *testing.T) { } } +func TestResolveObserveGateway_UsesExternalRefGatewayInstanceAcrossRails(t *testing.T) { + svc := &Service{ + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + Rail: model.RailProviderSettlement, + InvokeURI: "grpc://tgsettle-gateway", + IsEnabled: true, + }, + { + ID: "crypto_rail_gateway_tron_nile", + InstanceID: "crypto_rail_gateway_tron_nile", + Rail: model.RailCrypto, + InvokeURI: "grpc://tron-gateway", + IsEnabled: true, + }, + }, + }, + } + + payment := &agg.Payment{ + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + { + Index: 1, + Rail: "CRYPTO", + Gateway: "crypto_rail_gateway_tron_nile", + InstanceID: "crypto_rail_gateway_tron_nile", + Role: paymenttypes.QuoteRouteHopRoleSource, + }, + }, + }, + }, + } + + gateway, err := svc.resolveObserveGateway(context.Background(), payment, runningObserveCandidate{ + stepRef: "hop_2_settlement_observe", + transferRef: "trf-1", + gatewayInstanceID: "payment_gateway_settlement", + }) + if err != nil { + t.Fatalf("resolveObserveGateway returned error: %v", err) + } + if gateway == nil { + t.Fatal("expected gateway") + } + if got, want := gateway.ID, "payment_gateway_settlement"; got != want { + t.Fatalf("gateway id mismatch: got=%q want=%q", got, want) + } +} + var _ prepo.Repository = (*fakeExternalRuntimeRepo)(nil) var _ psvc.Service = (*fakeExternalRuntimeV2)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go index 47b8ece2..03ed9a66 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2.go @@ -35,9 +35,9 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r return nil, nil, merrors.Internal("No repo for orchestrator v2 provided") } - paymentRepo := buildPaymentRepositoryV2(repo, logger) - if paymentRepo == nil { - logger.Error("Orchestration v2 disabled: database not available") + paymentRepo, err := buildPaymentRepositoryV2(repo, logger) + if paymentRepo == nil || err != nil { + logger.Error("Orchestration v2 disabled: database not available", zap.Error(err)) return nil, nil, merrors.Internal("database is not available") } @@ -81,37 +81,39 @@ func buildOrchestrationV2Executors(logger mlogger.Logger, runtimeDeps v2RuntimeD gatewayRegistry: runtimeDeps.GatewayRegistry, cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes), } + providerSettlementExecutor := &gatewayProviderSettlementExecutor{ + gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, + gatewayRegistry: runtimeDeps.GatewayRegistry, + } guardExecutor := &gatewayGuardExecutor{ logger: execLogger.Named("guard"), gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, gatewayRegistry: runtimeDeps.GatewayRegistry, } return psvc.NewDefaultExecutors(execLogger, sexec.Dependencies{ - Crypto: cryptoExecutor, - Guard: guardExecutor, + Crypto: cryptoExecutor, + ProviderSettlement: providerSettlementExecutor, + Guard: guardExecutor, }) } -func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) prepo.Repository { +func buildPaymentRepositoryV2(repo storage.Repository, logger mlogger.Logger) (prepo.Repository, error) { if repo == nil { - return nil + return nil, merrors.InvalidArgument("repo must be provided") } provider, ok := repo.(v2MongoDBProvider) if !ok { - return nil + return nil, merrors.Internal("Failed to fetch correct repository interface") } db := provider.MongoDatabase() if db == nil { - return nil + return nil, merrors.Internal("Failed to fetch database") } paymentRepo, err := prepo.NewMongo( db.Collection(mservice.Payments), prepo.Dependencies{Logger: logger}, ) - if err != nil { - return nil - } - return paymentRepo + return paymentRepo, err } type v2GRPCServer struct { diff --git a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go new file mode 100644 index 00000000..0490787f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor.go @@ -0,0 +1,218 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +const ( + settlementMetadataQuoteRef = "quote_ref" + settlementMetadataOutgoingLeg = "outgoing_leg" +) + +type gatewayProviderSettlementExecutor struct { + gatewayInvokeResolver GatewayInvokeResolver + gatewayRegistry GatewayRegistry +} + +func (e *gatewayProviderSettlementExecutor) ExecuteProviderSettlement(ctx context.Context, req sexec.StepRequest) (*sexec.ExecuteOutput, error) { + if req.Payment == nil { + return nil, merrors.InvalidArgument("settlement fx_convert: payment is required") + } + if model.ParseRailOperation(string(req.Step.Action)) != model.RailOperationFXConvert { + return nil, merrors.InvalidArgument("settlement fx_convert: unsupported action") + } + + gateway, err := e.resolveGateway(ctx, req.Step) + if err != nil { + return nil, err + } + client, err := e.gatewayInvokeResolver.Resolve(ctx, gateway.InvokeURI) + if err != nil { + return nil, err + } + + sourceWalletRef, err := sourceManagedWalletRef(req.Payment) + if err != nil { + return nil, err + } + destination := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ + ManagedWalletRef: sourceWalletRef, + }, + } + + amount, err := settlementAmount(req.Payment) + if err != nil { + return nil, err + } + + stepRef := strings.TrimSpace(req.Step.StepRef) + operationRef := strings.TrimSpace(req.Payment.PaymentRef) + ":" + stepRef + idempotencyKey := strings.TrimSpace(req.Payment.IdempotencyKey) + if idempotencyKey == "" { + idempotencyKey = operationRef + } + idempotencyKey += ":" + stepRef + + resp, err := client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: idempotencyKey, + OrganizationRef: req.Payment.OrganizationRef.Hex(), + SourceWalletRef: sourceWalletRef, + Destination: destination, + Amount: amount, + OperationRef: operationRef, + IntentRef: strings.TrimSpace(req.Payment.IntentSnapshot.Ref), + PaymentRef: strings.TrimSpace(req.Payment.PaymentRef), + Metadata: settlementTransferMetadata(req.Payment, req.Step), + }) + if err != nil { + return nil, err + } + if resp == nil || resp.GetTransfer() == nil { + return nil, merrors.Internal("settlement fx_convert: transfer response is missing") + } + + step := req.StepExecution + refs, refsErr := transferExternalRefs(resp.GetTransfer(), firstNonEmpty( + strings.TrimSpace(req.Step.InstanceID), + strings.TrimSpace(gateway.InstanceID), + strings.TrimSpace(req.Step.Gateway), + strings.TrimSpace(gateway.ID), + )) + if refsErr != nil { + return nil, refsErr + } + step.ExternalRefs = refs + step.State = agg.StepStateCompleted + step.FailureCode = "" + step.FailureMsg = "" + return &sexec.ExecuteOutput{StepExecution: step}, nil +} + +func (e *gatewayProviderSettlementExecutor) resolveGateway(ctx context.Context, step xplan.Step) (*model.GatewayInstanceDescriptor, error) { + if e.gatewayRegistry == nil { + return nil, merrors.InvalidArgument("settlement fx_convert: gateway registry is required") + } + items, err := e.gatewayRegistry.List(ctx) + if err != nil { + return nil, err + } + + stepGateway := strings.TrimSpace(step.Gateway) + stepInstance := strings.TrimSpace(step.InstanceID) + + var byInstance *model.GatewayInstanceDescriptor + var byGateway *model.GatewayInstanceDescriptor + var single *model.GatewayInstanceDescriptor + settlementCount := 0 + for i := range items { + item := items[i] + if item == nil || model.ParseRail(string(item.Rail)) != model.RailProviderSettlement || !item.IsEnabled { + continue + } + settlementCount++ + single = item + if stepInstance != "" && (strings.EqualFold(strings.TrimSpace(item.InstanceID), stepInstance) || strings.EqualFold(strings.TrimSpace(item.ID), stepInstance)) { + byInstance = item + break + } + if stepGateway != "" && (strings.EqualFold(strings.TrimSpace(item.ID), stepGateway) || strings.EqualFold(strings.TrimSpace(item.InstanceID), stepGateway)) { + byGateway = item + } + } + switch { + case byInstance != nil: + if strings.TrimSpace(byInstance.InvokeURI) == "" { + return nil, merrors.InvalidArgument("settlement fx_convert: gateway invoke uri is missing") + } + return byInstance, nil + case byGateway != nil: + if strings.TrimSpace(byGateway.InvokeURI) == "" { + return nil, merrors.InvalidArgument("settlement fx_convert: gateway invoke uri is missing") + } + return byGateway, nil + case stepGateway == "" && stepInstance == "" && settlementCount == 1: + if strings.TrimSpace(single.InvokeURI) == "" { + return nil, merrors.InvalidArgument("settlement fx_convert: gateway invoke uri is missing") + } + return single, nil + default: + return nil, merrors.InvalidArgument("settlement fx_convert: gateway instance not found") + } +} + +func settlementAmount(payment *agg.Payment) (*moneyv1.Money, error) { + if payment == nil { + return nil, merrors.InvalidArgument("settlement fx_convert: payment is required") + } + + money := sourceAmountForSettlement(payment) + if money == nil { + return nil, merrors.InvalidArgument("settlement fx_convert: debit amount is required") + } + amount := strings.TrimSpace(money.Amount) + currency := strings.TrimSpace(money.Currency) + if amount == "" || currency == "" { + return nil, merrors.InvalidArgument("settlement fx_convert: debit amount is invalid") + } + return &moneyv1.Money{ + Amount: amount, + Currency: currency, + }, nil +} + +func sourceAmountForSettlement(payment *agg.Payment) *moneyv1.Money { + if payment != nil && payment.QuoteSnapshot != nil && payment.QuoteSnapshot.DebitAmount != nil { + return &moneyv1.Money{ + Amount: strings.TrimSpace(payment.QuoteSnapshot.DebitAmount.Amount), + Currency: strings.TrimSpace(payment.QuoteSnapshot.DebitAmount.Currency), + } + } + if payment != nil && payment.IntentSnapshot.Amount != nil { + return &moneyv1.Money{ + Amount: strings.TrimSpace(payment.IntentSnapshot.Amount.Amount), + Currency: strings.TrimSpace(payment.IntentSnapshot.Amount.Currency), + } + } + return nil +} + +func settlementTransferMetadata(payment *agg.Payment, step xplan.Step) map[string]string { + out := transferMetadata(step) + if out == nil { + out = map[string]string{} + } + if payment != nil { + if quoteRef := firstNonEmpty( + strings.TrimSpace(payment.QuotationRef), + strings.TrimSpace(quoteRefFromSnapshot(payment.QuoteSnapshot)), + ); quoteRef != "" { + out[settlementMetadataQuoteRef] = quoteRef + } + } + if outgoingLeg := strings.TrimSpace(string(step.Rail)); outgoingLeg != "" { + out[settlementMetadataOutgoingLeg] = outgoingLeg + } + if len(out) == 0 { + return nil + } + return out +} + +func quoteRefFromSnapshot(snapshot *model.PaymentQuoteSnapshot) string { + if snapshot == nil { + return "" + } + return strings.TrimSpace(snapshot.QuoteRef) +} + +var _ sexec.ProviderSettlementExecutor = (*gatewayProviderSettlementExecutor)(nil) diff --git a/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go new file mode 100644 index 00000000..1d6cf0c6 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/settlement_executor_test.go @@ -0,0 +1,191 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + chainclient "github.com/tech/sendico/gateway/chain/client" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/sexec" + "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" + "github.com/tech/sendico/payments/storage/model" + pm "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "go.mongodb.org/mongo-driver/v2/bson" +) + +func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_SubmitsTransfer(t *testing.T) { + orgID := bson.NewObjectID() + + var submitReq *chainv1.SubmitTransferRequest + client := &chainclient.Fake{ + SubmitTransferFn: func(_ context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { + submitReq = req + return &chainv1.SubmitTransferResponse{ + Transfer: &chainv1.Transfer{ + TransferRef: "trf-settlement-1", + OperationRef: "op-settlement-1", + }, + }, nil + }, + } + resolver := &fakeGatewayInvokeResolver{client: client} + registry := &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + Rail: model.RailProviderSettlement, + InvokeURI: "grpc://tgsettle-gateway", + IsEnabled: true, + }, + }, + } + executor := &gatewayProviderSettlementExecutor{ + gatewayInvokeResolver: resolver, + gatewayRegistry: registry, + } + + req := sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-1", + IdempotencyKey: "idem-1", + QuotationRef: "quote-1", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-1", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{Pan: "4111111111111111"}, + }, + Amount: &paymenttypes.Money{Amount: "1", Currency: "USDT"}, + }, + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + DebitAmount: &paymenttypes.Money{Amount: "1.000000", Currency: "USDT"}, + ExpectedSettlementAmount: &paymenttypes.Money{Amount: "76.63", Currency: "RUB"}, + QuoteRef: "quote-1", + }, + }, + Step: xplan.Step{ + StepRef: "hop_2_settlement_fx_convert", + StepCode: "hop.2.settlement.fx_convert", + Action: model.RailOperationFXConvert, + Rail: model.RailProviderSettlement, + Gateway: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_2_settlement_fx_convert", + StepCode: "hop.2.settlement.fx_convert", + Attempt: 1, + }, + } + + out, err := executor.ExecuteProviderSettlement(context.Background(), req) + if err != nil { + t.Fatalf("ExecuteProviderSettlement returned error: %v", err) + } + if out == nil { + t.Fatal("expected output") + } + if out.StepExecution.State != agg.StepStateCompleted { + t.Fatalf("expected completed state, got=%q", out.StepExecution.State) + } + if submitReq == nil { + t.Fatal("expected transfer submission request") + } + if got, want := resolver.lastInvokeURI, "grpc://tgsettle-gateway"; got != want { + t.Fatalf("invoke uri mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetSourceWalletRef(), "wallet-src"; got != want { + t.Fatalf("source wallet mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetDestination().GetManagedWalletRef(), "wallet-src"; got != want { + t.Fatalf("destination mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetAmount().GetAmount(), "1.000000"; got != want { + t.Fatalf("amount mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetAmount().GetCurrency(), "USDT"; got != want { + t.Fatalf("currency mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetMetadata()[settlementMetadataQuoteRef], "quote-1"; got != want { + t.Fatalf("quote_ref metadata mismatch: got=%q want=%q", got, want) + } + if got, want := submitReq.GetMetadata()[settlementMetadataOutgoingLeg], string(model.RailProviderSettlement); got != want { + t.Fatalf("outgoing_leg metadata mismatch: got=%q want=%q", got, want) + } + if len(out.StepExecution.ExternalRefs) != 2 { + t.Fatalf("expected two external refs, got=%d", len(out.StepExecution.ExternalRefs)) + } + if out.StepExecution.ExternalRefs[0].Kind != erecon.ExternalRefKindOperation { + t.Fatalf("unexpected first external ref kind: %q", out.StepExecution.ExternalRefs[0].Kind) + } +} + +func TestGatewayProviderSettlementExecutor_ExecuteProviderSettlement_MissingSettlementAmount(t *testing.T) { + orgID := bson.NewObjectID() + + executor := &gatewayProviderSettlementExecutor{ + gatewayInvokeResolver: &fakeGatewayInvokeResolver{ + client: &chainclient.Fake{}, + }, + gatewayRegistry: &fakeGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + Rail: model.RailProviderSettlement, + InvokeURI: "grpc://tgsettle-gateway", + IsEnabled: true, + }, + }, + }, + } + + _, err := executor.ExecuteProviderSettlement(context.Background(), sexec.StepRequest{ + Payment: &agg.Payment{ + OrganizationBoundBase: pm.OrganizationBoundBase{OrganizationRef: orgID}, + PaymentRef: "payment-2", + IdempotencyKey: "idem-2", + IntentSnapshot: model.PaymentIntent{ + Ref: "intent-2", + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + }, + QuoteSnapshot: nil, + }, + Step: xplan.Step{ + StepRef: "hop_2_settlement_fx_convert", + StepCode: "hop.2.settlement.fx_convert", + Action: model.RailOperationFXConvert, + Rail: model.RailProviderSettlement, + Gateway: "payment_gateway_settlement", + InstanceID: "payment_gateway_settlement", + }, + StepExecution: agg.StepExecution{ + StepRef: "hop_2_settlement_fx_convert", + StepCode: "hop.2.settlement.fx_convert", + Attempt: 1, + }, + }) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), "debit amount is required") { + t.Fatalf("unexpected error: %v", err) + } +}