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: path:
include: include:
- api/gateway/chain/** - api/gateway/chain/**
- api/gateway/common/**
- api/proto/** - api/proto/**
- api/pkg/** - api/pkg/**
ignore_message: '[rebuild]' ignore_message: '[rebuild]'

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -31,7 +31,7 @@ var (
feesServiceNames = []string{"BILLING_FEES", string(mservice.FeePlans)} feesServiceNames = []string{"BILLING_FEES", string(mservice.FeePlans)}
ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)}
oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} 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 { type discoveryEndpoint struct {

View File

@@ -8,6 +8,7 @@ import (
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
pm "github.com/tech/sendico/pkg/model" pm "github.com/tech/sendico/pkg/model"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
// Factory builds initial orchestration-v2 payment aggregates. // Factory builds initial orchestration-v2 payment aggregates.
@@ -102,8 +103,12 @@ func New(deps ...Dependencies) Factory {
if len(deps) > 0 { if len(deps) > 0 {
dep = deps[0] dep = deps[0]
} }
logger := dep.Logger
if logger == nil {
logger = zap.NewNop()
}
return &svc{ return &svc{
logger: dep.Logger.Named("agg"), logger: logger.Named("agg"),
now: func() time.Time { return time.Now().UTC() }, now: func() time.Time { return time.Now().UTC() },
newID: func() bson.ObjectID { newID: func() bson.ObjectID {
return bson.NewObjectID() return bson.NewObjectID()

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
func TestCreate_OK(t *testing.T) { func TestCreate_OK(t *testing.T) {
@@ -17,6 +18,7 @@ func TestCreate_OK(t *testing.T) {
paymentID := bson.NewObjectID() paymentID := bson.NewObjectID()
factory := &svc{ factory := &svc{
logger: zap.NewNop(),
now: func() time.Time { return now }, now: func() time.Time { return now },
newID: func() bson.ObjectID { newID: func() bson.ObjectID {
return paymentID return paymentID

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
) )
// Reconciler applies external async events to payment runtime state. // Reconciler applies external async events to payment runtime state.
@@ -130,12 +131,16 @@ func New(deps ...Dependencies) Reconciler {
if len(deps) > 0 { if len(deps) > 0 {
dep = deps[0] dep = deps[0]
} }
logger := dep.Logger
if logger == nil {
logger = zap.NewNop()
}
now := dep.Now now := dep.Now
if now == nil { if now == nil {
now = defaultNow now = defaultNow
} }
return &svc{ return &svc{
logger: dep.Logger.Named("erecon"), logger: logger.Named("erecon"),
now: now, now: now,
} }
} }

View File

@@ -7,11 +7,12 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"go.uber.org/zap"
) )
func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) { func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) 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{ in := &agg.Payment{
PaymentRef: "p1", PaymentRef: "p1",
@@ -74,7 +75,7 @@ func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) {
func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) { func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) {
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) 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{ out, err := reconciler.Reconcile(Input{
Payment: &agg.Payment{ Payment: &agg.Payment{
@@ -110,7 +111,7 @@ func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) {
func TestReconcile_GatewayFailureMapping(t *testing.T) { func TestReconcile_GatewayFailureMapping(t *testing.T) {
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) 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 retryable := true
out, err := reconciler.Reconcile(Input{ out, err := reconciler.Reconcile(Input{
@@ -178,7 +179,7 @@ func TestReconcile_GatewayFailureMapping(t *testing.T) {
func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) { func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) {
now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) 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{ out, err := reconciler.Reconcile(Input{
Payment: &agg.Payment{ Payment: &agg.Payment{
@@ -212,7 +213,7 @@ func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) {
} }
func TestReconcile_CardMatchByExternalRef(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{ out, err := reconciler.Reconcile(Input{
Payment: &agg.Payment{ Payment: &agg.Payment{
@@ -246,7 +247,7 @@ func TestReconcile_CardMatchByExternalRef(t *testing.T) {
} }
func TestReconcile_MatchingErrors(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{ _, err := reconciler.Reconcile(Input{
Payment: &agg.Payment{ Payment: &agg.Payment{
@@ -293,7 +294,7 @@ func TestReconcile_MatchingErrors(t *testing.T) {
} }
func TestReconcile_ValidationErrors(t *testing.T) { func TestReconcile_ValidationErrors(t *testing.T) {
reconciler := &svc{now: defaultNow} reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow})
tests := []struct { tests := []struct {
name string name string

View File

@@ -6,6 +6,7 @@ import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
// Store is the minimal payment store contract required for idempotency handling. // Store is the minimal payment store contract required for idempotency handling.
@@ -52,5 +53,9 @@ func New(deps ...Dependencies) Service {
if len(deps) > 0 { if len(deps) > 0 {
dep = 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() store = newMemoryAuditStore()
} }
logger := deps.Logger.Named("oobs") logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
logger = logger.Named("oobs")
metrics := deps.Metrics metrics := deps.Metrics
if metrics == nil { if metrics == nil {

View File

@@ -101,7 +101,7 @@ func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuo
Hops: []*paymenttypes.QuoteRouteHop{ Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, {Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource},
{Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"}, {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{ Settlement: &paymenttypes.QuoteRouteSettlement{
Model: "fix_source", Model: "fix_source",

View File

@@ -3,6 +3,7 @@ package opagg
import ( import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
) )
// Aggregator compacts compatible quote items into recipient-level execution groups. // Aggregator compacts compatible quote items into recipient-level execution groups.
@@ -45,5 +46,9 @@ func New(deps ...Dependencies) Aggregator {
if len(deps) > 0 { if len(deps) > 0 {
dep = 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/payments/orchestrator/internal/service/orchestrationv2/agg"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
) )
// StateMachine is the single source of truth for orchestration-v2 state transitions. // 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 { if len(deps) > 0 {
dep = 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 { if deps.Repository == nil {
return nil, merrors.InvalidArgument("payment repository v2 is required") return nil, merrors.InvalidArgument("payment repository v2 is required")
} }
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
return &svc{ return &svc{
logger: deps.Logger.Named("pquery"), logger: logger.Named("pquery"),
repo: deps.Repository, repo: deps.Repository,
}, nil }, nil
} }

View File

@@ -49,6 +49,9 @@ func newWithStoreLogger(store paymentStore, logger mlogger.Logger) (Repository,
if store == nil { if store == nil {
return nil, merrors.InvalidArgument("payment repository v2: store is required") return nil, merrors.InvalidArgument("payment repository v2: store is required")
} }
if logger == nil {
logger = zap.NewNop()
}
if err := store.EnsureIndexes(requiredIndexes()); err != nil { if err := store.EnsureIndexes(requiredIndexes()); err != nil {
return nil, err return nil, err
} }

View File

@@ -5,6 +5,7 @@ import (
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"go.uber.org/zap"
) )
// Mapper transforms orchestration-v2 runtime aggregate snapshots into API responses. // Mapper transforms orchestration-v2 runtime aggregate snapshots into API responses.
@@ -32,5 +33,9 @@ func New(deps ...Dependencies) Mapper {
if len(deps) > 0 { if len(deps) > 0 {
dep = 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, Firm: true,
}, },
Route: &paymenttypes.QuoteRouteSpecification{ Route: &paymenttypes.QuoteRouteSpecification{
Rail: "CARD_PAYOUT", Rail: "CARD",
Provider: "provider-1", Provider: "provider-1",
PayoutMethod: "CARD", PayoutMethod: "CARD",
Network: "VISA", Network: "VISA",
@@ -326,7 +326,7 @@ func newPaymentFixture() *agg.Payment {
}, },
{ {
Index: 20, Index: 20,
Rail: "CARD_PAYOUT", Rail: "CARD",
Gateway: "gw-card", Gateway: "gw-card",
InstanceID: "card-1", InstanceID: "card-1",
Role: paymenttypes.QuoteRouteHopRoleDestination, Role: paymenttypes.QuoteRouteHopRoleDestination,

View File

@@ -107,11 +107,13 @@ func inferRail(kind string, stepCode string) gatewayv1.Rail {
case strings.Contains(all, "ledger"): case strings.Contains(all, "ledger"):
return gatewayv1.Rail_RAIL_LEDGER return gatewayv1.Rail_RAIL_LEDGER
case strings.Contains(all, "card_payout"), strings.Contains(all, "card"): case strings.Contains(all, "card_payout"), strings.Contains(all, "card"):
return gatewayv1.Rail_RAIL_CARD_PAYOUT return gatewayv1.Rail_RAIL_CARD
case strings.Contains(all, "provider_settlement"), strings.Contains(all, "provider"): case strings.Contains(all, "provider_settlement"),
return gatewayv1.Rail_RAIL_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"): 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"): case strings.Contains(all, "crypto"), strings.Contains(all, "chain"), strings.Contains(all, "tx"):
return gatewayv1.Rail_RAIL_CRYPTO return gatewayv1.Rail_RAIL_CRYPTO
default: default:

View File

@@ -19,6 +19,7 @@ import (
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
) )
const ( const (
@@ -57,7 +58,11 @@ func newService(deps Dependencies) (Service, error) {
return nil, merrors.InvalidArgument("payment repository v2 is required") 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 observer := deps.Observer
if observer == nil { if observer == nil {

View File

@@ -600,7 +600,7 @@ func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification {
func buildCardRoute() *paymenttypes.QuoteRouteSpecification { func buildCardRoute() *paymenttypes.QuoteRouteSpecification {
return &paymenttypes.QuoteRouteSpecification{ return &paymenttypes.QuoteRouteSpecification{
Rail: "CARD_PAYOUT", Rail: "CARD",
Provider: "gw-card", Provider: "gw-card",
Network: "visa", Network: "visa",
} }

View File

@@ -7,6 +7,7 @@ import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
// Store is the minimal quote store contract required by the resolver. // Store is the minimal quote store contract required by the resolver.
@@ -45,12 +46,16 @@ func New(deps ...Dependencies) Resolver {
if len(deps) > 0 { if len(deps) > 0 {
dep = deps[0] dep = deps[0]
} }
logger := dep.Logger
if logger == nil {
logger = zap.NewNop()
}
now := dep.Now now := dep.Now
if now == nil { if now == nil {
now = time.Now now = time.Now
} }
return &svc{ return &svc{
logger: dep.Logger.Named("qsnap"), logger: logger.Named("qsnap"),
now: now, now: now,
} }
} }

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
quotestorage "github.com/tech/sendico/payments/storage/quote" quotestorage "github.com/tech/sendico/payments/storage/quote"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
func TestResolve_NotFound(t *testing.T) { func TestResolve_NotFound(t *testing.T) {
@@ -29,9 +30,7 @@ func TestResolve_NotFound(t *testing.T) {
func TestResolve_Expired(t *testing.T) { func TestResolve_Expired(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{ _, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { 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) { func TestResolve_NotExecutableState(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{ _, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { 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) { func TestResolve_NotExecutableExecutionNote(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{ _, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { 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) { func TestResolve_ShapeMismatch(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{ _, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {

View File

@@ -9,6 +9,7 @@ import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
func TestResolve_SingleShapeOK(t *testing.T) { func TestResolve_SingleShapeOK(t *testing.T) {
@@ -30,9 +31,7 @@ func TestResolve_SingleShapeOK(t *testing.T) {
ExpiresAt: now.Add(time.Minute), ExpiresAt: now.Add(time.Minute),
} }
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
out, err := resolver.Resolve(context.Background(), &fakeStore{ out, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { 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), ExpiresAt: now.Add(time.Minute),
} }
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
out, err := resolver.Resolve(context.Background(), &fakeStore{ out, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { 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), ExpiresAt: now.Add(time.Minute),
} }
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
out, err := resolver.Resolve(context.Background(), &fakeStore{ out, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { 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) { func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{ _, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { 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) { func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) {
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
resolver := &svc{ resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
now: func() time.Time { return now },
}
_, err := resolver.Resolve(context.Background(), &fakeStore{ _, err := resolver.Resolve(context.Background(), &fakeStore{
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {

View File

@@ -3,6 +3,7 @@ package reqval
import ( import (
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.mongodb.org/mongo-driver/v2/bson" "go.mongodb.org/mongo-driver/v2/bson"
"go.uber.org/zap"
) )
// Validator validates execute-payment inputs and returns a normalized context. // Validator validates execute-payment inputs and returns a normalized context.
@@ -50,5 +51,9 @@ func New(deps ...Dependencies) Validator {
if len(deps) > 0 { if len(deps) > 0 {
dep = 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/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
) )
// Registry dispatches orchestration steps to rail/action-specific executors. // Registry dispatches orchestration steps to rail/action-specific executors.
@@ -44,7 +45,7 @@ type CryptoExecutor interface {
ExecuteCrypto(ctx context.Context, req StepRequest) (*ExecuteOutput, error) ExecuteCrypto(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
} }
// ProviderSettlementExecutor handles provider settlement SEND actions. // ProviderSettlementExecutor handles settlement FX_CONVERT actions.
type ProviderSettlementExecutor interface { type ProviderSettlementExecutor interface {
ExecuteProviderSettlement(ctx context.Context, req StepRequest) (*ExecuteOutput, error) ExecuteProviderSettlement(ctx context.Context, req StepRequest) (*ExecuteOutput, error)
} }
@@ -70,8 +71,12 @@ type Dependencies struct {
} }
func New(deps Dependencies) Registry { func New(deps Dependencies) Registry {
logger := deps.Logger
if logger == nil {
logger = zap.NewNop()
}
return &svc{ return &svc{
logger: deps.Logger.Named("sexec"), logger: logger.Named("sexec"),
deps: deps, deps: deps,
} }
} }

View File

@@ -1,8 +1,6 @@
package sexec package sexec
import ( import (
"strings"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
) )
@@ -29,13 +27,20 @@ func classifyRoute(step xplan.Step) route {
switch rail { switch rail {
case model.RailCrypto: case model.RailCrypto:
return routeCrypto return routeCrypto
case model.RailProviderSettlement:
return routeProviderSettlement
case model.RailCardPayout: case model.RailCardPayout:
return routeCardPayout return routeCardPayout
default: default:
return routeUnknown return routeUnknown
} }
case model.RailOperationFXConvert:
switch rail {
case model.RailProviderSettlement:
return routeProviderSettlement
case model.RailLedger:
return routeLedger
default:
return routeUnknown
}
case model.RailOperationFee: case model.RailOperationFee:
if rail == model.RailCrypto { if rail == model.RailCrypto {
return routeCrypto return routeCrypto
@@ -57,8 +62,7 @@ func isLedgerAction(action model.RailOperation) bool {
model.RailOperationExternalCredit, model.RailOperationExternalCredit,
model.RailOperationMove, model.RailOperationMove,
model.RailOperationBlock, model.RailOperationBlock,
model.RailOperationRelease, model.RailOperationRelease:
model.RailOperationFXConvert:
return true return true
default: default:
return false return false
@@ -66,47 +70,9 @@ func isLedgerAction(action model.RailOperation) bool {
} }
func normalizeAction(action model.RailOperation) model.RailOperation { func normalizeAction(action model.RailOperation) model.RailOperation {
switch strings.ToUpper(strings.TrimSpace(string(action))) { return model.ParseRailOperation(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
}
} }
func normalizeRail(rail model.Rail) model.Rail { func normalizeRail(rail model.Rail) model.Rail {
switch strings.ToUpper(strings.TrimSpace(string(rail))) { return model.ParseRail(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
}
} }

View File

@@ -63,13 +63,13 @@ func (s *svc) Execute(ctx context.Context, in ExecuteInput) (out *ExecuteOutput,
return out, err return out, err
case routeProviderSettlement: case routeProviderSettlement:
if s.deps.ProviderSettlement == nil { if s.deps.ProviderSettlement == nil {
return nil, missingExecutorError("provider_settlement") return nil, missingExecutorError("settlement")
} }
out, err = s.deps.ProviderSettlement.ExecuteProviderSettlement(ctx, req) out, err = s.deps.ProviderSettlement.ExecuteProviderSettlement(ctx, req)
return out, err return out, err
case routeCardPayout: case routeCardPayout:
if s.deps.CardPayout == nil { if s.deps.CardPayout == nil {
return nil, missingExecutorError("card_payout") return nil, missingExecutorError("card")
} }
out, err = s.deps.CardPayout.ExecuteCardPayout(ctx, req) out, err = s.deps.CardPayout.ExecuteCardPayout(ctx, req)
return out, err 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{} crypto := &fakeCryptoExecutor{}
provider := &fakeProviderSettlementExecutor{} provider := &fakeProviderSettlementExecutor{}
card := &fakeCardPayoutExecutor{} card := &fakeCardPayoutExecutor{}
@@ -67,9 +67,9 @@ func TestExecute_DispatchSendRailsAndObserve(t *testing.T) {
}, },
}, },
{ {
name: "send provider settlement", name: "fx convert provider settlement",
step: xplan.Step{ 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) { wantCalls: func(t *testing.T) {
t.Helper() 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) { func TestExecute_MissingExecutor(t *testing.T) {
registry := New(Dependencies{}) 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/ostate"
"github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
"go.uber.org/zap"
) )
// Runtime selects runnable orchestration steps and reconciles step runtime states. // Runtime selects runnable orchestration steps and reconciles step runtime states.
@@ -73,9 +74,13 @@ func New(deps ...Dependencies) Runtime {
if len(deps) > 0 { if len(deps) > 0 {
dep = deps[0] dep = deps[0]
} }
logger := dep.Logger
if logger == nil {
logger = zap.NewNop()
}
stateMachine := dep.StateMachine stateMachine := dep.StateMachine
if stateMachine == nil { 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 now := dep.Now
if now == nil { if now == nil {
@@ -84,7 +89,7 @@ func New(deps ...Dependencies) Runtime {
} }
} }
return &svc{ return &svc{
logger: dep.Logger.Named("ssched"), logger: logger.Named("ssched"),
stateMachine: stateMachine, stateMachine: stateMachine,
now: now, now: now,
} }

View File

@@ -25,7 +25,7 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) {
}, },
{ {
Index: 20, Index: 20,
Rail: "CARD_PAYOUT", Rail: "CARD",
Gateway: "gw-card", Gateway: "gw-card",
InstanceID: "card-1", InstanceID: "card-1",
Role: paymenttypes.QuoteRouteHopRoleDestination, Role: paymenttypes.QuoteRouteHopRoleDestination,
@@ -101,7 +101,7 @@ func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T)
Route: &paymenttypes.QuoteRouteSpecification{ Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{ Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, {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) 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) { func TestCompile_InternalToInternal_UsesMove(t *testing.T) {
compiler := New() compiler := New()
@@ -176,7 +234,7 @@ func TestCompile_GuardsArePrepended(t *testing.T) {
Route: &paymenttypes.QuoteRouteSpecification{ Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{ Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, {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{ ExecutionConditions: &paymenttypes.QuoteExecutionConditions{
@@ -213,7 +271,7 @@ func TestCompile_SingleExternalFallback(t *testing.T) {
QuoteSnapshot: &model.PaymentQuoteSnapshot{ QuoteSnapshot: &model.PaymentQuoteSnapshot{
Route: &paymenttypes.QuoteRouteSpecification{ Route: &paymenttypes.QuoteRouteSpecification{
RouteRef: "route-summary", RouteRef: "route-summary",
Rail: "CARD_PAYOUT", Rail: "CARD",
Provider: "gw-card", Provider: "gw-card",
Network: "visa", Network: "visa",
}, },

View File

@@ -21,7 +21,7 @@ func TestCompile_PolicyOverrideByRailPair(t *testing.T) {
Route: &paymenttypes.QuoteRouteSpecification{ Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{ Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, {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{ Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{ Hops: []*paymenttypes.QuoteRouteHop{
{Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, {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{ Route: &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{ Hops: []*paymenttypes.QuoteRouteHop{
{Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, {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 { func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility {
switch action { switch action {
case model.RailOperationSend, model.RailOperationObserveConfirm: case model.RailOperationSend, model.RailOperationFXConvert, model.RailOperationObserveConfirm:
if role == paymenttypes.QuoteRouteHopRoleDestination { if role == paymenttypes.QuoteRouteHopRoleDestination {
return model.ReportVisibilityUser return model.ReportVisibilityUser
} }
@@ -106,6 +106,8 @@ func defaultUserLabel(
return "Card payout submitted" return "Card payout submitted"
} }
return "Transfer submitted" return "Transfer submitted"
case model.RailOperationFXConvert:
return "FX conversion submitted"
case model.RailOperationObserveConfirm: case model.RailOperationObserveConfirm:
if kind == model.PaymentKindPayout && rail == model.RailCardPayout { if kind == model.PaymentKindPayout && rail == model.RailCardPayout {
return "Card payout confirmed" return "Card payout confirmed"

View File

@@ -4,6 +4,7 @@ import (
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mlogger"
paymenttypes "github.com/tech/sendico/pkg/payments/types" paymenttypes "github.com/tech/sendico/pkg/payments/types"
"go.uber.org/zap"
) )
// Compiler builds execution runtime step graph from resolved quote snapshots. // Compiler builds execution runtime step graph from resolved quote snapshots.
@@ -116,7 +117,11 @@ func New(deps ...Dependencies) Compiler {
if len(deps) > 0 { if len(deps) > 0 {
dep = deps[0] dep = deps[0]
} }
logger := dep.Logger
if logger == nil {
logger = zap.NewNop()
}
return &svc{ 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 { func railToken(rail model.Rail) string {
if rail == model.RailCardPayout {
return "card_payout"
}
return strings.ToLower(strings.TrimSpace(string(rail))) return strings.ToLower(strings.TrimSpace(string(rail)))
} }
@@ -158,25 +161,5 @@ func normalizeHopRole(
} }
func normalizeRail(raw string) model.Rail { func normalizeRail(raw string) model.Rail {
token := strings.ToUpper(strings.TrimSpace(raw)) return model.ParseRail(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
}
} }

View File

@@ -142,15 +142,16 @@ func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditio
} }
func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step {
visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role) action, op := railActionForHop(hop)
visibility := defaultVisibilityForAction(action, hop.role)
userLabel := "" userLabel := ""
if visibility == model.ReportVisibilityUser { 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{ return Step{
StepCode: singleHopCode(hop, "send"), StepCode: singleHopCode(hop, op),
Kind: StepKindRailSend, Kind: kindForAction(action),
Action: model.RailOperationSend, Action: action,
Rail: hop.rail, Rail: hop.rail,
Gateway: hop.gateway, Gateway: hop.gateway,
InstanceID: hop.instanceID, 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 { func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step {
visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role) visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role)
userLabel := "" userLabel := ""

View File

@@ -199,7 +199,7 @@ func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalize
} }
switch action { switch action {
case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee: case model.RailOperationSend, model.RailOperationFXConvert, model.RailOperationObserveConfirm, model.RailOperationFee:
return to.rail return to.rail
case model.RailOperationBlock, case model.RailOperationBlock,
model.RailOperationRelease, model.RailOperationRelease,
@@ -220,7 +220,7 @@ func resolveStepContext(
from normalizedHop, from normalizedHop,
to normalizedHop, to normalizedHop,
) (uint32, paymenttypes.QuoteRouteHopRole, string, string) { ) (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 return to.index, to.role, to.gateway, to.instanceID
} }
if rail == from.rail { if rail == from.rail {
@@ -234,7 +234,7 @@ func resolveStepContext(
func kindForAction(action model.RailOperation) StepKind { func kindForAction(action model.RailOperation) StepKind {
switch action { switch action {
case model.RailOperationSend: case model.RailOperationSend, model.RailOperationFXConvert:
return StepKindRailSend return StepKindRailSend
case model.RailOperationObserveConfirm: case model.RailOperationObserveConfirm:
return StepKindRailObserve return StepKindRailObserve

View File

@@ -41,7 +41,7 @@ step_scheduler_runtime
Pick runnable steps, manage dependency checks, retries/attempts, and mark blocked/skipped. Pick runnable steps, manage dependency checks, retries/attempts, and mark blocked/skipped.
step_executor_registry 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 external_event_reconciler
Consume async gateway/ledger/card events, map to step updates, append external refs, advance aggregate state. 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 { func normalizeRail(value model.Rail) model.Rail {
normalized := model.Rail(strings.ToUpper(strings.TrimSpace(string(value)))) normalized := model.ParseRail(string(value))
if normalized == "" { if normalized == model.RailUnspecified {
return model.RailUnspecified return model.RailUnspecified
} }
return normalized return normalized

View File

@@ -23,7 +23,7 @@ func TestFind_NetworkFiltersEdges(t *testing.T) {
t.Fatalf("unexpected error: %v", err) 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 { if got, want := path.Edges[0].Network, "TRON"; got != want {
t.Fatalf("unexpected first edge network: got=%q want=%q", 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) 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) { 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. // 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) { func TestFind_IgnoresInvalidEdges(t *testing.T) {
@@ -88,5 +88,5 @@ func TestFind_IgnoresInvalidEdges(t *testing.T) {
t.Fatalf("unexpected error: %v", err) 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) 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 { if got, want := len(path.Edges), 2; got != want {
t.Fatalf("unexpected edge count: got=%d want=%d", 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) 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) { func TestFind_HandlesCycles(t *testing.T) {
@@ -103,7 +103,7 @@ func TestFind_HandlesCycles(t *testing.T) {
t.Fatalf("unexpected error: %v", err) 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) { 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) { func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) {
svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{ svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{
items: []*model.GatewayInstanceDescriptor{ items: []*model.GatewayInstanceDescriptor{
@@ -406,7 +442,7 @@ func TestCompute_FailsWhenCoreReturnsDifferentRoute(t *testing.T) {
quote: &ComputedQuote{ quote: &ComputedQuote{
DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"}, DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"},
Route: &quotationv2.RouteSpecification{ Route: &quotationv2.RouteSpecification{
Rail: "CARD_PAYOUT", Rail: "CARD",
Provider: "other-provider", Provider: "other-provider",
PayoutMethod: "CARD", PayoutMethod: "CARD",
}, },

View File

@@ -16,6 +16,9 @@ func (s *QuoteComputationService) resolveRouteRails(
destinationRail model.Rail, destinationRail model.Rail,
network string, network string,
) ([]model.Rail, error) { ) ([]model.Rail, error) {
sourceRail = model.ParseRail(string(sourceRail))
destinationRail = model.ParseRail(string(destinationRail))
s.logger.Debug("Resolving route rails", s.logger.Debug("Resolving route rails",
zap.String("source_rail", string(sourceRail)), zap.String("source_rail", string(sourceRail)),
zap.String("dest_rail", string(destinationRail)), zap.String("dest_rail", string(destinationRail)),
@@ -179,8 +182,8 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_
continue continue
} }
from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail)))) from := model.ParseRail(string(route.FromRail))
to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail)))) to := model.ParseRail(string(route.ToRail))
if from == model.RailUnspecified || to == model.RailUnspecified { if from == model.RailUnspecified || to == model.RailUnspecified {
continue 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 { if got, want := string(item.Steps[1].Rail), string(model.RailProviderSettlement); got != want {
t.Fatalf("unexpected transit rail: got=%q want=%q", 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) t.Fatalf("unexpected route transit hop rail: %q", got)
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"strings" "strings"
"github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/payments/storage/model"
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
) )
func buildComputationSteps( func buildComputationSteps(
@@ -19,6 +20,7 @@ func buildComputationSteps(
attrs := intent.Attributes attrs := intent.Attributes
amount := protoMoneyFromModel(intent.Amount) amount := protoMoneyFromModel(intent.Amount)
destinationAmount := destinationStepAmount(intent, amount)
sourceRail := sourceRailForIntent(intent) sourceRail := sourceRailForIntent(intent)
destinationRail := destinationRailForIntent(intent) destinationRail := destinationRailForIntent(intent)
rails := normalizeRouteRails(sourceRail, destinationRail, routeRails) rails := normalizeRouteRails(sourceRail, destinationRail, routeRails)
@@ -101,7 +103,7 @@ func buildComputationSteps(
GatewayID: destinationGatewayID, GatewayID: destinationGatewayID,
InstanceID: destinationInstanceID, InstanceID: destinationInstanceID,
DependsOn: []string{lastStepID}, DependsOn: []string{lastStepID},
Amount: cloneProtoMoney(amount), Amount: destinationAmount,
Optional: false, Optional: false,
IncludeInAggregate: true, IncludeInAggregate: true,
}) })
@@ -196,3 +198,22 @@ func destinationOperationForRail(rail model.Rail) model.RailOperation {
return model.RailOperationExternalCredit 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 ( import (
"strings" "strings"
"github.com/tech/sendico/payments/storage/model"
"github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/merrors"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" 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 { func normalizeRail(value string) string {
if rail := model.ParseRail(value); rail != model.RailUnspecified {
return string(rail)
}
return strings.ToUpper(strings.TrimSpace(value)) return strings.ToUpper(strings.TrimSpace(value))
} }

View File

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

View File

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

View File

@@ -42,8 +42,8 @@ func (t *PaymentPlanTemplate) Normalize() {
if t == nil { if t == nil {
return return
} }
t.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.FromRail)))) t.FromRail = normalizeRail(t.FromRail)
t.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.ToRail)))) t.ToRail = normalizeRail(t.ToRail)
t.Network = strings.ToUpper(strings.TrimSpace(t.Network)) t.Network = strings.ToUpper(strings.TrimSpace(t.Network))
if len(t.Steps) == 0 { if len(t.Steps) == 0 {
return return
@@ -51,7 +51,7 @@ func (t *PaymentPlanTemplate) Normalize() {
for i := range t.Steps { for i := range t.Steps {
step := &t.Steps[i] step := &t.Steps[i]
step.StepID = strings.TrimSpace(step.StepID) 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.Operation = strings.ToLower(strings.TrimSpace(step.Operation))
step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility) step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility)
step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) 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 { if r == nil {
return return
} }
r.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.FromRail)))) r.FromRail = normalizeRail(r.FromRail)
r.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.ToRail)))) r.ToRail = normalizeRail(r.ToRail)
r.Network = strings.ToUpper(strings.TrimSpace(r.Network)) 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 return nil, err
} }
entity.Normalize()
return entity, nil return entity, nil
} }
@@ -133,11 +134,15 @@ func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTempl
query := repository.Query() query := repository.Query()
if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" { if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 {
query = query.Filter(repository.Field("fromRail"), from) 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 != "" { if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 {
query = query.Filter(repository.Field("toRail"), to) 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 != "" { if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" {
query = query.Filter(repository.Field("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 { if err := cur.Decode(item); err != nil {
return err return err
} }
item.Normalize()
templates = append(templates, item) templates = append(templates, item)
return nil return nil
} }

View File

@@ -120,6 +120,7 @@ func (r *Routes) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentR
} }
return nil, err return nil, err
} }
entity.Normalize()
return entity, nil return entity, nil
} }
@@ -130,11 +131,15 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m
query := repository.Query() query := repository.Query()
if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" { if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 {
query = query.Filter(repository.Field("fromRail"), from) 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 != "" { if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 {
query = query.Filter(repository.Field("toRail"), to) 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 != "" { if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" {
query = query.Filter(repository.Field("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 { if err := cur.Decode(item); err != nil {
return err return err
} }
item.Normalize()
routes = append(routes, item) routes = append(routes, item)
return nil return nil
} }
@@ -163,3 +169,38 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m
} }
var _ storage.RoutesStore = (*Routes)(nil) 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 want string
}{ }{
{name: "prefix and key", prefix: "crypto_rail_gateway", key: " TRON ", want: "crypto_rail_gateway_tron"}, {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 key", prefix: "payment_gateway", key: " ", want: "payment_gateway_unknown"},
{name: "missing prefix", prefix: " ", key: "TRON", want: "tron"}, {name: "missing prefix", prefix: " ", key: "TRON", want: "tron"},
} }
@@ -35,7 +35,7 @@ func TestStableCryptoRailGatewayID(t *testing.T) {
} }
func TestStablePaymentGatewayID(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) t.Fatalf("unexpected stable id: got=%q want=%q", got, want)
} }
if got, want := StablePaymentGatewayID(""), "payment_gateway_unknown"; got != want { if got, want := StablePaymentGatewayID(""), "payment_gateway_unknown"; got != want {

View File

@@ -4,10 +4,10 @@ import "strings"
const ( const (
RailCrypto = "CRYPTO" RailCrypto = "CRYPTO"
RailProviderSettlement = "PROVIDER_SETTLEMENT" RailProviderSettlement = "SETTLEMENT"
RailLedger = "LEDGER" RailLedger = "LEDGER"
RailCardPayout = "CARD_PAYOUT" RailCardPayout = "CARD"
RailFiatOnRamp = "FIAT_ONRAMP" RailFiatOnRamp = "ONRAMP"
) )
const ( const (
@@ -48,7 +48,30 @@ var knownRailOperations = map[string]struct{}{
// NormalizeRail canonicalizes a rail token. // NormalizeRail canonicalizes a rail token.
func NormalizeRail(value string) string { 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. // 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. // NormalizeRailOperation canonicalizes a rail operation token.
func NormalizeRailOperation(value string) string { func NormalizeRailOperation(value string) string {
clean := strings.ToUpper(strings.TrimSpace(value)) clean := strings.ToUpper(strings.TrimSpace(value))
if strings.HasPrefix(clean, "RAIL_OPERATION_") { if after, ok := strings.CutPrefix(clean, "RAIL_OPERATION_"); ok {
clean = strings.TrimPrefix(clean, "RAIL_OPERATION_") clean = after
} }
return clean return clean
} }
@@ -140,3 +163,11 @@ func CardPayoutRailGatewayOperations() []string {
RailOperationObserveConfirm, 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") 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 defaultConsumerBufferSize = 1024
const redactedNATSPassword = "xxxxx"
func sanitizeNATSURL(rawURL string) string { func buildSafePublishableNATSURL(rawURL string) string {
if rawURL == "" { if rawURL == "" {
return rawURL return rawURL
} }
parts := strings.Split(rawURL, ",") parts := strings.Split(rawURL, ",")
sanitized := make([]string, 0, len(parts)) safe := make([]string, 0, len(parts))
for _, part := range parts { for _, part := range parts {
trimmed := strings.TrimSpace(part) trimmed := strings.TrimSpace(part)
if trimmed == "" { if trimmed == "" {
continue continue
} }
if !strings.Contains(trimmed, "://") { built, ok := buildSafePublishableNATSEntry(trimmed)
sanitized = append(sanitized, trimmed) if !ok {
safe = append(safe, trimmed)
continue continue
} }
safe = append(safe, built)
}
parsed, err := url.Parse(trimmed) if len(safe) == 0 {
if err != nil {
sanitized = append(sanitized, trimmed)
continue
}
if parsed.User == nil {
sanitized = append(sanitized, trimmed)
continue
}
sanitized = append(sanitized, parsed.Redacted())
}
if len(sanitized) == 0 {
return strings.TrimSpace(rawURL) 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 // 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() natsURL = u.String()
} }
sanitizedNATSURL := sanitizeNATSURL(natsURL) publishableNATSURL := buildSafePublishableNATSURL(natsURL)
opts := []nats.Option{ opts := []nats.Option{
nats.Name(settings.NATSName), nats.Name(settings.NATSName),
@@ -156,7 +224,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
zap.String("broker", settings.NATSName), zap.String("broker", settings.NATSName),
} }
if conn != nil { 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 { if err != nil {
fields = append(fields, zap.Error(err)) 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), zap.String("broker", settings.NATSName),
} }
if conn != nil { 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...) l.Info("Reconnected to NATS", fields...)
}), }),
@@ -178,7 +246,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e
} }
if conn != nil { if conn != nil {
if url := conn.ConnectedUrl(); url != "" { 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 { if err := conn.LastError(); err != nil {
fields = append(fields, zap.Error(err)) 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 { 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 return nil, err
} }
if res.js, err = res.nc.JetStream(); err != nil { 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), logger.Info("Connected to NATS", zap.String("broker", settings.NATSName),
zap.String("url", sanitizedNATSURL)) zap.String("url", publishableNATSURL))
return res, nil return res, nil
} }

View File

@@ -5,14 +5,14 @@ import (
"testing" "testing"
) )
func TestSanitizeNATSURL(t *testing.T) { func TestBuildSafePublishableNATSURL(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("redacts single URL credentials", func(t *testing.T) { t.Run("redacts single URL credentials", func(t *testing.T) {
t.Parallel() t.Parallel()
raw := "nats://alice:supersecret@localhost:4222" raw := "nats://alice:supersecret@localhost:4222"
sanitized := sanitizeNATSURL(raw) sanitized := buildSafePublishableNATSURL(raw)
if strings.Contains(sanitized, "supersecret") { if strings.Contains(sanitized, "supersecret") {
t.Fatalf("expected password to be redacted, got %q", sanitized) 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.Run("keeps URL without credentials unchanged", func(t *testing.T) {
t.Parallel() t.Parallel()
raw := "nats://localhost:4222" raw := "nats://localhost:4222"
sanitized := sanitizeNATSURL(raw) sanitized := buildSafePublishableNATSURL(raw)
if sanitized != raw { if sanitized != raw {
t.Fatalf("expected URL without credentials to remain unchanged, got %q", sanitized) t.Fatalf("expected URL without credentials to remain unchanged, got %q", sanitized)
} }
@@ -36,7 +50,7 @@ func TestSanitizeNATSURL(t *testing.T) {
t.Parallel() t.Parallel()
raw := " nats://alice:one@localhost:4222, nats://bob:two@localhost:4223 " 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") { if strings.Contains(sanitized, "one") || strings.Contains(sanitized, "two") {
t.Fatalf("expected passwords to be redacted, got %q", sanitized) t.Fatalf("expected passwords to be redacted, got %q", sanitized)
@@ -50,9 +64,37 @@ func TestSanitizeNATSURL(t *testing.T) {
t.Parallel() t.Parallel()
raw := "not a url" raw := "not a url"
sanitized := sanitizeNATSURL(raw) sanitized := buildSafePublishableNATSURL(raw)
if sanitized != raw { if sanitized != raw {
t.Fatalf("expected invalid URL to remain unchanged, got %q", sanitized) 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 { enum Rail {
RAIL_UNSPECIFIED = 0; RAIL_UNSPECIFIED = 0;
RAIL_CRYPTO = 1; RAIL_CRYPTO = 1;
RAIL_PROVIDER_SETTLEMENT = 2; RAIL_SETTLEMENT = 2;
RAIL_LEDGER = 3; RAIL_LEDGER = 3;
RAIL_CARD_PAYOUT = 4; RAIL_CARD = 4;
RAIL_FIAT_ONRAMP = 5; RAIL_ONRAMP = 5;
RAIL_OFFRAMP = 6;
} }
// Operations supported in a payment plan. // 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= 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 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= 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-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU=
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/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 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/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= 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/proto ./api/proto
COPY api/pkg ./api/pkg COPY api/pkg ./api/pkg
COPY api/gateway/common ./api/gateway/common
COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/
RUN bash ci/scripts/proto/generate.sh RUN bash ci/scripts/proto/generate.sh
@@ -25,6 +26,7 @@ WORKDIR /src
# Copy generated proto and pkg from builder # Copy generated proto and pkg from builder
COPY --from=builder /src/api/proto ./api/proto COPY --from=builder /src/api/proto ./api/proto
COPY --from=builder /src/api/pkg ./api/pkg COPY --from=builder /src/api/pkg ./api/pkg
COPY --from=builder /src/api/gateway/common ./api/gateway/common
# Copy dev-specific entrypoint script # Copy dev-specific entrypoint script
COPY ci/dev/entrypoints/chain-gateway.sh /app/entrypoint.sh 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/proto ./api/proto
COPY api/pkg ./api/pkg COPY api/pkg ./api/pkg
COPY api/gateway/common ./api/gateway/common
COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/
RUN bash ci/scripts/proto/generate.sh RUN bash ci/scripts/proto/generate.sh
@@ -25,6 +26,7 @@ WORKDIR /src
# Copy generated proto and pkg from builder # Copy generated proto and pkg from builder
COPY --from=builder /src/api/proto ./api/proto COPY --from=builder /src/api/proto ./api/proto
COPY --from=builder /src/api/pkg ./api/pkg 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 # Source code will be mounted at runtime
WORKDIR /src/api/gateway/mntx WORKDIR /src/api/gateway/mntx

View File

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

View File

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

View File

@@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace" BUILD_CONTEXT="/workspace"
fi 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 \ /kaniko/executor \
--context "${BUILD_CONTEXT}" \ --context "${BUILD_CONTEXT}" \
--dockerfile "${CHAIN_GATEWAY_DOCKERFILE}" \ --dockerfile "${CHAIN_GATEWAY_DOCKERFILE}" \

View File

@@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace" BUILD_CONTEXT="/workspace"
fi 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 \ /kaniko/executor \
--context "${BUILD_CONTEXT}" \ --context "${BUILD_CONTEXT}" \
--dockerfile "${MNTX_GATEWAY_DOCKERFILE}" \ --dockerfile "${MNTX_GATEWAY_DOCKERFILE}" \

View File

@@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace" BUILD_CONTEXT="/workspace"
fi 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 \ /kaniko/executor \
--context "${BUILD_CONTEXT}" \ --context "${BUILD_CONTEXT}" \
--dockerfile "${TGSETTLE_GATEWAY_DOCKERFILE}" \ --dockerfile "${TGSETTLE_GATEWAY_DOCKERFILE}" \

View File

@@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then
BUILD_CONTEXT="/workspace" BUILD_CONTEXT="/workspace"
fi 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 \ /kaniko/executor \
--context "${BUILD_CONTEXT}" \ --context "${BUILD_CONTEXT}" \
--dockerfile "${TRON_GATEWAY_DOCKERFILE}" \ --dockerfile "${TRON_GATEWAY_DOCKERFILE}" \

View File

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