+implementation of settlement step execution

This commit is contained in:
Stephan D
2026-02-25 20:09:45 +01:00
parent af4b68f4c7
commit 26bedc5743
7 changed files with 501 additions and 14 deletions

View File

@@ -19,6 +19,7 @@ func requiredIndexes() []*indexDefinition {
Keys: []ri.Key{ Keys: []ri.Key{
{Field: "paymentRef", Sort: ri.Asc}, {Field: "paymentRef", Sort: ri.Asc},
}, },
Unique: true,
}, },
{ {
Keys: []ri.Key{ Keys: []ri.Key{

View File

@@ -32,7 +32,7 @@ func TestNewWithStore_EnsuresRequiredIndexes(t *testing.T) {
} }
assertIndex(t, store.indexes[0], []string{"organizationRef", "paymentRef"}, true) 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[2], []string{"organizationRef", "idempotencyKey"}, true)
assertIndex(t, store.indexes[3], []string{"organizationRef", "quotationRef", "createdAt"}, false) assertIndex(t, store.indexes[3], []string{"organizationRef", "quotationRef", "createdAt"}, false)
assertIndex(t, store.indexes[4], []string{"organizationRef", "state", "createdAt"}, false) assertIndex(t, store.indexes[4], []string{"organizationRef", "state", "createdAt"}, false)

View File

@@ -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) { 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{ executor := gatewayCryptoExecutor{
gatewayRegistry: s.gatewayRegistry, gatewayRegistry: s.gatewayRegistry,
} }

View File

@@ -9,8 +9,10 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/erecon" "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/prepo"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/psvc"
"github.com/tech/sendico/payments/storage/model"
pm "github.com/tech/sendico/pkg/model" pm "github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/payments/rail" "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" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"go.mongodb.org/mongo-driver/v2/bson" "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 _ prepo.Repository = (*fakeExternalRuntimeRepo)(nil)
var _ psvc.Service = (*fakeExternalRuntimeV2)(nil) var _ psvc.Service = (*fakeExternalRuntimeV2)(nil)

View File

@@ -35,9 +35,9 @@ func newOrchestrationV2Service(logger mlogger.Logger, repo storage.Repository, r
return nil, nil, merrors.Internal("No repo for orchestrator v2 provided") return nil, nil, merrors.Internal("No repo for orchestrator v2 provided")
} }
paymentRepo := buildPaymentRepositoryV2(repo, logger) paymentRepo, err := buildPaymentRepositoryV2(repo, logger)
if paymentRepo == nil { if paymentRepo == nil || err != nil {
logger.Error("Orchestration v2 disabled: database not available") logger.Error("Orchestration v2 disabled: database not available", zap.Error(err))
return nil, nil, merrors.Internal("database is not available") return nil, nil, merrors.Internal("database is not available")
} }
@@ -81,6 +81,10 @@ func buildOrchestrationV2Executors(logger mlogger.Logger, runtimeDeps v2RuntimeD
gatewayRegistry: runtimeDeps.GatewayRegistry, gatewayRegistry: runtimeDeps.GatewayRegistry,
cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes), cardGatewayRoutes: cloneCardGatewayRoutes(runtimeDeps.CardGatewayRoutes),
} }
providerSettlementExecutor := &gatewayProviderSettlementExecutor{
gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver,
gatewayRegistry: runtimeDeps.GatewayRegistry,
}
guardExecutor := &gatewayGuardExecutor{ guardExecutor := &gatewayGuardExecutor{
logger: execLogger.Named("guard"), logger: execLogger.Named("guard"),
gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver, gatewayInvokeResolver: runtimeDeps.GatewayInvokeResolver,
@@ -88,30 +92,28 @@ func buildOrchestrationV2Executors(logger mlogger.Logger, runtimeDeps v2RuntimeD
} }
return psvc.NewDefaultExecutors(execLogger, sexec.Dependencies{ return psvc.NewDefaultExecutors(execLogger, sexec.Dependencies{
Crypto: cryptoExecutor, Crypto: cryptoExecutor,
ProviderSettlement: providerSettlementExecutor,
Guard: guardExecutor, 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 { if repo == nil {
return nil return nil, merrors.InvalidArgument("repo must be provided")
} }
provider, ok := repo.(v2MongoDBProvider) provider, ok := repo.(v2MongoDBProvider)
if !ok { if !ok {
return nil return nil, merrors.Internal("Failed to fetch correct repository interface")
} }
db := provider.MongoDatabase() db := provider.MongoDatabase()
if db == nil { if db == nil {
return nil return nil, merrors.Internal("Failed to fetch database")
} }
paymentRepo, err := prepo.NewMongo( paymentRepo, err := prepo.NewMongo(
db.Collection(mservice.Payments), db.Collection(mservice.Payments),
prepo.Dependencies{Logger: logger}, prepo.Dependencies{Logger: logger},
) )
if err != nil { return paymentRepo, err
return nil
}
return paymentRepo
} }
type v2GRPCServer struct { type v2GRPCServer struct {

View File

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

View File

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