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

@@ -14,6 +14,7 @@ when:
path:
include:
- api/gateway/chain/**
- api/gateway/common/**
- api/proto/**
- api/pkg/**
ignore_message: '[rebuild]'

View File

@@ -13,6 +13,7 @@ when:
path:
include:
- api/gateway/mntx/**
- api/gateway/common/**
- api/proto/**
- api/pkg/**
ignore_message: '[rebuild]'

View File

@@ -11,6 +11,7 @@ when:
path:
include:
- api/gateway/tgsettle/**
- api/gateway/common/**
- api/proto/**
- api/pkg/**
ignore_message: '[rebuild]'

View File

@@ -14,6 +14,7 @@ when:
path:
include:
- api/gateway/tron/**
- api/gateway/common/**
- api/proto/**
- api/pkg/**
ignore_message: '[rebuild]'

View File

@@ -333,7 +333,7 @@ func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gat
return &gatewayv1.GatewayInstanceDescriptor{
Id: id,
Rail: gatewayv1.Rail_RAIL_CARD_PAYOUT,
Rail: gatewayv1.Rail_RAIL_CARD,
Network: network,
Currencies: currencies,
Capabilities: &gatewayv1.RailCapabilities{

View File

@@ -151,7 +151,7 @@ func (s *Service) startDiscoveryAnnouncer() {
return
}
announce := discovery.Announcement{
Service: "CARD_PAYOUT_RAIL_GATEWAY",
Service: "CARD_RAIL_GATEWAY",
Rail: discovery.RailCardPayout,
Operations: discovery.CardPayoutRailGatewayOperations(),
InvokeURI: s.invokeURI,
@@ -164,7 +164,7 @@ func (s *Service) startDiscoveryAnnouncer() {
announce.Currencies = currenciesFromDescriptor(s.gatewayDescriptor)
}
if strings.TrimSpace(announce.ID) == "" {
announce.ID = "card_payout_rail_gateway"
announce.ID = discovery.StablePaymentGatewayID(discovery.RailCardPayout)
}
s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce)
s.announcer.Start()

View File

@@ -36,7 +36,7 @@ messaging:
buffer_size: 1024
gateway:
rail: "provider_settlement"
rail: "SETTLEMENT"
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
timeout_seconds: 345600
accepted_user_ids: []

View File

@@ -36,7 +36,7 @@ messaging:
buffer_size: 1024
gateway:
rail: "provider_settlement"
rail: "SETTLEMENT"
target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID
timeout_seconds: 345600
accepted_user_ids: []

View File

@@ -10,6 +10,7 @@ import (
gatewaymongo "github.com/tech/sendico/gateway/tgsettle/storage/mongo"
"github.com/tech/sendico/pkg/api/routers"
"github.com/tech/sendico/pkg/db"
"github.com/tech/sendico/pkg/discovery"
"github.com/tech/sendico/pkg/merrors"
msg "github.com/tech/sendico/pkg/messaging"
mb "github.com/tech/sendico/pkg/messaging/broker"
@@ -141,8 +142,12 @@ func (i *Imp) loadConfig() (*config, error) {
if cfg.Metrics == nil {
cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"}
}
cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail)
if cfg.Gateway.Rail == "" {
return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail")
}
if !discovery.IsKnownRail(cfg.Gateway.Rail) {
return nil, merrors.InvalidArgument("gateway rail must be a known token", "gateway.rail")
}
return cfg, nil
}

View File

@@ -95,9 +95,12 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro
broker: broker,
cfg: cfg,
msgCfg: cfg.MessagingSettings,
rail: strings.TrimSpace(cfg.Rail),
rail: discovery.NormalizeRail(cfg.Rail),
invokeURI: strings.TrimSpace(cfg.InvokeURI),
}
if svc.rail == "" {
svc.rail = strings.TrimSpace(cfg.Rail)
}
svc.chatID = strings.TrimSpace(readEnv(cfg.TargetChatIDEnv))
svc.successReaction = strings.TrimSpace(cfg.SuccessReaction)
if svc.successReaction == "" {
@@ -526,11 +529,12 @@ func (s *Service) startAnnouncer() {
if s == nil || s.producer == nil {
return
}
caps := discovery.CardPayoutRailGatewayOperations()
rail := discovery.RailProviderSettlement
caps := discovery.ProviderSettlementRailGatewayOperations()
announce := discovery.Announcement{
ID: discovery.StablePaymentGatewayID(discovery.NormalizeRail(s.rail)),
ID: discovery.StablePaymentGatewayID(rail),
Service: string(mservice.PaymentGateway),
Rail: discovery.NormalizeRail(s.rail),
Rail: rail,
Operations: caps,
InvokeURI: s.invokeURI,
}

View File

@@ -31,7 +31,7 @@ var (
feesServiceNames = []string{"BILLING_FEES", string(mservice.FeePlans)}
ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)}
oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)}
mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)}
mntxServiceNames = []string{"CARD_RAIL_GATEWAY", string(mservice.MntxGateway)}
)
type discoveryEndpoint struct {

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

View File

@@ -41,7 +41,7 @@ step_scheduler_runtime
Pick runnable steps, manage dependency checks, retries/attempts, and mark blocked/skipped.
step_executor_registry
Rail/action executors (ledger, crypto, provider_settlement, card_payout, observe_confirm) behind interfaces.
Rail/action executors (ledger, crypto, settlement, card, observe_confirm) behind interfaces.
external_event_reconciler
Consume async gateway/ledger/card events, map to step updates, append external refs, advance aggregate state.

View File

@@ -64,8 +64,8 @@ func networkPriority(edgeNetwork, requested string) int {
}
func normalizeRail(value model.Rail) model.Rail {
normalized := model.Rail(strings.ToUpper(strings.TrimSpace(string(value))))
if normalized == "" {
normalized := model.ParseRail(string(value))
if normalized == model.RailUnspecified {
return model.RailUnspecified
}
return normalized

View File

@@ -23,7 +23,7 @@ func TestFind_NetworkFiltersEdges(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
if got, want := path.Edges[0].Network, "TRON"; got != want {
t.Fatalf("unexpected first edge network: got=%q want=%q", got, want)
}
@@ -47,7 +47,7 @@ func TestFind_PrefersExactNetworkOverWildcard(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "PROVIDER_SETTLEMENT", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "SETTLEMENT", "CARD"})
}
func TestFind_DeterministicTieBreak(t *testing.T) {
@@ -68,7 +68,7 @@ func TestFind_DeterministicTieBreak(t *testing.T) {
}
// Both routes have equal length; lexical tie-break chooses LEDGER branch.
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
}
func TestFind_IgnoresInvalidEdges(t *testing.T) {
@@ -88,5 +88,5 @@ func TestFind_IgnoresInvalidEdges(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
}

View File

@@ -62,7 +62,7 @@ func TestFind_FindsIndirectPath(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
if got, want := len(path.Edges), 2; got != want {
t.Fatalf("unexpected edge count: got=%d want=%d", got, want)
}
@@ -84,7 +84,7 @@ func TestFind_PrefersShortestPath(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "CARD"})
}
func TestFind_HandlesCycles(t *testing.T) {
@@ -103,7 +103,7 @@ func TestFind_HandlesCycles(t *testing.T) {
t.Fatalf("unexpected error: %v", err)
}
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"})
assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"})
}
func TestFind_ReturnsErrorWhenPathUnavailable(t *testing.T) {

View File

@@ -131,6 +131,42 @@ func TestBuildPlan_RequiresFXAddsMiddleStep(t *testing.T) {
}
}
func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing.T) {
svc := New(nil)
orgID := bson.NewObjectID()
intent := sampleCryptoToCardQuoteIntent()
intent.RequiresFX = true
intent.SettlementCurrency = "RUB"
planModel, err := svc.BuildPlan(context.Background(), ComputeInput{
OrganizationRef: orgID.Hex(),
OrganizationID: orgID,
BaseIdempotencyKey: "idem-key",
PreviewOnly: false,
Intents: []*transfer_intent_hydrator.QuoteIntent{intent},
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if len(planModel.Items) != 1 {
t.Fatalf("expected one plan item")
}
steps := planModel.Items[0].Steps
if got, want := len(steps), 4; got != want {
t.Fatalf("unexpected step count: got=%d want=%d", got, want)
}
last := steps[len(steps)-1]
if last == nil || last.Amount == nil {
t.Fatalf("expected destination step amount")
}
if got, want := strings.TrimSpace(last.Amount.GetCurrency()), "RUB"; got != want {
t.Fatalf("unexpected destination step currency: got=%q want=%q", got, want)
}
if got, want := last.Operation, model.RailOperationSend; got != want {
t.Fatalf("unexpected destination operation: got=%q want=%q", got, want)
}
}
func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{
@@ -406,7 +442,7 @@ func TestCompute_FailsWhenCoreReturnsDifferentRoute(t *testing.T) {
quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT",
Rail: "CARD",
Provider: "other-provider",
PayoutMethod: "CARD",
},

View File

@@ -16,6 +16,9 @@ func (s *QuoteComputationService) resolveRouteRails(
destinationRail model.Rail,
network string,
) ([]model.Rail, error) {
sourceRail = model.ParseRail(string(sourceRail))
destinationRail = model.ParseRail(string(destinationRail))
s.logger.Debug("Resolving route rails",
zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)),
@@ -179,8 +182,8 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_
continue
}
from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail))))
to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail))))
from := model.ParseRail(string(route.FromRail))
to := model.ParseRail(string(route.ToRail))
if from == model.RailUnspecified || to == model.RailUnspecified {
continue

View File

@@ -74,7 +74,7 @@ func TestBuildPlan_UsesRouteGraphPath(t *testing.T) {
if got, want := string(item.Steps[1].Rail), string(model.RailProviderSettlement); got != want {
t.Fatalf("unexpected transit rail: got=%q want=%q", got, want)
}
if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "PROVIDER_SETTLEMENT" {
if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "SETTLEMENT" {
t.Fatalf("unexpected route transit hop rail: %q", got)
}
}

View File

@@ -5,6 +5,7 @@ import (
"strings"
"github.com/tech/sendico/payments/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
)
func buildComputationSteps(
@@ -19,6 +20,7 @@ func buildComputationSteps(
attrs := intent.Attributes
amount := protoMoneyFromModel(intent.Amount)
destinationAmount := destinationStepAmount(intent, amount)
sourceRail := sourceRailForIntent(intent)
destinationRail := destinationRailForIntent(intent)
rails := normalizeRouteRails(sourceRail, destinationRail, routeRails)
@@ -101,7 +103,7 @@ func buildComputationSteps(
GatewayID: destinationGatewayID,
InstanceID: destinationInstanceID,
DependsOn: []string{lastStepID},
Amount: cloneProtoMoney(amount),
Amount: destinationAmount,
Optional: false,
IncludeInAggregate: true,
})
@@ -196,3 +198,22 @@ func destinationOperationForRail(rail model.Rail) model.RailOperation {
return model.RailOperationExternalCredit
}
}
func destinationStepAmount(intent model.PaymentIntent, sourceAmount *moneyv1.Money) *moneyv1.Money {
amount := cloneProtoMoney(sourceAmount)
if amount == nil {
return nil
}
if !intent.RequiresFX {
return amount
}
settlementCurrency := strings.ToUpper(strings.TrimSpace(intent.SettlementCurrency))
if settlementCurrency == "" && intent.FX != nil && intent.FX.Pair != nil {
settlementCurrency = strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote))
}
if settlementCurrency != "" {
amount.Currency = settlementCurrency
}
return amount
}

View File

@@ -3,6 +3,7 @@ package quote_computation_service
import (
"strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
)
@@ -59,6 +60,9 @@ func normalizeSettlementParts(src *quotationv2.RouteSettlement) (chain, token, c
}
func normalizeRail(value string) string {
if rail := model.ParseRail(value); rail != model.RailUnspecified {
return string(rail)
}
return strings.ToUpper(strings.TrimSpace(value))
}

View File

@@ -40,7 +40,7 @@ func TestMap_ExecutableQuote(t *testing.T) {
Currency: "USD",
},
Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT",
Rail: "CARD",
Provider: "monetix",
PayoutMethod: "CARD",
Settlement: &quotationv2.RouteSettlement{

View File

@@ -75,10 +75,11 @@ type Rail string
const (
RailUnspecified Rail = "UNSPECIFIED"
RailCrypto Rail = "CRYPTO"
RailProviderSettlement Rail = "PROVIDER_SETTLEMENT"
RailProviderSettlement Rail = "SETTLEMENT"
RailLedger Rail = "LEDGER"
RailCardPayout Rail = "CARD_PAYOUT"
RailFiatOnRamp Rail = "FIAT_ONRAMP"
RailCardPayout Rail = "CARD"
RailFiatOnRamp Rail = "ONRAMP"
RailFiatOffRamp Rail = "OFFRAMP"
)
// RailOperation identifies an explicit action within a payment plan.

View File

@@ -42,8 +42,8 @@ func (t *PaymentPlanTemplate) Normalize() {
if t == nil {
return
}
t.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.FromRail))))
t.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.ToRail))))
t.FromRail = normalizeRail(t.FromRail)
t.ToRail = normalizeRail(t.ToRail)
t.Network = strings.ToUpper(strings.TrimSpace(t.Network))
if len(t.Steps) == 0 {
return
@@ -51,7 +51,7 @@ func (t *PaymentPlanTemplate) Normalize() {
for i := range t.Steps {
step := &t.Steps[i]
step.StepID = strings.TrimSpace(step.StepID)
step.Rail = Rail(strings.ToUpper(strings.TrimSpace(string(step.Rail))))
step.Rail = normalizeRail(step.Rail)
step.Operation = strings.ToLower(strings.TrimSpace(step.Operation))
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy)

View File

@@ -0,0 +1,62 @@
package model
import "strings"
var supportedRails = map[Rail]struct{}{
RailCrypto: {},
RailProviderSettlement: {},
RailLedger: {},
RailCardPayout: {},
RailFiatOnRamp: {},
RailFiatOffRamp: {},
}
// ParseRail canonicalizes string values into a Rail token.
func ParseRail(value string) Rail {
clean := strings.ToUpper(strings.TrimSpace(value))
if clean == "" {
return RailUnspecified
}
clean = strings.ReplaceAll(clean, "-", "_")
clean = strings.ReplaceAll(clean, " ", "_")
for strings.Contains(clean, "__") {
clean = strings.ReplaceAll(clean, "__", "_")
}
switch clean {
case string(RailCrypto), "RAIL_CRYPTO":
return RailCrypto
case string(RailProviderSettlement), "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT":
return RailProviderSettlement
case string(RailLedger), "RAIL_LEDGER":
return RailLedger
case string(RailCardPayout), "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT":
return RailCardPayout
case string(RailFiatOnRamp), "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP":
return RailFiatOnRamp
case string(RailFiatOffRamp), "FIAT_OFFRAMP", "RAIL_OFFRAMP", "RAIL_FIAT_OFFRAMP":
return RailFiatOffRamp
default:
return RailUnspecified
}
}
// IsSupportedRail reports whether rail is recognized by payment planning.
func IsSupportedRail(rail Rail) bool {
_, ok := supportedRails[ParseRail(string(rail))]
return ok
}
func normalizeRail(value Rail) Rail {
parsed := ParseRail(string(value))
if parsed != RailUnspecified {
return parsed
}
clean := strings.ToUpper(strings.TrimSpace(string(value)))
if clean == "" {
return RailUnspecified
}
return Rail(clean)
}

View File

@@ -0,0 +1,28 @@
package model
import "testing"
func TestParseRail(t *testing.T) {
cases := []struct {
name string
input string
want Rail
}{
{name: "crypto", input: "crypto", want: RailCrypto},
{name: "settlement canonical", input: "SETTLEMENT", want: RailProviderSettlement},
{name: "settlement legacy", input: "provider_settlement", want: RailProviderSettlement},
{name: "card canonical", input: "card", want: RailCardPayout},
{name: "card legacy", input: "card_payout", want: RailCardPayout},
{name: "onramp", input: "fiat_onramp", want: RailFiatOnRamp},
{name: "offramp", input: "fiat_offramp", want: RailFiatOffRamp},
{name: "unknown", input: "telegram", want: RailUnspecified},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := ParseRail(tc.input); got != tc.want {
t.Fatalf("ParseRail(%q) = %q, want %q", tc.input, got, tc.want)
}
})
}
}

View File

@@ -28,8 +28,8 @@ func (r *PaymentRoute) Normalize() {
if r == nil {
return
}
r.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.FromRail))))
r.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.ToRail))))
r.FromRail = normalizeRail(r.FromRail)
r.ToRail = normalizeRail(r.ToRail)
r.Network = strings.ToUpper(strings.TrimSpace(r.Network))
}

View File

@@ -123,6 +123,7 @@ func (p *PlanTemplates) GetByID(ctx context.Context, id bson.ObjectID) (*model.P
}
return nil, err
}
entity.Normalize()
return entity, nil
}
@@ -133,11 +134,15 @@ func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTempl
query := repository.Query()
if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" {
query = query.Filter(repository.Field("fromRail"), from)
if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 {
query = query.Filter(repository.Field("fromRail"), from[0])
} else if len(from) > 1 {
query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...)
}
if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" {
query = query.Filter(repository.Field("toRail"), to)
if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 {
query = query.Filter(repository.Field("toRail"), to[0])
} else if len(to) > 1 {
query = query.In(repository.Field("toRail"), stringSliceToAny(to)...)
}
if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" {
query = query.Filter(repository.Field("network"), network)
@@ -152,6 +157,7 @@ func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTempl
if err := cur.Decode(item); err != nil {
return err
}
item.Normalize()
templates = append(templates, item)
return nil
}

View File

@@ -120,6 +120,7 @@ func (r *Routes) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentR
}
return nil, err
}
entity.Normalize()
return entity, nil
}
@@ -130,11 +131,15 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m
query := repository.Query()
if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" {
query = query.Filter(repository.Field("fromRail"), from)
if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 {
query = query.Filter(repository.Field("fromRail"), from[0])
} else if len(from) > 1 {
query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...)
}
if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" {
query = query.Filter(repository.Field("toRail"), to)
if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 {
query = query.Filter(repository.Field("toRail"), to[0])
} else if len(to) > 1 {
query = query.In(repository.Field("toRail"), stringSliceToAny(to)...)
}
if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" {
query = query.Filter(repository.Field("network"), network)
@@ -149,6 +154,7 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m
if err := cur.Decode(item); err != nil {
return err
}
item.Normalize()
routes = append(routes, item)
return nil
}
@@ -163,3 +169,38 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m
}
var _ storage.RoutesStore = (*Routes)(nil)
func normalizedRailFilterValues(rail model.Rail) []string {
clean := strings.ToUpper(strings.TrimSpace(string(rail)))
if clean == "" {
return nil
}
if parsed := model.ParseRail(string(rail)); parsed != model.RailUnspecified {
switch parsed {
case model.RailCrypto:
return []string{string(model.RailCrypto), "RAIL_CRYPTO"}
case model.RailProviderSettlement:
return []string{string(model.RailProviderSettlement), "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT"}
case model.RailLedger:
return []string{string(model.RailLedger), "RAIL_LEDGER"}
case model.RailCardPayout:
return []string{string(model.RailCardPayout), "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT"}
case model.RailFiatOnRamp:
return []string{string(model.RailFiatOnRamp), "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP"}
case model.RailFiatOffRamp:
return []string{string(model.RailFiatOffRamp), "FIAT_OFFRAMP", "RAIL_OFFRAMP", "RAIL_FIAT_OFFRAMP"}
default:
return []string{string(parsed)}
}
}
return []string{clean}
}
func stringSliceToAny(values []string) []any {
out := make([]any, 0, len(values))
for _, value := range values {
out = append(out, value)
}
return out
}

View File

@@ -10,7 +10,7 @@ func TestStableGatewayID(t *testing.T) {
want string
}{
{name: "prefix and key", prefix: "crypto_rail_gateway", key: " TRON ", want: "crypto_rail_gateway_tron"},
{name: "prefix trailing underscore", prefix: "payment_gateway_", key: " PROVIDER_SETTLEMENT ", want: "payment_gateway_provider_settlement"},
{name: "prefix trailing underscore", prefix: "payment_gateway_", key: " SETTLEMENT ", want: "payment_gateway_settlement"},
{name: "missing key", prefix: "payment_gateway", key: " ", want: "payment_gateway_unknown"},
{name: "missing prefix", prefix: " ", key: "TRON", want: "tron"},
}
@@ -35,7 +35,7 @@ func TestStableCryptoRailGatewayID(t *testing.T) {
}
func TestStablePaymentGatewayID(t *testing.T) {
if got, want := StablePaymentGatewayID(" PROVIDER_SETTLEMENT "), "payment_gateway_provider_settlement"; got != want {
if got, want := StablePaymentGatewayID(" SETTLEMENT "), "payment_gateway_settlement"; got != want {
t.Fatalf("unexpected stable id: got=%q want=%q", got, want)
}
if got, want := StablePaymentGatewayID(""), "payment_gateway_unknown"; got != want {

View File

@@ -4,10 +4,10 @@ import "strings"
const (
RailCrypto = "CRYPTO"
RailProviderSettlement = "PROVIDER_SETTLEMENT"
RailProviderSettlement = "SETTLEMENT"
RailLedger = "LEDGER"
RailCardPayout = "CARD_PAYOUT"
RailFiatOnRamp = "FIAT_ONRAMP"
RailCardPayout = "CARD"
RailFiatOnRamp = "ONRAMP"
)
const (
@@ -48,7 +48,30 @@ var knownRailOperations = map[string]struct{}{
// NormalizeRail canonicalizes a rail token.
func NormalizeRail(value string) string {
return strings.ToUpper(strings.TrimSpace(value))
clean := strings.ToUpper(strings.TrimSpace(value))
if clean == "" {
return ""
}
clean = strings.ReplaceAll(clean, "-", "_")
clean = strings.ReplaceAll(clean, " ", "_")
for strings.Contains(clean, "__") {
clean = strings.ReplaceAll(clean, "__", "_")
}
switch clean {
case RailCrypto, "RAIL_CRYPTO":
return RailCrypto
case RailProviderSettlement, "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT":
return RailProviderSettlement
case RailLedger, "RAIL_LEDGER":
return RailLedger
case RailCardPayout, "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT":
return RailCardPayout
case RailFiatOnRamp, "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP":
return RailFiatOnRamp
default:
return clean
}
}
// IsKnownRail reports whether the value is a recognized payment rail.
@@ -60,8 +83,8 @@ func IsKnownRail(value string) bool {
// NormalizeRailOperation canonicalizes a rail operation token.
func NormalizeRailOperation(value string) string {
clean := strings.ToUpper(strings.TrimSpace(value))
if strings.HasPrefix(clean, "RAIL_OPERATION_") {
clean = strings.TrimPrefix(clean, "RAIL_OPERATION_")
if after, ok := strings.CutPrefix(clean, "RAIL_OPERATION_"); ok {
clean = after
}
return clean
}
@@ -140,3 +163,11 @@ func CardPayoutRailGatewayOperations() []string {
RailOperationObserveConfirm,
}
}
// ProviderSettlementRailGatewayOperations returns canonical operations for settlement gateways.
func ProviderSettlementRailGatewayOperations() []string {
return []string{
RailOperationFXConvert,
RailOperationObserveConfirm,
}
}

View File

@@ -47,3 +47,15 @@ func TestIsKnownRail(t *testing.T) {
t.Fatalf("did not expect telegram rail to be known")
}
}
func TestNormalizeRailAliases(t *testing.T) {
if got := NormalizeRail("provider_settlement"); got != RailProviderSettlement {
t.Fatalf("provider_settlement alias mismatch: got=%q want=%q", got, RailProviderSettlement)
}
if got := NormalizeRail("card_payout"); got != RailCardPayout {
t.Fatalf("card_payout alias mismatch: got=%q want=%q", got, RailCardPayout)
}
if got := NormalizeRail("RAIL_SETTLEMENT"); got != RailProviderSettlement {
t.Fatalf("RAIL_SETTLEMENT alias mismatch: got=%q want=%q", got, RailProviderSettlement)
}
}

View File

@@ -34,40 +34,108 @@ type envConfig struct {
}
const defaultConsumerBufferSize = 1024
const redactedNATSPassword = "xxxxx"
func sanitizeNATSURL(rawURL string) string {
func buildSafePublishableNATSURL(rawURL string) string {
if rawURL == "" {
return rawURL
}
parts := strings.Split(rawURL, ",")
sanitized := make([]string, 0, len(parts))
safe := make([]string, 0, len(parts))
for _, part := range parts {
trimmed := strings.TrimSpace(part)
if trimmed == "" {
continue
}
if !strings.Contains(trimmed, "://") {
sanitized = append(sanitized, trimmed)
built, ok := buildSafePublishableNATSEntry(trimmed)
if !ok {
safe = append(safe, trimmed)
continue
}
parsed, err := url.Parse(trimmed)
if err != nil {
sanitized = append(sanitized, trimmed)
continue
}
if parsed.User == nil {
sanitized = append(sanitized, trimmed)
continue
}
sanitized = append(sanitized, parsed.Redacted())
safe = append(safe, built)
}
if len(sanitized) == 0 {
if len(safe) == 0 {
return strings.TrimSpace(rawURL)
}
return strings.Join(sanitized, ",")
return strings.Join(safe, ",")
}
func buildSafePublishableNATSEntry(raw string) (string, bool) {
parsed, err := url.Parse(raw)
if err == nil && parsed.Host != "" {
safe := &url.URL{
Scheme: parsed.Scheme,
Host: parsed.Host,
Path: parsed.Path,
RawPath: parsed.RawPath,
RawQuery: parsed.RawQuery,
Fragment: parsed.Fragment,
}
if safe.Scheme == "" {
safe.Scheme = "nats"
}
if parsed.User != nil {
username := parsed.User.Username()
if username == "" {
username = redactedNATSPassword
}
safe.User = url.UserPassword(username, redactedNATSPassword)
}
return safe.String(), true
}
return buildSafePublishableFromAuthority(raw)
}
func buildSafePublishableFromAuthority(raw string) (string, bool) {
scheme := "nats"
authorityAndSuffix := raw
if schemeIndex := strings.Index(raw, "://"); schemeIndex >= 0 {
if candidate := strings.TrimSpace(raw[:schemeIndex]); candidate != "" {
scheme = candidate
}
authorityAndSuffix = raw[schemeIndex+3:]
}
authorityEnd := strings.IndexAny(authorityAndSuffix, "/?#")
if authorityEnd < 0 {
authorityEnd = len(authorityAndSuffix)
}
authority := authorityAndSuffix[:authorityEnd]
suffix := authorityAndSuffix[authorityEnd:]
atIndex := strings.LastIndex(authority, "@")
hostPort := authority
username := ""
if atIndex >= 0 {
userInfo := authority[:atIndex]
hostPort = authority[atIndex+1:]
if hostPort == "" {
return "", false
}
username = userInfo
if colonIndex := strings.Index(userInfo, ":"); colonIndex >= 0 {
username = userInfo[:colonIndex]
}
if username == "" {
username = redactedNATSPassword
}
}
if hostPort == "" || strings.ContainsAny(hostPort, " \t\r\n") {
return "", false
}
safe := &url.URL{
Scheme: scheme,
Host: hostPort,
}
if username != "" {
safe.User = url.UserPassword(username, redactedNATSPassword)
}
return safe.String() + suffix, true
}
// loadEnv gathers and validates connection details from environment variables
@@ -144,7 +212,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
}
natsURL = u.String()
}
sanitizedNATSURL := sanitizeNATSURL(natsURL)
publishableNATSURL := buildSafePublishableNATSURL(natsURL)
opts := []nats.Option{
nats.Name(settings.NATSName),
@@ -156,7 +224,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
zap.String("broker", settings.NATSName),
}
if conn != nil {
fields = append(fields, zap.String("connected_url", sanitizeNATSURL(conn.ConnectedUrl())))
fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(conn.ConnectedUrl())))
}
if err != nil {
fields = append(fields, zap.Error(err))
@@ -168,7 +236,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
zap.String("broker", settings.NATSName),
}
if conn != nil {
fields = append(fields, zap.String("connected_url", sanitizeNATSURL(conn.ConnectedUrl())))
fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(conn.ConnectedUrl())))
}
l.Info("Reconnected to NATS", fields...)
}),
@@ -178,7 +246,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
}
if conn != nil {
if url := conn.ConnectedUrl(); url != "" {
fields = append(fields, zap.String("connected_url", sanitizeNATSURL(url)))
fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(url)))
}
if err := conn.LastError(); err != nil {
fields = append(fields, zap.Error(err))
@@ -208,7 +276,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
}
if res.nc, err = nats.Connect(natsURL, opts...); err != nil {
l.Error("Failed to connect to NATS", zap.String("url", sanitizedNATSURL), zap.Error(err))
l.Error("Failed to connect to NATS", zap.String("url", publishableNATSURL), zap.Error(err))
return nil, err
}
if res.js, err = res.nc.JetStream(); err != nil {
@@ -216,7 +284,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
}
logger.Info("Connected to NATS", zap.String("broker", settings.NATSName),
zap.String("url", sanitizedNATSURL))
zap.String("url", publishableNATSURL))
return res, nil
}

View File

@@ -5,14 +5,14 @@ import (
"testing"
)
func TestSanitizeNATSURL(t *testing.T) {
func TestBuildSafePublishableNATSURL(t *testing.T) {
t.Parallel()
t.Run("redacts single URL credentials", func(t *testing.T) {
t.Parallel()
raw := "nats://alice:supersecret@localhost:4222"
sanitized := sanitizeNATSURL(raw)
sanitized := buildSafePublishableNATSURL(raw)
if strings.Contains(sanitized, "supersecret") {
t.Fatalf("expected password to be redacted, got %q", sanitized)
@@ -22,11 +22,25 @@ func TestSanitizeNATSURL(t *testing.T) {
}
})
t.Run("redacts credentials in gateway URL format", func(t *testing.T) {
t.Parallel()
raw := "nats://dev_nats:nats_password_123@dev-nats:4222"
sanitized := buildSafePublishableNATSURL(raw)
if strings.Contains(sanitized, "nats_password_123") {
t.Fatalf("expected password to be redacted, got %q", sanitized)
}
if !strings.Contains(sanitized, "dev_nats:xxxxx@dev-nats:4222") {
t.Fatalf("expected sanitized URL with redacted password, got %q", sanitized)
}
})
t.Run("keeps URL without credentials unchanged", func(t *testing.T) {
t.Parallel()
raw := "nats://localhost:4222"
sanitized := sanitizeNATSURL(raw)
sanitized := buildSafePublishableNATSURL(raw)
if sanitized != raw {
t.Fatalf("expected URL without credentials to remain unchanged, got %q", sanitized)
}
@@ -36,7 +50,7 @@ func TestSanitizeNATSURL(t *testing.T) {
t.Parallel()
raw := " nats://alice:one@localhost:4222, nats://bob:two@localhost:4223 "
sanitized := sanitizeNATSURL(raw)
sanitized := buildSafePublishableNATSURL(raw)
if strings.Contains(sanitized, "one") || strings.Contains(sanitized, "two") {
t.Fatalf("expected passwords to be redacted, got %q", sanitized)
@@ -50,9 +64,37 @@ func TestSanitizeNATSURL(t *testing.T) {
t.Parallel()
raw := "not a url"
sanitized := sanitizeNATSURL(raw)
sanitized := buildSafePublishableNATSURL(raw)
if sanitized != raw {
t.Fatalf("expected invalid URL to remain unchanged, got %q", sanitized)
}
})
t.Run("redacts malformed URL credentials via fallback", func(t *testing.T) {
t.Parallel()
raw := "nats://alice:pa%ss@localhost:4222"
sanitized := buildSafePublishableNATSURL(raw)
if strings.Contains(sanitized, "pa%ss") {
t.Fatalf("expected malformed password to be redacted, got %q", sanitized)
}
if !strings.Contains(sanitized, "alice:xxxxx@localhost:4222") {
t.Fatalf("expected fallback redaction to preserve host and username, got %q", sanitized)
}
})
t.Run("redacts URL without scheme when user info is present", func(t *testing.T) {
t.Parallel()
raw := "alice:topsecret@localhost:4222"
sanitized := buildSafePublishableNATSURL(raw)
if strings.Contains(sanitized, "topsecret") {
t.Fatalf("expected password to be redacted, got %q", sanitized)
}
if !strings.Contains(sanitized, "alice:xxxxx@localhost:4222") {
t.Fatalf("expected sanitized authority with redacted password, got %q", sanitized)
}
})
}

View File

@@ -26,10 +26,11 @@ enum Operation {
enum Rail {
RAIL_UNSPECIFIED = 0;
RAIL_CRYPTO = 1;
RAIL_PROVIDER_SETTLEMENT = 2;
RAIL_SETTLEMENT = 2;
RAIL_LEDGER = 3;
RAIL_CARD_PAYOUT = 4;
RAIL_FIAT_ONRAMP = 5;
RAIL_CARD = 4;
RAIL_ONRAMP = 5;
RAIL_OFFRAMP = 6;
}
// Operations supported in a payment plan.

View File

@@ -361,8 +361,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s=
google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ=
google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU=
google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY=

View File

@@ -11,6 +11,7 @@ WORKDIR /src
COPY api/proto ./api/proto
COPY api/pkg ./api/pkg
COPY api/gateway/common ./api/gateway/common
COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/
RUN bash ci/scripts/proto/generate.sh
@@ -25,6 +26,7 @@ WORKDIR /src
# Copy generated proto and pkg from builder
COPY --from=builder /src/api/proto ./api/proto
COPY --from=builder /src/api/pkg ./api/pkg
COPY --from=builder /src/api/gateway/common ./api/gateway/common
# Copy dev-specific entrypoint script
COPY ci/dev/entrypoints/chain-gateway.sh /app/entrypoint.sh

View File

@@ -11,6 +11,7 @@ WORKDIR /src
COPY api/proto ./api/proto
COPY api/pkg ./api/pkg
COPY api/gateway/common ./api/gateway/common
COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/
RUN bash ci/scripts/proto/generate.sh
@@ -25,6 +26,7 @@ WORKDIR /src
# Copy generated proto and pkg from builder
COPY --from=builder /src/api/proto ./api/proto
COPY --from=builder /src/api/pkg ./api/pkg
COPY --from=builder /src/api/gateway/common ./api/gateway/common
# Source code will be mounted at runtime
WORKDIR /src/api/gateway/mntx

View File

@@ -11,6 +11,7 @@ WORKDIR /src
COPY api/proto ./api/proto
COPY api/pkg ./api/pkg
COPY api/gateway/common ./api/gateway/common
COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/
RUN bash ci/scripts/proto/generate.sh
@@ -25,6 +26,7 @@ WORKDIR /src
# Copy generated proto and pkg from builder
COPY --from=builder /src/api/proto ./api/proto
COPY --from=builder /src/api/pkg ./api/pkg
COPY --from=builder /src/api/gateway/common ./api/gateway/common
# Source code will be mounted at runtime
WORKDIR /src/api/gateway/tgsettle

View File

@@ -11,6 +11,7 @@ WORKDIR /src
COPY api/proto ./api/proto
COPY api/pkg ./api/pkg
COPY api/gateway/common ./api/gateway/common
COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/
RUN bash ci/scripts/proto/generate.sh
@@ -25,6 +26,7 @@ WORKDIR /src
# Copy generated proto and pkg from builder
COPY --from=builder /src/api/proto ./api/proto
COPY --from=builder /src/api/pkg ./api/pkg
COPY --from=builder /src/api/gateway/common ./api/gateway/common
# Copy dev-specific entrypoint script
COPY ci/dev/entrypoints/tron-gateway.sh /app/entrypoint.sh

View File

@@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace"
fi
# Gateway modules use a local replace (../common); ensure build context contains shared code.
if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then
if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then
echo "[chain-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2
BUILD_CONTEXT="${REPO_ROOT}"
fi
fi
if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then
echo "[chain-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2
exit 67
fi
if [ ! -f "${BUILD_CONTEXT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then
echo "[chain-gateway-build] dockerfile not found in build context: ${CHAIN_GATEWAY_DOCKERFILE}" >&2
exit 68
fi
/kaniko/executor \
--context "${BUILD_CONTEXT}" \
--dockerfile "${CHAIN_GATEWAY_DOCKERFILE}" \

View File

@@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace"
fi
# Gateway modules use a local replace (../common); ensure build context contains shared code.
if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then
if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then
echo "[mntx-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2
BUILD_CONTEXT="${REPO_ROOT}"
fi
fi
if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then
echo "[mntx-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2
exit 67
fi
if [ ! -f "${BUILD_CONTEXT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then
echo "[mntx-gateway-build] dockerfile not found in build context: ${MNTX_GATEWAY_DOCKERFILE}" >&2
exit 68
fi
/kaniko/executor \
--context "${BUILD_CONTEXT}" \
--dockerfile "${MNTX_GATEWAY_DOCKERFILE}" \

View File

@@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace"
fi
# Gateway modules use a local replace (../common); ensure build context contains shared code.
if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then
if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then
echo "[tgsettle-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2
BUILD_CONTEXT="${REPO_ROOT}"
fi
fi
if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then
echo "[tgsettle-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2
exit 67
fi
if [ ! -f "${BUILD_CONTEXT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then
echo "[tgsettle-gateway-build] dockerfile not found in build context: ${TGSETTLE_GATEWAY_DOCKERFILE}" >&2
exit 68
fi
/kaniko/executor \
--context "${BUILD_CONTEXT}" \
--dockerfile "${TGSETTLE_GATEWAY_DOCKERFILE}" \

View File

@@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace"
fi
# Gateway modules use a local replace (../common); ensure build context contains shared code.
if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${TRON_GATEWAY_DOCKERFILE}" ]; then
if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${TRON_GATEWAY_DOCKERFILE}" ]; then
echo "[tron-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2
BUILD_CONTEXT="${REPO_ROOT}"
fi
fi
if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then
echo "[tron-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2
exit 67
fi
if [ ! -f "${BUILD_CONTEXT}/${TRON_GATEWAY_DOCKERFILE}" ]; then
echo "[tron-gateway-build] dockerfile not found in build context: ${TRON_GATEWAY_DOCKERFILE}" >&2
exit 68
fi
/kaniko/executor \
--context "${BUILD_CONTEXT}" \
--dockerfile "${TRON_GATEWAY_DOCKERFILE}" \

View File

@@ -616,6 +616,7 @@ services:
dev-chain-gateway-vault-agent: { condition: service_healthy }
volumes:
- ./api/gateway/chain:/src/api/gateway/chain
- ./api/gateway/common:/src/api/gateway/common
- ./api/gateway/chain/config.dev.yml:/app/config.yml:ro
- dev-chain-gateway-vault-run:/run/vault:ro
ports:
@@ -693,6 +694,7 @@ services:
dev-tron-gateway-vault-agent: { condition: service_healthy }
volumes:
- ./api/gateway/tron:/src/api/gateway/tron
- ./api/gateway/common:/src/api/gateway/common
- ./api/gateway/tron/config.dev.yml:/app/config.yml:ro
- dev-tron-gateway-vault-run:/run/vault:ro
ports:
@@ -738,6 +740,7 @@ services:
dev-vault: { condition: service_healthy }
volumes:
- ./api/gateway/mntx:/src/api/gateway/mntx
- ./api/gateway/common:/src/api/gateway/common
- ./api/gateway/mntx/config.dev.yml:/app/config.yml:ro
ports:
- "50075:50075"
@@ -781,6 +784,7 @@ services:
dev-vault: { condition: service_healthy }
volumes:
- ./api/gateway/tgsettle:/src/api/gateway/tgsettle
- ./api/gateway/common:/src/api/gateway/common
- ./api/gateway/tgsettle/config.dev.yml:/app/config.yml:ro
ports:
- "50080:50080"