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