quotation service fixed
This commit is contained in:
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
pm "github.com/tech/sendico/pkg/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Factory builds initial orchestration-v2 payment aggregates.
|
||||
@@ -102,8 +103,12 @@ func New(deps ...Dependencies) Factory {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{
|
||||
logger: dep.Logger.Named("agg"),
|
||||
logger: logger.Named("agg"),
|
||||
now: func() time.Time { return time.Now().UTC() },
|
||||
newID: func() bson.ObjectID {
|
||||
return bson.NewObjectID()
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestCreate_OK(t *testing.T) {
|
||||
@@ -17,7 +18,8 @@ func TestCreate_OK(t *testing.T) {
|
||||
paymentID := bson.NewObjectID()
|
||||
|
||||
factory := &svc{
|
||||
now: func() time.Time { return now },
|
||||
logger: zap.NewNop(),
|
||||
now: func() time.Time { return now },
|
||||
newID: func() bson.ObjectID {
|
||||
return paymentID
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Reconciler applies external async events to payment runtime state.
|
||||
@@ -130,12 +131,16 @@ func New(deps ...Dependencies) Reconciler {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
now := dep.Now
|
||||
if now == nil {
|
||||
now = defaultNow
|
||||
}
|
||||
return &svc{
|
||||
logger: dep.Logger.Named("erecon"),
|
||||
logger: logger.Named("erecon"),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,11 +7,12 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := &svc{now: func() time.Time { return now }}
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
in := &agg.Payment{
|
||||
PaymentRef: "p1",
|
||||
@@ -74,7 +75,7 @@ func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
|
||||
|
||||
func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := &svc{now: func() time.Time { return now }}
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
@@ -110,7 +111,7 @@ func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) {
|
||||
|
||||
func TestReconcile_GatewayFailureMapping(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := &svc{now: func() time.Time { return now }}
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
retryable := true
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
@@ -178,7 +179,7 @@ func TestReconcile_GatewayFailureMapping(t *testing.T) {
|
||||
|
||||
func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC)
|
||||
reconciler := &svc{now: func() time.Time { return now }}
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
@@ -212,7 +213,7 @@ func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReconcile_CardMatchByExternalRef(t *testing.T) {
|
||||
reconciler := &svc{now: defaultNow}
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow})
|
||||
|
||||
out, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
@@ -246,7 +247,7 @@ func TestReconcile_CardMatchByExternalRef(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReconcile_MatchingErrors(t *testing.T) {
|
||||
reconciler := &svc{now: defaultNow}
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow})
|
||||
|
||||
_, err := reconciler.Reconcile(Input{
|
||||
Payment: &agg.Payment{
|
||||
@@ -293,7 +294,7 @@ func TestReconcile_MatchingErrors(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestReconcile_ValidationErrors(t *testing.T) {
|
||||
reconciler := &svc{now: defaultNow}
|
||||
reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Store is the minimal payment store contract required for idempotency handling.
|
||||
@@ -52,5 +53,9 @@ func New(deps ...Dependencies) Service {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("idem")}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{logger: logger.Named("idem")}
|
||||
}
|
||||
|
||||
@@ -25,7 +25,11 @@ func newService(deps Dependencies) (Observer, error) {
|
||||
store = newMemoryAuditStore()
|
||||
}
|
||||
|
||||
logger := deps.Logger.Named("oobs")
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("oobs")
|
||||
|
||||
metrics := deps.Metrics
|
||||
if metrics == nil {
|
||||
|
||||
@@ -101,7 +101,7 @@ func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuo
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"},
|
||||
{Index: 3, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"},
|
||||
{Index: 3, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"},
|
||||
},
|
||||
Settlement: &paymenttypes.QuoteRouteSettlement{
|
||||
Model: "fix_source",
|
||||
|
||||
@@ -3,6 +3,7 @@ package opagg
|
||||
import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Aggregator compacts compatible quote items into recipient-level execution groups.
|
||||
@@ -45,5 +46,9 @@ func New(deps ...Dependencies) Aggregator {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("opagg")}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{logger: logger.Named("opagg")}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// StateMachine is the single source of truth for orchestration-v2 state transitions.
|
||||
@@ -26,5 +27,9 @@ func New(deps ...Dependencies) StateMachine {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("ostate")}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{logger: logger.Named("ostate")}
|
||||
}
|
||||
|
||||
@@ -48,8 +48,12 @@ func newService(deps Dependencies) (Service, error) {
|
||||
if deps.Repository == nil {
|
||||
return nil, merrors.InvalidArgument("payment repository v2 is required")
|
||||
}
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{
|
||||
logger: deps.Logger.Named("pquery"),
|
||||
logger: logger.Named("pquery"),
|
||||
repo: deps.Repository,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -49,6 +49,9 @@ func newWithStoreLogger(store paymentStore, logger mlogger.Logger) (Repository,
|
||||
if store == nil {
|
||||
return nil, merrors.InvalidArgument("payment repository v2: store is required")
|
||||
}
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
if err := store.EnsureIndexes(requiredIndexes()); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Mapper transforms orchestration-v2 runtime aggregate snapshots into API responses.
|
||||
@@ -32,5 +33,9 @@ func New(deps ...Dependencies) Mapper {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("prmap")}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{logger: logger.Named("prmap")}
|
||||
}
|
||||
|
||||
@@ -305,7 +305,7 @@ func newPaymentFixture() *agg.Payment {
|
||||
Firm: true,
|
||||
},
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Rail: "CARD_PAYOUT",
|
||||
Rail: "CARD",
|
||||
Provider: "provider-1",
|
||||
PayoutMethod: "CARD",
|
||||
Network: "VISA",
|
||||
@@ -326,7 +326,7 @@ func newPaymentFixture() *agg.Payment {
|
||||
},
|
||||
{
|
||||
Index: 20,
|
||||
Rail: "CARD_PAYOUT",
|
||||
Rail: "CARD",
|
||||
Gateway: "gw-card",
|
||||
InstanceID: "card-1",
|
||||
Role: paymenttypes.QuoteRouteHopRoleDestination,
|
||||
|
||||
@@ -107,11 +107,13 @@ func inferRail(kind string, stepCode string) gatewayv1.Rail {
|
||||
case strings.Contains(all, "ledger"):
|
||||
return gatewayv1.Rail_RAIL_LEDGER
|
||||
case strings.Contains(all, "card_payout"), strings.Contains(all, "card"):
|
||||
return gatewayv1.Rail_RAIL_CARD_PAYOUT
|
||||
case strings.Contains(all, "provider_settlement"), strings.Contains(all, "provider"):
|
||||
return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT
|
||||
return gatewayv1.Rail_RAIL_CARD
|
||||
case strings.Contains(all, "provider_settlement"),
|
||||
strings.Contains(all, "settlement"),
|
||||
strings.Contains(all, "provider"):
|
||||
return gatewayv1.Rail_RAIL_SETTLEMENT
|
||||
case strings.Contains(all, "fiat_onramp"), strings.Contains(all, "onramp"):
|
||||
return gatewayv1.Rail_RAIL_FIAT_ONRAMP
|
||||
return gatewayv1.Rail_RAIL_ONRAMP
|
||||
case strings.Contains(all, "crypto"), strings.Contains(all, "chain"), strings.Contains(all, "tx"):
|
||||
return gatewayv1.Rail_RAIL_CRYPTO
|
||||
default:
|
||||
|
||||
@@ -19,6 +19,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
@@ -57,7 +58,11 @@ func newService(deps Dependencies) (Service, error) {
|
||||
return nil, merrors.InvalidArgument("payment repository v2 is required")
|
||||
}
|
||||
|
||||
logger := deps.Logger.Named("psvc")
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
logger = logger.Named("psvc")
|
||||
|
||||
observer := deps.Observer
|
||||
if observer == nil {
|
||||
|
||||
@@ -600,7 +600,7 @@ func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification {
|
||||
|
||||
func buildCardRoute() *paymenttypes.QuoteRouteSpecification {
|
||||
return &paymenttypes.QuoteRouteSpecification{
|
||||
Rail: "CARD_PAYOUT",
|
||||
Rail: "CARD",
|
||||
Provider: "gw-card",
|
||||
Network: "visa",
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Store is the minimal quote store contract required by the resolver.
|
||||
@@ -45,12 +46,16 @@ func New(deps ...Dependencies) Resolver {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
now := dep.Now
|
||||
if now == nil {
|
||||
now = time.Now
|
||||
}
|
||||
return &svc{
|
||||
logger: dep.Logger.Named("qsnap"),
|
||||
logger: logger.Named("qsnap"),
|
||||
now: now,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestResolve_NotFound(t *testing.T) {
|
||||
@@ -29,9 +30,7 @@ func TestResolve_NotFound(t *testing.T) {
|
||||
|
||||
func TestResolve_Expired(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
@@ -58,9 +57,7 @@ func TestResolve_Expired(t *testing.T) {
|
||||
|
||||
func TestResolve_NotExecutableState(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
@@ -88,9 +85,7 @@ func TestResolve_NotExecutableState(t *testing.T) {
|
||||
|
||||
func TestResolve_NotExecutableExecutionNote(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
@@ -115,9 +110,7 @@ func TestResolve_NotExecutableExecutionNote(t *testing.T) {
|
||||
|
||||
func TestResolve_ShapeMismatch(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
|
||||
@@ -9,6 +9,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestResolve_SingleShapeOK(t *testing.T) {
|
||||
@@ -30,9 +31,7 @@ func TestResolve_SingleShapeOK(t *testing.T) {
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
out, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
@@ -89,9 +88,7 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
out, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
@@ -142,9 +139,7 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) {
|
||||
ExpiresAt: now.Add(time.Minute),
|
||||
}
|
||||
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
out, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
@@ -177,9 +172,7 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) {
|
||||
|
||||
func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
@@ -211,9 +204,7 @@ func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) {
|
||||
|
||||
func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) {
|
||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
resolver := &svc{
|
||||
now: func() time.Time { return now },
|
||||
}
|
||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
||||
|
||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
||||
|
||||
@@ -3,6 +3,7 @@ package reqval
|
||||
import (
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Validator validates execute-payment inputs and returns a normalized context.
|
||||
@@ -50,5 +51,9 @@ func New(deps ...Dependencies) Validator {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
return &svc{logger: dep.Logger.Named("reqval")}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{logger: logger.Named("reqval")}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Registry dispatches orchestration steps to rail/action-specific executors.
|
||||
@@ -44,7 +45,7 @@ type CryptoExecutor interface {
|
||||
ExecuteCrypto(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
|
||||
}
|
||||
|
||||
// ProviderSettlementExecutor handles provider settlement SEND actions.
|
||||
// ProviderSettlementExecutor handles settlement FX_CONVERT actions.
|
||||
type ProviderSettlementExecutor interface {
|
||||
ExecuteProviderSettlement(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
|
||||
}
|
||||
@@ -70,8 +71,12 @@ type Dependencies struct {
|
||||
}
|
||||
|
||||
func New(deps Dependencies) Registry {
|
||||
logger := deps.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{
|
||||
logger: deps.Logger.Named("sexec"),
|
||||
logger: logger.Named("sexec"),
|
||||
deps: deps,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package sexec
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
)
|
||||
@@ -29,13 +27,20 @@ func classifyRoute(step xplan.Step) route {
|
||||
switch rail {
|
||||
case model.RailCrypto:
|
||||
return routeCrypto
|
||||
case model.RailProviderSettlement:
|
||||
return routeProviderSettlement
|
||||
case model.RailCardPayout:
|
||||
return routeCardPayout
|
||||
default:
|
||||
return routeUnknown
|
||||
}
|
||||
case model.RailOperationFXConvert:
|
||||
switch rail {
|
||||
case model.RailProviderSettlement:
|
||||
return routeProviderSettlement
|
||||
case model.RailLedger:
|
||||
return routeLedger
|
||||
default:
|
||||
return routeUnknown
|
||||
}
|
||||
case model.RailOperationFee:
|
||||
if rail == model.RailCrypto {
|
||||
return routeCrypto
|
||||
@@ -57,8 +62,7 @@ func isLedgerAction(action model.RailOperation) bool {
|
||||
model.RailOperationExternalCredit,
|
||||
model.RailOperationMove,
|
||||
model.RailOperationBlock,
|
||||
model.RailOperationRelease,
|
||||
model.RailOperationFXConvert:
|
||||
model.RailOperationRelease:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
@@ -66,47 +70,9 @@ func isLedgerAction(action model.RailOperation) bool {
|
||||
}
|
||||
|
||||
func normalizeAction(action model.RailOperation) model.RailOperation {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(action))) {
|
||||
case string(model.RailOperationDebit):
|
||||
return model.RailOperationDebit
|
||||
case string(model.RailOperationCredit):
|
||||
return model.RailOperationCredit
|
||||
case string(model.RailOperationExternalDebit):
|
||||
return model.RailOperationExternalDebit
|
||||
case string(model.RailOperationExternalCredit):
|
||||
return model.RailOperationExternalCredit
|
||||
case string(model.RailOperationMove):
|
||||
return model.RailOperationMove
|
||||
case string(model.RailOperationSend):
|
||||
return model.RailOperationSend
|
||||
case string(model.RailOperationFee):
|
||||
return model.RailOperationFee
|
||||
case string(model.RailOperationObserveConfirm):
|
||||
return model.RailOperationObserveConfirm
|
||||
case string(model.RailOperationFXConvert):
|
||||
return model.RailOperationFXConvert
|
||||
case string(model.RailOperationBlock):
|
||||
return model.RailOperationBlock
|
||||
case string(model.RailOperationRelease):
|
||||
return model.RailOperationRelease
|
||||
default:
|
||||
return model.RailOperationUnspecified
|
||||
}
|
||||
return model.ParseRailOperation(string(action))
|
||||
}
|
||||
|
||||
func normalizeRail(rail model.Rail) model.Rail {
|
||||
switch strings.ToUpper(strings.TrimSpace(string(rail))) {
|
||||
case string(model.RailCrypto):
|
||||
return model.RailCrypto
|
||||
case string(model.RailProviderSettlement):
|
||||
return model.RailProviderSettlement
|
||||
case string(model.RailLedger):
|
||||
return model.RailLedger
|
||||
case string(model.RailCardPayout):
|
||||
return model.RailCardPayout
|
||||
case string(model.RailFiatOnRamp):
|
||||
return model.RailFiatOnRamp
|
||||
default:
|
||||
return model.RailUnspecified
|
||||
}
|
||||
return model.ParseRail(string(rail))
|
||||
}
|
||||
|
||||
@@ -63,13 +63,13 @@ func (s *svc) Execute(ctx context.Context, in ExecuteInput) (out *ExecuteOutput,
|
||||
return out, err
|
||||
case routeProviderSettlement:
|
||||
if s.deps.ProviderSettlement == nil {
|
||||
return nil, missingExecutorError("provider_settlement")
|
||||
return nil, missingExecutorError("settlement")
|
||||
}
|
||||
out, err = s.deps.ProviderSettlement.ExecuteProviderSettlement(ctx, req)
|
||||
return out, err
|
||||
case routeCardPayout:
|
||||
if s.deps.CardPayout == nil {
|
||||
return nil, missingExecutorError("card_payout")
|
||||
return nil, missingExecutorError("card")
|
||||
}
|
||||
out, err = s.deps.CardPayout.ExecuteCardPayout(ctx, req)
|
||||
return out, err
|
||||
|
||||
@@ -37,7 +37,7 @@ func TestExecute_DispatchLedger(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_DispatchSendRailsAndObserve(t *testing.T) {
|
||||
func TestExecute_DispatchRailsAndObserve(t *testing.T) {
|
||||
crypto := &fakeCryptoExecutor{}
|
||||
provider := &fakeProviderSettlementExecutor{}
|
||||
card := &fakeCardPayoutExecutor{}
|
||||
@@ -67,9 +67,9 @@ func TestExecute_DispatchSendRailsAndObserve(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "send provider settlement",
|
||||
name: "fx convert provider settlement",
|
||||
step: xplan.Step{
|
||||
StepRef: "s2", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement,
|
||||
StepRef: "s2", StepCode: "provider.fx_convert", Action: model.RailOperationFXConvert, Rail: model.RailProviderSettlement,
|
||||
},
|
||||
wantCalls: func(t *testing.T) {
|
||||
t.Helper()
|
||||
@@ -144,6 +144,19 @@ func TestExecute_UnsupportedStep(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_UnsupportedProviderSettlementSend(t *testing.T) {
|
||||
registry := New(Dependencies{})
|
||||
|
||||
_, err := registry.Execute(context.Background(), ExecuteInput{
|
||||
Payment: &agg.Payment{PaymentRef: "p1"},
|
||||
Step: xplan.Step{StepRef: "s1", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement},
|
||||
StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "provider.send", Attempt: 1},
|
||||
})
|
||||
if !errors.Is(err, ErrUnsupportedStep) {
|
||||
t.Fatalf("expected ErrUnsupportedStep, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecute_MissingExecutor(t *testing.T) {
|
||||
registry := New(Dependencies{})
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate"
|
||||
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Runtime selects runnable orchestration steps and reconciles step runtime states.
|
||||
@@ -73,9 +74,13 @@ func New(deps ...Dependencies) Runtime {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
stateMachine := dep.StateMachine
|
||||
if stateMachine == nil {
|
||||
stateMachine = ostate.New(ostate.Dependencies{Logger: dep.Logger.Named("ssched.ostate")})
|
||||
stateMachine = ostate.New(ostate.Dependencies{Logger: logger.Named("ssched.ostate")})
|
||||
}
|
||||
now := dep.Now
|
||||
if now == nil {
|
||||
@@ -84,7 +89,7 @@ func New(deps ...Dependencies) Runtime {
|
||||
}
|
||||
}
|
||||
return &svc{
|
||||
logger: dep.Logger.Named("ssched"),
|
||||
logger: logger.Named("ssched"),
|
||||
stateMachine: stateMachine,
|
||||
now: now,
|
||||
}
|
||||
|
||||
@@ -25,7 +25,7 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) {
|
||||
},
|
||||
{
|
||||
Index: 20,
|
||||
Rail: "CARD_PAYOUT",
|
||||
Rail: "CARD",
|
||||
Gateway: "gw-card",
|
||||
InstanceID: "card-1",
|
||||
Role: paymenttypes.QuoteRouteHopRoleDestination,
|
||||
@@ -101,7 +101,7 @@ func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T)
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
{Index: 20, Rail: "CARD", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -144,6 +144,64 @@ func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) {
|
||||
assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden)
|
||||
}
|
||||
|
||||
func TestCompile_ExternalViaSettlement_UsesFXConvertOnSettlementHop(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
graph, err := compiler.Compile(Input{
|
||||
IntentSnapshot: testIntent(model.PaymentKindPayout),
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{
|
||||
Index: 10,
|
||||
Rail: "CRYPTO",
|
||||
Gateway: "gw-crypto",
|
||||
InstanceID: "crypto-1",
|
||||
Role: paymenttypes.QuoteRouteHopRoleSource,
|
||||
},
|
||||
{
|
||||
Index: 20,
|
||||
Rail: "SETTLEMENT",
|
||||
Gateway: "gw-settlement",
|
||||
InstanceID: "settlement-1",
|
||||
Role: paymenttypes.QuoteRouteHopRoleTransit,
|
||||
},
|
||||
{
|
||||
Index: 30,
|
||||
Rail: "CARD",
|
||||
Gateway: "gw-card",
|
||||
InstanceID: "card-1",
|
||||
Role: paymenttypes.QuoteRouteHopRoleDestination,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Compile returned error: %v", err)
|
||||
}
|
||||
|
||||
convertCount := 0
|
||||
sendCount := 0
|
||||
for _, step := range graph.Steps {
|
||||
if step.Rail != model.RailProviderSettlement {
|
||||
continue
|
||||
}
|
||||
if step.Action == model.RailOperationFXConvert {
|
||||
convertCount++
|
||||
}
|
||||
if step.Action == model.RailOperationSend {
|
||||
sendCount++
|
||||
}
|
||||
}
|
||||
if convertCount == 0 {
|
||||
t.Fatalf("expected at least one settlement FX_CONVERT step")
|
||||
}
|
||||
if sendCount != 0 {
|
||||
t.Fatalf("expected no settlement SEND steps, got %d", sendCount)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompile_InternalToInternal_UsesMove(t *testing.T) {
|
||||
compiler := New()
|
||||
|
||||
@@ -176,7 +234,7 @@ func TestCompile_GuardsArePrepended(t *testing.T) {
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
{Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
|
||||
@@ -213,7 +271,7 @@ func TestCompile_SingleExternalFallback(t *testing.T) {
|
||||
QuoteSnapshot: &model.PaymentQuoteSnapshot{
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
RouteRef: "route-summary",
|
||||
Rail: "CARD_PAYOUT",
|
||||
Rail: "CARD",
|
||||
Provider: "gw-card",
|
||||
Network: "visa",
|
||||
},
|
||||
|
||||
@@ -21,7 +21,7 @@ func TestCompile_PolicyOverrideByRailPair(t *testing.T) {
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
{Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -78,7 +78,7 @@ func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) {
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
{Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -185,7 +185,7 @@ func TestCompile_ValidationErrors(t *testing.T) {
|
||||
Route: &paymenttypes.QuoteRouteSpecification{
|
||||
Hops: []*paymenttypes.QuoteRouteHop{
|
||||
{Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource},
|
||||
{Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
{Index: 2, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -81,7 +81,7 @@ func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy {
|
||||
|
||||
func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility {
|
||||
switch action {
|
||||
case model.RailOperationSend, model.RailOperationObserveConfirm:
|
||||
case model.RailOperationSend, model.RailOperationFXConvert, model.RailOperationObserveConfirm:
|
||||
if role == paymenttypes.QuoteRouteHopRoleDestination {
|
||||
return model.ReportVisibilityUser
|
||||
}
|
||||
@@ -106,6 +106,8 @@ func defaultUserLabel(
|
||||
return "Card payout submitted"
|
||||
}
|
||||
return "Transfer submitted"
|
||||
case model.RailOperationFXConvert:
|
||||
return "FX conversion submitted"
|
||||
case model.RailOperationObserveConfirm:
|
||||
if kind == model.PaymentKindPayout && rail == model.RailCardPayout {
|
||||
return "Card payout confirmed"
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
paymenttypes "github.com/tech/sendico/pkg/payments/types"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Compiler builds execution runtime step graph from resolved quote snapshots.
|
||||
@@ -116,7 +117,11 @@ func New(deps ...Dependencies) Compiler {
|
||||
if len(deps) > 0 {
|
||||
dep = deps[0]
|
||||
}
|
||||
logger := dep.Logger
|
||||
if logger == nil {
|
||||
logger = zap.NewNop()
|
||||
}
|
||||
return &svc{
|
||||
logger: dep.Logger.Named("xplan"),
|
||||
logger: logger.Named("xplan"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,6 +58,9 @@ func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string)
|
||||
}
|
||||
|
||||
func railToken(rail model.Rail) string {
|
||||
if rail == model.RailCardPayout {
|
||||
return "card_payout"
|
||||
}
|
||||
return strings.ToLower(strings.TrimSpace(string(rail)))
|
||||
}
|
||||
|
||||
@@ -158,25 +161,5 @@ func normalizeHopRole(
|
||||
}
|
||||
|
||||
func normalizeRail(raw string) model.Rail {
|
||||
token := strings.ToUpper(strings.TrimSpace(raw))
|
||||
token = strings.ReplaceAll(token, "-", "_")
|
||||
token = strings.ReplaceAll(token, " ", "_")
|
||||
for strings.Contains(token, "__") {
|
||||
token = strings.ReplaceAll(token, "__", "_")
|
||||
}
|
||||
|
||||
switch token {
|
||||
case "CRYPTO":
|
||||
return model.RailCrypto
|
||||
case "PROVIDER_SETTLEMENT", "PROVIDER":
|
||||
return model.RailProviderSettlement
|
||||
case "LEDGER":
|
||||
return model.RailLedger
|
||||
case "CARD_PAYOUT", "CARD":
|
||||
return model.RailCardPayout
|
||||
case "FIAT_ONRAMP", "FIAT_ON_RAMP":
|
||||
return model.RailFiatOnRamp
|
||||
default:
|
||||
return model.RailUnspecified
|
||||
}
|
||||
return model.ParseRail(raw)
|
||||
}
|
||||
|
||||
@@ -142,15 +142,16 @@ func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditio
|
||||
}
|
||||
|
||||
func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step {
|
||||
visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role)
|
||||
action, op := railActionForHop(hop)
|
||||
visibility := defaultVisibilityForAction(action, hop.role)
|
||||
userLabel := ""
|
||||
if visibility == model.ReportVisibilityUser {
|
||||
userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind)
|
||||
userLabel = defaultUserLabel(action, hop.rail, hop.role, intent.Kind)
|
||||
}
|
||||
return Step{
|
||||
StepCode: singleHopCode(hop, "send"),
|
||||
Kind: StepKindRailSend,
|
||||
Action: model.RailOperationSend,
|
||||
StepCode: singleHopCode(hop, op),
|
||||
Kind: kindForAction(action),
|
||||
Action: action,
|
||||
Rail: hop.rail,
|
||||
Gateway: hop.gateway,
|
||||
InstanceID: hop.instanceID,
|
||||
@@ -161,6 +162,13 @@ func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step {
|
||||
}
|
||||
}
|
||||
|
||||
func railActionForHop(hop normalizedHop) (model.RailOperation, string) {
|
||||
if hop.rail == model.RailProviderSettlement {
|
||||
return model.RailOperationFXConvert, "fx_convert"
|
||||
}
|
||||
return model.RailOperationSend, "send"
|
||||
}
|
||||
|
||||
func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step {
|
||||
visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role)
|
||||
userLabel := ""
|
||||
|
||||
@@ -199,7 +199,7 @@ func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalize
|
||||
}
|
||||
|
||||
switch action {
|
||||
case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee:
|
||||
case model.RailOperationSend, model.RailOperationFXConvert, model.RailOperationObserveConfirm, model.RailOperationFee:
|
||||
return to.rail
|
||||
case model.RailOperationBlock,
|
||||
model.RailOperationRelease,
|
||||
@@ -220,7 +220,7 @@ func resolveStepContext(
|
||||
from normalizedHop,
|
||||
to normalizedHop,
|
||||
) (uint32, paymenttypes.QuoteRouteHopRole, string, string) {
|
||||
if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) {
|
||||
if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationFXConvert || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) {
|
||||
return to.index, to.role, to.gateway, to.instanceID
|
||||
}
|
||||
if rail == from.rail {
|
||||
@@ -234,7 +234,7 @@ func resolveStepContext(
|
||||
|
||||
func kindForAction(action model.RailOperation) StepKind {
|
||||
switch action {
|
||||
case model.RailOperationSend:
|
||||
case model.RailOperationSend, model.RailOperationFXConvert:
|
||||
return StepKindRailSend
|
||||
case model.RailOperationObserveConfirm:
|
||||
return StepKindRailObserve
|
||||
|
||||
Reference in New Issue
Block a user