quotation service fixed

This commit is contained in:
Stephan D
2026-02-24 16:14:09 +01:00
parent 6444813f38
commit 2fe90347a8
76 changed files with 769 additions and 230 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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",
},

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 := ""

View File

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