diff --git a/.woodpecker/gateway_chain.yml b/.woodpecker/gateway_chain.yml index aa13ae7b..5ed26a66 100644 --- a/.woodpecker/gateway_chain.yml +++ b/.woodpecker/gateway_chain.yml @@ -14,6 +14,7 @@ when: path: include: - api/gateway/chain/** + - api/gateway/common/** - api/proto/** - api/pkg/** ignore_message: '[rebuild]' diff --git a/.woodpecker/gateway_mntx.yml b/.woodpecker/gateway_mntx.yml index bc7ba934..c680a1cf 100644 --- a/.woodpecker/gateway_mntx.yml +++ b/.woodpecker/gateway_mntx.yml @@ -13,6 +13,7 @@ when: path: include: - api/gateway/mntx/** + - api/gateway/common/** - api/proto/** - api/pkg/** ignore_message: '[rebuild]' diff --git a/.woodpecker/gateway_tgsettle.yml b/.woodpecker/gateway_tgsettle.yml index 63db1e1a..8a512f6f 100644 --- a/.woodpecker/gateway_tgsettle.yml +++ b/.woodpecker/gateway_tgsettle.yml @@ -11,6 +11,7 @@ when: path: include: - api/gateway/tgsettle/** + - api/gateway/common/** - api/proto/** - api/pkg/** ignore_message: '[rebuild]' diff --git a/.woodpecker/gateway_tron.yml b/.woodpecker/gateway_tron.yml index 576bc07d..12965950 100644 --- a/.woodpecker/gateway_tron.yml +++ b/.woodpecker/gateway_tron.yml @@ -14,6 +14,7 @@ when: path: include: - api/gateway/tron/** + - api/gateway/common/** - api/proto/** - api/pkg/** ignore_message: '[rebuild]' diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go index 1a799ff6..70f133d9 100644 --- a/api/gateway/mntx/internal/server/internal/serverimp.go +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -333,7 +333,7 @@ func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gat return &gatewayv1.GatewayInstanceDescriptor{ Id: id, - Rail: gatewayv1.Rail_RAIL_CARD_PAYOUT, + Rail: gatewayv1.Rail_RAIL_CARD, Network: network, Currencies: currencies, Capabilities: &gatewayv1.RailCapabilities{ diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index 27abd0ee..fba8ba80 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -151,7 +151,7 @@ func (s *Service) startDiscoveryAnnouncer() { return } announce := discovery.Announcement{ - Service: "CARD_PAYOUT_RAIL_GATEWAY", + Service: "CARD_RAIL_GATEWAY", Rail: discovery.RailCardPayout, Operations: discovery.CardPayoutRailGatewayOperations(), InvokeURI: s.invokeURI, @@ -164,7 +164,7 @@ func (s *Service) startDiscoveryAnnouncer() { announce.Currencies = currenciesFromDescriptor(s.gatewayDescriptor) } if strings.TrimSpace(announce.ID) == "" { - announce.ID = "card_payout_rail_gateway" + announce.ID = discovery.StablePaymentGatewayID(discovery.RailCardPayout) } s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce) s.announcer.Start() diff --git a/api/gateway/tgsettle/config.dev.yml b/api/gateway/tgsettle/config.dev.yml index 20e0d8dd..050b9d5d 100644 --- a/api/gateway/tgsettle/config.dev.yml +++ b/api/gateway/tgsettle/config.dev.yml @@ -36,7 +36,7 @@ messaging: buffer_size: 1024 gateway: - rail: "provider_settlement" + rail: "SETTLEMENT" target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID timeout_seconds: 345600 accepted_user_ids: [] diff --git a/api/gateway/tgsettle/config.yml b/api/gateway/tgsettle/config.yml index cde9320a..7e736332 100644 --- a/api/gateway/tgsettle/config.yml +++ b/api/gateway/tgsettle/config.yml @@ -36,7 +36,7 @@ messaging: buffer_size: 1024 gateway: - rail: "provider_settlement" + rail: "SETTLEMENT" target_chat_id_env: TGSETTLE_GATEWAY_CHAT_ID timeout_seconds: 345600 accepted_user_ids: [] diff --git a/api/gateway/tgsettle/internal/server/internal/serverimp.go b/api/gateway/tgsettle/internal/server/internal/serverimp.go index 686c6cdf..2cfe94f2 100644 --- a/api/gateway/tgsettle/internal/server/internal/serverimp.go +++ b/api/gateway/tgsettle/internal/server/internal/serverimp.go @@ -10,6 +10,7 @@ import ( gatewaymongo "github.com/tech/sendico/gateway/tgsettle/storage/mongo" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" mb "github.com/tech/sendico/pkg/messaging/broker" @@ -141,8 +142,12 @@ func (i *Imp) loadConfig() (*config, error) { if cfg.Metrics == nil { cfg.Metrics = &grpcapp.MetricsConfig{Address: ":9406"} } + cfg.Gateway.Rail = discovery.NormalizeRail(cfg.Gateway.Rail) if cfg.Gateway.Rail == "" { return nil, merrors.InvalidArgument("gateway rail is required", "gateway.rail") } + if !discovery.IsKnownRail(cfg.Gateway.Rail) { + return nil, merrors.InvalidArgument("gateway rail must be a known token", "gateway.rail") + } return cfg, nil } diff --git a/api/gateway/tgsettle/internal/service/gateway/service.go b/api/gateway/tgsettle/internal/service/gateway/service.go index ba58185f..2a63a075 100644 --- a/api/gateway/tgsettle/internal/service/gateway/service.go +++ b/api/gateway/tgsettle/internal/service/gateway/service.go @@ -95,9 +95,12 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro broker: broker, cfg: cfg, msgCfg: cfg.MessagingSettings, - rail: strings.TrimSpace(cfg.Rail), + rail: discovery.NormalizeRail(cfg.Rail), invokeURI: strings.TrimSpace(cfg.InvokeURI), } + if svc.rail == "" { + svc.rail = strings.TrimSpace(cfg.Rail) + } svc.chatID = strings.TrimSpace(readEnv(cfg.TargetChatIDEnv)) svc.successReaction = strings.TrimSpace(cfg.SuccessReaction) if svc.successReaction == "" { @@ -526,11 +529,12 @@ func (s *Service) startAnnouncer() { if s == nil || s.producer == nil { return } - caps := discovery.CardPayoutRailGatewayOperations() + rail := discovery.RailProviderSettlement + caps := discovery.ProviderSettlementRailGatewayOperations() announce := discovery.Announcement{ - ID: discovery.StablePaymentGatewayID(discovery.NormalizeRail(s.rail)), + ID: discovery.StablePaymentGatewayID(rail), Service: string(mservice.PaymentGateway), - Rail: discovery.NormalizeRail(s.rail), + Rail: rail, Operations: caps, InvokeURI: s.invokeURI, } diff --git a/api/payments/orchestrator/internal/server/internal/discovery_clients.go b/api/payments/orchestrator/internal/server/internal/discovery_clients.go index 78e88e82..317ac9dd 100644 --- a/api/payments/orchestrator/internal/server/internal/discovery_clients.go +++ b/api/payments/orchestrator/internal/server/internal/discovery_clients.go @@ -31,7 +31,7 @@ var ( feesServiceNames = []string{"BILLING_FEES", string(mservice.FeePlans)} ledgerServiceNames = []string{"LEDGER", string(mservice.Ledger)} oracleServiceNames = []string{"FX_ORACLE", string(mservice.FXOracle)} - mntxServiceNames = []string{"CARD_PAYOUT_RAIL_GATEWAY", string(mservice.MntxGateway)} + mntxServiceNames = []string{"CARD_RAIL_GATEWAY", string(mservice.MntxGateway)} ) type discoveryEndpoint struct { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go index 4bae38aa..ce2460ab 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/module.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" pm "github.com/tech/sendico/pkg/model" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) // Factory builds initial orchestration-v2 payment aggregates. @@ -102,8 +103,12 @@ func New(deps ...Dependencies) Factory { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } return &svc{ - logger: dep.Logger.Named("agg"), + logger: logger.Named("agg"), now: func() time.Time { return time.Now().UTC() }, newID: func() bson.ObjectID { return bson.NewObjectID() diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go index 068a3530..7045157e 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/agg/service_test.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/pkg/merrors" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func TestCreate_OK(t *testing.T) { @@ -17,7 +18,8 @@ func TestCreate_OK(t *testing.T) { paymentID := bson.NewObjectID() factory := &svc{ - now: func() time.Time { return now }, + logger: zap.NewNop(), + now: func() time.Time { return now }, newID: func() bson.ObjectID { return paymentID }, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go index 06944f35..5edb5dcd 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/module.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // Reconciler applies external async events to payment runtime state. @@ -130,12 +131,16 @@ func New(deps ...Dependencies) Reconciler { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } now := dep.Now if now == nil { now = defaultNow } return &svc{ - logger: dep.Logger.Named("erecon"), + logger: logger.Named("erecon"), now: now, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go index f9ba4c26..34b4c437 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/erecon/service_test.go @@ -7,11 +7,12 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" ) func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) { now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) - reconciler := &svc{now: func() time.Time { return now }} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) in := &agg.Payment{ PaymentRef: "p1", @@ -74,7 +75,7 @@ func TestReconcile_GatewayWaiting_UpdatesRunningAndRefs(t *testing.T) { func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) { now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) - reconciler := &svc{now: func() time.Time { return now }} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := reconciler.Reconcile(Input{ Payment: &agg.Payment{ @@ -110,7 +111,7 @@ func TestReconcile_GatewaySuccess_SettlesPayment(t *testing.T) { func TestReconcile_GatewayFailureMapping(t *testing.T) { now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) - reconciler := &svc{now: func() time.Time { return now }} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) retryable := true out, err := reconciler.Reconcile(Input{ @@ -178,7 +179,7 @@ func TestReconcile_GatewayFailureMapping(t *testing.T) { func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) { now := time.Date(2026, time.January, 10, 11, 12, 13, 0, time.UTC) - reconciler := &svc{now: func() time.Time { return now }} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := reconciler.Reconcile(Input{ Payment: &agg.Payment{ @@ -212,7 +213,7 @@ func TestReconcile_LedgerTerminalFailure_ForcesAggregateFailed(t *testing.T) { } func TestReconcile_CardMatchByExternalRef(t *testing.T) { - reconciler := &svc{now: defaultNow} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow}) out, err := reconciler.Reconcile(Input{ Payment: &agg.Payment{ @@ -246,7 +247,7 @@ func TestReconcile_CardMatchByExternalRef(t *testing.T) { } func TestReconcile_MatchingErrors(t *testing.T) { - reconciler := &svc{now: defaultNow} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow}) _, err := reconciler.Reconcile(Input{ Payment: &agg.Payment{ @@ -293,7 +294,7 @@ func TestReconcile_MatchingErrors(t *testing.T) { } func TestReconcile_ValidationErrors(t *testing.T) { - reconciler := &svc{now: defaultNow} + reconciler := New(Dependencies{Logger: zap.NewNop(), Now: defaultNow}) tests := []struct { name string diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go index fd924ebb..6add1c20 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/idem/module.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) // Store is the minimal payment store contract required for idempotency handling. @@ -52,5 +53,9 @@ func New(deps ...Dependencies) Service { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("idem")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("idem")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go index 9a89e9b3..af1d5df5 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/oobs/service.go @@ -25,7 +25,11 @@ func newService(deps Dependencies) (Observer, error) { store = newMemoryAuditStore() } - logger := deps.Logger.Named("oobs") + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("oobs") metrics := deps.Metrics if metrics == nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go index 25ea0e7a..8fd9bab7 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/fixtures_test.go @@ -101,7 +101,7 @@ func sampleQuote(quoteRef, debit, settlement, feeTotal string) *model.PaymentQuo Hops: []*paymenttypes.QuoteRouteHop{ {Index: 1, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, {Index: 2, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleTransit, Gateway: "internal"}, - {Index: 3, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"}, + {Index: 3, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination, Gateway: "monetix"}, }, Settlement: &paymenttypes.QuoteRouteSettlement{ Model: "fix_source", diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go index 2e05f5e8..1620f5a6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/opagg/module.go @@ -3,6 +3,7 @@ package opagg import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // Aggregator compacts compatible quote items into recipient-level execution groups. @@ -45,5 +46,9 @@ func New(deps ...Dependencies) Aggregator { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("opagg")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("opagg")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go index 07fb7d9f..6da7736a 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ostate/module.go @@ -4,6 +4,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/agg" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // StateMachine is the single source of truth for orchestration-v2 state transitions. @@ -26,5 +27,9 @@ func New(deps ...Dependencies) StateMachine { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("ostate")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("ostate")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go index 246e418d..63b2dd69 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/pquery/service.go @@ -48,8 +48,12 @@ func newService(deps Dependencies) (Service, error) { if deps.Repository == nil { return nil, merrors.InvalidArgument("payment repository v2 is required") } + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } return &svc{ - logger: deps.Logger.Named("pquery"), + logger: logger.Named("pquery"), repo: deps.Repository, }, nil } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go index 2711368a..50d2f4e0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prepo/service.go @@ -49,6 +49,9 @@ func newWithStoreLogger(store paymentStore, logger mlogger.Logger) (Repository, if store == nil { return nil, merrors.InvalidArgument("payment repository v2: store is required") } + if logger == nil { + logger = zap.NewNop() + } if err := store.EnsureIndexes(requiredIndexes()); err != nil { return nil, err } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go index 7a20c2fe..3bd975b6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/module.go @@ -5,6 +5,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2" + "go.uber.org/zap" ) // Mapper transforms orchestration-v2 runtime aggregate snapshots into API responses. @@ -32,5 +33,9 @@ func New(deps ...Dependencies) Mapper { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("prmap")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("prmap")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go index 2442611a..fd7b8758 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/service_test.go @@ -305,7 +305,7 @@ func newPaymentFixture() *agg.Payment { Firm: true, }, Route: &paymenttypes.QuoteRouteSpecification{ - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "provider-1", PayoutMethod: "CARD", Network: "VISA", @@ -326,7 +326,7 @@ func newPaymentFixture() *agg.Payment { }, { Index: 20, - Rail: "CARD_PAYOUT", + Rail: "CARD", Gateway: "gw-card", InstanceID: "card-1", Role: paymenttypes.QuoteRouteHopRoleDestination, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go index 96cb5ba3..fe5530ee 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/prmap/state_mapping.go @@ -107,11 +107,13 @@ func inferRail(kind string, stepCode string) gatewayv1.Rail { case strings.Contains(all, "ledger"): return gatewayv1.Rail_RAIL_LEDGER case strings.Contains(all, "card_payout"), strings.Contains(all, "card"): - return gatewayv1.Rail_RAIL_CARD_PAYOUT - case strings.Contains(all, "provider_settlement"), strings.Contains(all, "provider"): - return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT + return gatewayv1.Rail_RAIL_CARD + case strings.Contains(all, "provider_settlement"), + strings.Contains(all, "settlement"), + strings.Contains(all, "provider"): + return gatewayv1.Rail_RAIL_SETTLEMENT case strings.Contains(all, "fiat_onramp"), strings.Contains(all, "onramp"): - return gatewayv1.Rail_RAIL_FIAT_ONRAMP + return gatewayv1.Rail_RAIL_ONRAMP case strings.Contains(all, "crypto"), strings.Contains(all, "chain"), strings.Contains(all, "tx"): return gatewayv1.Rail_RAIL_CRYPTO default: diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go index 8089611f..9ed79e86 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service.go @@ -19,6 +19,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) const ( @@ -57,7 +58,11 @@ func newService(deps Dependencies) (Service, error) { return nil, merrors.InvalidArgument("payment repository v2 is required") } - logger := deps.Logger.Named("psvc") + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } + logger = logger.Named("psvc") observer := deps.Observer if observer == nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 02b1efa5..00b4770f 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -600,7 +600,7 @@ func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification { func buildCardRoute() *paymenttypes.QuoteRouteSpecification { return &paymenttypes.QuoteRouteSpecification{ - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "gw-card", Network: "visa", } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index 3b9082f0..12cc4d6d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) // Store is the minimal quote store contract required by the resolver. @@ -45,12 +46,16 @@ func New(deps ...Dependencies) Resolver { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } now := dep.Now if now == nil { now = time.Now } return &svc{ - logger: dep.Logger.Named("qsnap"), + logger: logger.Named("qsnap"), now: now, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go index 1ce56463..5fda64db 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/payments/storage/model" quotestorage "github.com/tech/sendico/payments/storage/quote" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func TestResolve_NotFound(t *testing.T) { @@ -29,9 +30,7 @@ func TestResolve_NotFound(t *testing.T) { func TestResolve_Expired(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -58,9 +57,7 @@ func TestResolve_Expired(t *testing.T) { func TestResolve_NotExecutableState(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -88,9 +85,7 @@ func TestResolve_NotExecutableState(t *testing.T) { func TestResolve_NotExecutableExecutionNote(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -115,9 +110,7 @@ func TestResolve_NotExecutableExecutionNote(t *testing.T) { func TestResolve_ShapeMismatch(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go index 5341566b..8afa5cf6 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go @@ -9,6 +9,7 @@ import ( "github.com/tech/sendico/payments/storage/model" paymenttypes "github.com/tech/sendico/pkg/payments/types" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) func TestResolve_SingleShapeOK(t *testing.T) { @@ -30,9 +31,7 @@ func TestResolve_SingleShapeOK(t *testing.T) { ExpiresAt: now.Add(time.Minute), } - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -89,9 +88,7 @@ func TestResolve_ArrayShapeOK(t *testing.T) { ExpiresAt: now.Add(time.Minute), } - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -142,9 +139,7 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) { ExpiresAt: now.Add(time.Minute), } - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) out, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -177,9 +172,7 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) { func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { @@ -211,9 +204,7 @@ func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) { func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) { now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC) - resolver := &svc{ - now: func() time.Time { return now }, - } + resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }}) _, err := resolver.Resolve(context.Background(), &fakeStore{ getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go index 2bef45c4..95b6cf2c 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go @@ -3,6 +3,7 @@ package reqval import ( "github.com/tech/sendico/pkg/mlogger" "go.mongodb.org/mongo-driver/v2/bson" + "go.uber.org/zap" ) // Validator validates execute-payment inputs and returns a normalized context. @@ -50,5 +51,9 @@ func New(deps ...Dependencies) Validator { if len(deps) > 0 { dep = deps[0] } - return &svc{logger: dep.Logger.Named("reqval")} + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } + return &svc{logger: logger.Named("reqval")} } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go index f7efc8ba..8b21229f 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/module.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // Registry dispatches orchestration steps to rail/action-specific executors. @@ -44,7 +45,7 @@ type CryptoExecutor interface { ExecuteCrypto(ctx context.Context, req StepRequest) (*ExecuteOutput, error) } -// ProviderSettlementExecutor handles provider settlement SEND actions. +// ProviderSettlementExecutor handles settlement FX_CONVERT actions. type ProviderSettlementExecutor interface { ExecuteProviderSettlement(ctx context.Context, req StepRequest) (*ExecuteOutput, error) } @@ -70,8 +71,12 @@ type Dependencies struct { } func New(deps Dependencies) Registry { + logger := deps.Logger + if logger == nil { + logger = zap.NewNop() + } return &svc{ - logger: deps.Logger.Named("sexec"), + logger: logger.Named("sexec"), deps: deps, } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go index a05a0018..2f526a5d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/routes.go @@ -1,8 +1,6 @@ package sexec import ( - "strings" - "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/payments/storage/model" ) @@ -29,13 +27,20 @@ func classifyRoute(step xplan.Step) route { switch rail { case model.RailCrypto: return routeCrypto - case model.RailProviderSettlement: - return routeProviderSettlement case model.RailCardPayout: return routeCardPayout default: return routeUnknown } + case model.RailOperationFXConvert: + switch rail { + case model.RailProviderSettlement: + return routeProviderSettlement + case model.RailLedger: + return routeLedger + default: + return routeUnknown + } case model.RailOperationFee: if rail == model.RailCrypto { return routeCrypto @@ -57,8 +62,7 @@ func isLedgerAction(action model.RailOperation) bool { model.RailOperationExternalCredit, model.RailOperationMove, model.RailOperationBlock, - model.RailOperationRelease, - model.RailOperationFXConvert: + model.RailOperationRelease: return true default: return false @@ -66,47 +70,9 @@ func isLedgerAction(action model.RailOperation) bool { } func normalizeAction(action model.RailOperation) model.RailOperation { - switch strings.ToUpper(strings.TrimSpace(string(action))) { - case string(model.RailOperationDebit): - return model.RailOperationDebit - case string(model.RailOperationCredit): - return model.RailOperationCredit - case string(model.RailOperationExternalDebit): - return model.RailOperationExternalDebit - case string(model.RailOperationExternalCredit): - return model.RailOperationExternalCredit - case string(model.RailOperationMove): - return model.RailOperationMove - case string(model.RailOperationSend): - return model.RailOperationSend - case string(model.RailOperationFee): - return model.RailOperationFee - case string(model.RailOperationObserveConfirm): - return model.RailOperationObserveConfirm - case string(model.RailOperationFXConvert): - return model.RailOperationFXConvert - case string(model.RailOperationBlock): - return model.RailOperationBlock - case string(model.RailOperationRelease): - return model.RailOperationRelease - default: - return model.RailOperationUnspecified - } + return model.ParseRailOperation(string(action)) } func normalizeRail(rail model.Rail) model.Rail { - switch strings.ToUpper(strings.TrimSpace(string(rail))) { - case string(model.RailCrypto): - return model.RailCrypto - case string(model.RailProviderSettlement): - return model.RailProviderSettlement - case string(model.RailLedger): - return model.RailLedger - case string(model.RailCardPayout): - return model.RailCardPayout - case string(model.RailFiatOnRamp): - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } + return model.ParseRail(string(rail)) } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go index d70b7bf5..9a8130af 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service.go @@ -63,13 +63,13 @@ func (s *svc) Execute(ctx context.Context, in ExecuteInput) (out *ExecuteOutput, return out, err case routeProviderSettlement: if s.deps.ProviderSettlement == nil { - return nil, missingExecutorError("provider_settlement") + return nil, missingExecutorError("settlement") } out, err = s.deps.ProviderSettlement.ExecuteProviderSettlement(ctx, req) return out, err case routeCardPayout: if s.deps.CardPayout == nil { - return nil, missingExecutorError("card_payout") + return nil, missingExecutorError("card") } out, err = s.deps.CardPayout.ExecuteCardPayout(ctx, req) return out, err diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go index 0d370171..3f85f087 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/sexec/service_test.go @@ -37,7 +37,7 @@ func TestExecute_DispatchLedger(t *testing.T) { } } -func TestExecute_DispatchSendRailsAndObserve(t *testing.T) { +func TestExecute_DispatchRailsAndObserve(t *testing.T) { crypto := &fakeCryptoExecutor{} provider := &fakeProviderSettlementExecutor{} card := &fakeCardPayoutExecutor{} @@ -67,9 +67,9 @@ func TestExecute_DispatchSendRailsAndObserve(t *testing.T) { }, }, { - name: "send provider settlement", + name: "fx convert provider settlement", step: xplan.Step{ - StepRef: "s2", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement, + StepRef: "s2", StepCode: "provider.fx_convert", Action: model.RailOperationFXConvert, Rail: model.RailProviderSettlement, }, wantCalls: func(t *testing.T) { t.Helper() @@ -144,6 +144,19 @@ func TestExecute_UnsupportedStep(t *testing.T) { } } +func TestExecute_UnsupportedProviderSettlementSend(t *testing.T) { + registry := New(Dependencies{}) + + _, err := registry.Execute(context.Background(), ExecuteInput{ + Payment: &agg.Payment{PaymentRef: "p1"}, + Step: xplan.Step{StepRef: "s1", StepCode: "provider.send", Action: model.RailOperationSend, Rail: model.RailProviderSettlement}, + StepExecution: agg.StepExecution{StepRef: "s1", StepCode: "provider.send", Attempt: 1}, + }) + if !errors.Is(err, ErrUnsupportedStep) { + t.Fatalf("expected ErrUnsupportedStep, got %v", err) + } +} + func TestExecute_MissingExecutor(t *testing.T) { registry := New(Dependencies{}) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go index 7197e56b..f7c3d1d0 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/ssched/module.go @@ -8,6 +8,7 @@ import ( "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/ostate" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrationv2/xplan" "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" ) // Runtime selects runnable orchestration steps and reconciles step runtime states. @@ -73,9 +74,13 @@ func New(deps ...Dependencies) Runtime { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } stateMachine := dep.StateMachine if stateMachine == nil { - stateMachine = ostate.New(ostate.Dependencies{Logger: dep.Logger.Named("ssched.ostate")}) + stateMachine = ostate.New(ostate.Dependencies{Logger: logger.Named("ssched.ostate")}) } now := dep.Now if now == nil { @@ -84,7 +89,7 @@ func New(deps ...Dependencies) Runtime { } } return &svc{ - logger: dep.Logger.Named("ssched"), + logger: logger.Named("ssched"), stateMachine: stateMachine, now: now, } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go index 385e8a3e..985229a5 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_flow_test.go @@ -25,7 +25,7 @@ func TestCompile_ExternalToExternal_BridgeExpansion(t *testing.T) { }, { Index: 20, - Rail: "CARD_PAYOUT", + Rail: "CARD", Gateway: "gw-card", InstanceID: "card-1", Role: paymenttypes.QuoteRouteHopRoleDestination, @@ -101,7 +101,7 @@ func TestCompile_InternalToExternal_UsesHoldAndSettlementBranches(t *testing.T) Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 20, Rail: "CARD", Gateway: "gw-card", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, @@ -144,6 +144,64 @@ func TestCompile_ExternalToInternal_UsesCreditAfterObserve(t *testing.T) { assertStep(t, graph.Steps[2], "edge.10_20.ledger.credit", model.RailOperationCredit, model.RailLedger, model.ReportVisibilityHidden) } +func TestCompile_ExternalViaSettlement_UsesFXConvertOnSettlementHop(t *testing.T) { + compiler := New() + + graph, err := compiler.Compile(Input{ + IntentSnapshot: testIntent(model.PaymentKindPayout), + QuoteSnapshot: &model.PaymentQuoteSnapshot{ + Route: &paymenttypes.QuoteRouteSpecification{ + Hops: []*paymenttypes.QuoteRouteHop{ + { + Index: 10, + Rail: "CRYPTO", + Gateway: "gw-crypto", + InstanceID: "crypto-1", + Role: paymenttypes.QuoteRouteHopRoleSource, + }, + { + Index: 20, + Rail: "SETTLEMENT", + Gateway: "gw-settlement", + InstanceID: "settlement-1", + Role: paymenttypes.QuoteRouteHopRoleTransit, + }, + { + Index: 30, + Rail: "CARD", + Gateway: "gw-card", + InstanceID: "card-1", + Role: paymenttypes.QuoteRouteHopRoleDestination, + }, + }, + }, + }, + }) + if err != nil { + t.Fatalf("Compile returned error: %v", err) + } + + convertCount := 0 + sendCount := 0 + for _, step := range graph.Steps { + if step.Rail != model.RailProviderSettlement { + continue + } + if step.Action == model.RailOperationFXConvert { + convertCount++ + } + if step.Action == model.RailOperationSend { + sendCount++ + } + } + if convertCount == 0 { + t.Fatalf("expected at least one settlement FX_CONVERT step") + } + if sendCount != 0 { + t.Fatalf("expected no settlement SEND steps, got %d", sendCount) + } +} + func TestCompile_InternalToInternal_UsesMove(t *testing.T) { compiler := New() @@ -176,7 +234,7 @@ func TestCompile_GuardsArePrepended(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 10, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, ExecutionConditions: &paymenttypes.QuoteExecutionConditions{ @@ -213,7 +271,7 @@ func TestCompile_SingleExternalFallback(t *testing.T) { QuoteSnapshot: &model.PaymentQuoteSnapshot{ Route: &paymenttypes.QuoteRouteSpecification{ RouteRef: "route-summary", - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "gw-card", Network: "visa", }, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go index 1d90930a..8b3a54d2 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/compile_policy_test.go @@ -21,7 +21,7 @@ func TestCompile_PolicyOverrideByRailPair(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, @@ -78,7 +78,7 @@ func TestCompile_PolicyPriorityAndCustodyMatching(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 10, Rail: "CRYPTO", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 20, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 20, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, @@ -185,7 +185,7 @@ func TestCompile_ValidationErrors(t *testing.T) { Route: &paymenttypes.QuoteRouteSpecification{ Hops: []*paymenttypes.QuoteRouteHop{ {Index: 1, Rail: "LEDGER", Role: paymenttypes.QuoteRouteHopRoleSource}, - {Index: 2, Rail: "CARD_PAYOUT", Role: paymenttypes.QuoteRouteHopRoleDestination}, + {Index: 2, Rail: "CARD", Role: paymenttypes.QuoteRouteHopRoleDestination}, }, }, }, diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go index 3efcc9a0..67fdd603 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/expansion.go @@ -81,7 +81,7 @@ func normalizeCommitPolicy(policy model.CommitPolicy) model.CommitPolicy { func defaultVisibilityForAction(action model.RailOperation, role paymenttypes.QuoteRouteHopRole) model.ReportVisibility { switch action { - case model.RailOperationSend, model.RailOperationObserveConfirm: + case model.RailOperationSend, model.RailOperationFXConvert, model.RailOperationObserveConfirm: if role == paymenttypes.QuoteRouteHopRoleDestination { return model.ReportVisibilityUser } @@ -106,6 +106,8 @@ func defaultUserLabel( return "Card payout submitted" } return "Transfer submitted" + case model.RailOperationFXConvert: + return "FX conversion submitted" case model.RailOperationObserveConfirm: if kind == model.PaymentKindPayout && rail == model.RailCardPayout { return "Card payout confirmed" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go index f1295002..8e8686cb 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/module.go @@ -4,6 +4,7 @@ import ( "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/mlogger" paymenttypes "github.com/tech/sendico/pkg/payments/types" + "go.uber.org/zap" ) // Compiler builds execution runtime step graph from resolved quote snapshots. @@ -116,7 +117,11 @@ func New(deps ...Dependencies) Compiler { if len(deps) > 0 { dep = deps[0] } + logger := dep.Logger + if logger == nil { + logger = zap.NewNop() + } return &svc{ - logger: dep.Logger.Named("xplan"), + logger: logger.Named("xplan"), } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go index 81bb0346..c12b29ac 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/route.go @@ -58,6 +58,9 @@ func edgeCode(from normalizedHop, to normalizedHop, rail model.Rail, op string) } func railToken(rail model.Rail) string { + if rail == model.RailCardPayout { + return "card_payout" + } return strings.ToLower(strings.TrimSpace(string(rail))) } @@ -158,25 +161,5 @@ func normalizeHopRole( } func normalizeRail(raw string) model.Rail { - token := strings.ToUpper(strings.TrimSpace(raw)) - token = strings.ReplaceAll(token, "-", "_") - token = strings.ReplaceAll(token, " ", "_") - for strings.Contains(token, "__") { - token = strings.ReplaceAll(token, "__", "_") - } - - switch token { - case "CRYPTO": - return model.RailCrypto - case "PROVIDER_SETTLEMENT", "PROVIDER": - return model.RailProviderSettlement - case "LEDGER": - return model.RailLedger - case "CARD_PAYOUT", "CARD": - return model.RailCardPayout - case "FIAT_ONRAMP", "FIAT_ON_RAMP": - return model.RailFiatOnRamp - default: - return model.RailUnspecified - } + return model.ParseRail(raw) } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go index 1318404b..314a0dd3 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_boundaries.go @@ -142,15 +142,16 @@ func appendGuards(ex *expansion, conditions *paymenttypes.QuoteExecutionConditio } func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { - visibility := defaultVisibilityForAction(model.RailOperationSend, hop.role) + action, op := railActionForHop(hop) + visibility := defaultVisibilityForAction(action, hop.role) userLabel := "" if visibility == model.ReportVisibilityUser { - userLabel = defaultUserLabel(model.RailOperationSend, hop.rail, hop.role, intent.Kind) + userLabel = defaultUserLabel(action, hop.rail, hop.role, intent.Kind) } return Step{ - StepCode: singleHopCode(hop, "send"), - Kind: StepKindRailSend, - Action: model.RailOperationSend, + StepCode: singleHopCode(hop, op), + Kind: kindForAction(action), + Action: action, Rail: hop.rail, Gateway: hop.gateway, InstanceID: hop.instanceID, @@ -161,6 +162,13 @@ func makeRailSendStep(hop normalizedHop, intent model.PaymentIntent) Step { } } +func railActionForHop(hop normalizedHop) (model.RailOperation, string) { + if hop.rail == model.RailProviderSettlement { + return model.RailOperationFXConvert, "fx_convert" + } + return model.RailOperationSend, "send" +} + func makeRailObserveStep(hop normalizedHop, intent model.PaymentIntent) Step { visibility := defaultVisibilityForAction(model.RailOperationObserveConfirm, hop.role) userLabel := "" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go index f0ba594c..a9da65f4 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/xplan/service_policy.go @@ -199,7 +199,7 @@ func inferPolicyRail(spec PolicyStep, action model.RailOperation, from normalize } switch action { - case model.RailOperationSend, model.RailOperationObserveConfirm, model.RailOperationFee: + case model.RailOperationSend, model.RailOperationFXConvert, model.RailOperationObserveConfirm, model.RailOperationFee: return to.rail case model.RailOperationBlock, model.RailOperationRelease, @@ -220,7 +220,7 @@ func resolveStepContext( from normalizedHop, to normalizedHop, ) (uint32, paymenttypes.QuoteRouteHopRole, string, string) { - if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) { + if rail == to.rail && (action == model.RailOperationSend || action == model.RailOperationFXConvert || action == model.RailOperationObserveConfirm || action == model.RailOperationFee) { return to.index, to.role, to.gateway, to.instanceID } if rail == from.rail { @@ -234,7 +234,7 @@ func resolveStepContext( func kindForAction(action model.RailOperation) StepKind { switch action { - case model.RailOperationSend: + case model.RailOperationSend, model.RailOperationFXConvert: return StepKindRailSend case model.RailOperationObserveConfirm: return StepKindRailObserve diff --git a/api/payments/orchestrator/main.go b/api/payments/orchestrator/main.go index 0f188098..655b1b7d 100644 --- a/api/payments/orchestrator/main.go +++ b/api/payments/orchestrator/main.go @@ -41,7 +41,7 @@ step_scheduler_runtime Pick runnable steps, manage dependency checks, retries/attempts, and mark blocked/skipped. step_executor_registry -Rail/action executors (ledger, crypto, provider_settlement, card_payout, observe_confirm) behind interfaces. +Rail/action executors (ledger, crypto, settlement, card, observe_confirm) behind interfaces. external_event_reconciler Consume async gateway/ledger/card events, map to step updates, append external refs, advance aggregate state. diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go index a3dc12c5..278be422 100644 --- a/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/adjacency.go @@ -64,8 +64,8 @@ func networkPriority(edgeNetwork, requested string) int { } func normalizeRail(value model.Rail) model.Rail { - normalized := model.Rail(strings.ToUpper(strings.TrimSpace(string(value)))) - if normalized == "" { + normalized := model.ParseRail(string(value)) + if normalized == model.RailUnspecified { return model.RailUnspecified } return normalized diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go index cd917aa3..fa71e088 100644 --- a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_network_test.go @@ -23,7 +23,7 @@ func TestFind_NetworkFiltersEdges(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) if got, want := path.Edges[0].Network, "TRON"; got != want { t.Fatalf("unexpected first edge network: got=%q want=%q", got, want) } @@ -47,7 +47,7 @@ func TestFind_PrefersExactNetworkOverWildcard(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "PROVIDER_SETTLEMENT", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "SETTLEMENT", "CARD"}) } func TestFind_DeterministicTieBreak(t *testing.T) { @@ -68,7 +68,7 @@ func TestFind_DeterministicTieBreak(t *testing.T) { } // Both routes have equal length; lexical tie-break chooses LEDGER branch. - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) } func TestFind_IgnoresInvalidEdges(t *testing.T) { @@ -88,5 +88,5 @@ func TestFind_IgnoresInvalidEdges(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) } diff --git a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go index 711cb92a..001fd148 100644 --- a/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go +++ b/api/payments/quotation/internal/service/quotation/graph_path_finder/service_test.go @@ -62,7 +62,7 @@ func TestFind_FindsIndirectPath(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) if got, want := len(path.Edges), 2; got != want { t.Fatalf("unexpected edge count: got=%d want=%d", got, want) } @@ -84,7 +84,7 @@ func TestFind_PrefersShortestPath(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "CARD"}) } func TestFind_HandlesCycles(t *testing.T) { @@ -103,7 +103,7 @@ func TestFind_HandlesCycles(t *testing.T) { t.Fatalf("unexpected error: %v", err) } - assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD_PAYOUT"}) + assertPathRails(t, path, []string{"CRYPTO", "LEDGER", "CARD"}) } func TestFind_ReturnsErrorWhenPathUnavailable(t *testing.T) { diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go index 045f2f7b..a4db0da1 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/compute_test.go @@ -131,6 +131,42 @@ func TestBuildPlan_RequiresFXAddsMiddleStep(t *testing.T) { } } +func TestBuildPlan_RequiresFXUsesSettlementCurrencyForDestinationStep(t *testing.T) { + svc := New(nil) + orgID := bson.NewObjectID() + intent := sampleCryptoToCardQuoteIntent() + intent.RequiresFX = true + intent.SettlementCurrency = "RUB" + + planModel, err := svc.BuildPlan(context.Background(), ComputeInput{ + OrganizationRef: orgID.Hex(), + OrganizationID: orgID, + BaseIdempotencyKey: "idem-key", + PreviewOnly: false, + Intents: []*transfer_intent_hydrator.QuoteIntent{intent}, + }) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(planModel.Items) != 1 { + t.Fatalf("expected one plan item") + } + steps := planModel.Items[0].Steps + if got, want := len(steps), 4; got != want { + t.Fatalf("unexpected step count: got=%d want=%d", got, want) + } + last := steps[len(steps)-1] + if last == nil || last.Amount == nil { + t.Fatalf("expected destination step amount") + } + if got, want := strings.TrimSpace(last.Amount.GetCurrency()), "RUB"; got != want { + t.Fatalf("unexpected destination step currency: got=%q want=%q", got, want) + } + if got, want := last.Operation, model.RailOperationSend; got != want { + t.Fatalf("unexpected destination operation: got=%q want=%q", got, want) + } +} + func TestBuildPlan_SelectsGatewaysAndIgnoresIrrelevant(t *testing.T) { svc := New(nil, WithGatewayRegistry(staticGatewayRegistry{ items: []*model.GatewayInstanceDescriptor{ @@ -406,7 +442,7 @@ func TestCompute_FailsWhenCoreReturnsDifferentRoute(t *testing.T) { quote: &ComputedQuote{ DebitAmount: &moneyv1.Money{Amount: "100", Currency: "USD"}, Route: "ationv2.RouteSpecification{ - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "other-provider", PayoutMethod: "CARD", }, diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go index 11797093..5d93f9d8 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding.go @@ -16,6 +16,9 @@ func (s *QuoteComputationService) resolveRouteRails( destinationRail model.Rail, network string, ) ([]model.Rail, error) { + sourceRail = model.ParseRail(string(sourceRail)) + destinationRail = model.ParseRail(string(destinationRail)) + s.logger.Debug("Resolving route rails", zap.String("source_rail", string(sourceRail)), zap.String("dest_rail", string(destinationRail)), @@ -179,8 +182,8 @@ func (s *QuoteComputationService) routeGraphEdges(ctx context.Context) ([]graph_ continue } - from := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.FromRail)))) - to := model.Rail(strings.ToUpper(strings.TrimSpace(string(route.ToRail)))) + from := model.ParseRail(string(route.FromRail)) + to := model.ParseRail(string(route.ToRail)) if from == model.RailUnspecified || to == model.RailUnspecified { continue diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go index 16ae2c9d..0acc2868 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_path_finding_test.go @@ -74,7 +74,7 @@ func TestBuildPlan_UsesRouteGraphPath(t *testing.T) { if got, want := string(item.Steps[1].Rail), string(model.RailProviderSettlement); got != want { t.Fatalf("unexpected transit rail: got=%q want=%q", got, want) } - if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "PROVIDER_SETTLEMENT" { + if got := strings.ToUpper(strings.TrimSpace(item.Route.GetHops()[1].GetRail())); got != "SETTLEMENT" { t.Fatalf("unexpected route transit hop rail: %q", got) } } diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go index f052318a..9b7b490e 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/planner_steps.go @@ -5,6 +5,7 @@ import ( "strings" "github.com/tech/sendico/payments/storage/model" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) func buildComputationSteps( @@ -19,6 +20,7 @@ func buildComputationSteps( attrs := intent.Attributes amount := protoMoneyFromModel(intent.Amount) + destinationAmount := destinationStepAmount(intent, amount) sourceRail := sourceRailForIntent(intent) destinationRail := destinationRailForIntent(intent) rails := normalizeRouteRails(sourceRail, destinationRail, routeRails) @@ -101,7 +103,7 @@ func buildComputationSteps( GatewayID: destinationGatewayID, InstanceID: destinationInstanceID, DependsOn: []string{lastStepID}, - Amount: cloneProtoMoney(amount), + Amount: destinationAmount, Optional: false, IncludeInAggregate: true, }) @@ -196,3 +198,22 @@ func destinationOperationForRail(rail model.Rail) model.RailOperation { return model.RailOperationExternalCredit } } + +func destinationStepAmount(intent model.PaymentIntent, sourceAmount *moneyv1.Money) *moneyv1.Money { + amount := cloneProtoMoney(sourceAmount) + if amount == nil { + return nil + } + if !intent.RequiresFX { + return amount + } + + settlementCurrency := strings.ToUpper(strings.TrimSpace(intent.SettlementCurrency)) + if settlementCurrency == "" && intent.FX != nil && intent.FX.Pair != nil { + settlementCurrency = strings.ToUpper(strings.TrimSpace(intent.FX.Pair.Quote)) + } + if settlementCurrency != "" { + amount.Currency = settlementCurrency + } + return amount +} diff --git a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go index b6d80ffa..e0bfa87d 100644 --- a/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go +++ b/api/payments/quotation/internal/service/quotation/quote_computation_service/quote_binding_validation.go @@ -3,6 +3,7 @@ package quote_computation_service import ( "strings" + "github.com/tech/sendico/payments/storage/model" "github.com/tech/sendico/pkg/merrors" quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2" ) @@ -59,6 +60,9 @@ func normalizeSettlementParts(src *quotationv2.RouteSettlement) (chain, token, c } func normalizeRail(value string) string { + if rail := model.ParseRail(value); rail != model.RailUnspecified { + return string(rail) + } return strings.ToUpper(strings.TrimSpace(value)) } diff --git a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go index 40c96e56..c81282b7 100644 --- a/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go +++ b/api/payments/quotation/internal/service/quotation/quote_response_mapper_v2/service_test.go @@ -40,7 +40,7 @@ func TestMap_ExecutableQuote(t *testing.T) { Currency: "USD", }, Route: "ationv2.RouteSpecification{ - Rail: "CARD_PAYOUT", + Rail: "CARD", Provider: "monetix", PayoutMethod: "CARD", Settlement: "ationv2.RouteSettlement{ diff --git a/api/payments/storage/model/payment.go b/api/payments/storage/model/payment.go index 0837e488..6b8d95f1 100644 --- a/api/payments/storage/model/payment.go +++ b/api/payments/storage/model/payment.go @@ -75,10 +75,11 @@ type Rail string const ( RailUnspecified Rail = "UNSPECIFIED" RailCrypto Rail = "CRYPTO" - RailProviderSettlement Rail = "PROVIDER_SETTLEMENT" + RailProviderSettlement Rail = "SETTLEMENT" RailLedger Rail = "LEDGER" - RailCardPayout Rail = "CARD_PAYOUT" - RailFiatOnRamp Rail = "FIAT_ONRAMP" + RailCardPayout Rail = "CARD" + RailFiatOnRamp Rail = "ONRAMP" + RailFiatOffRamp Rail = "OFFRAMP" ) // RailOperation identifies an explicit action within a payment plan. diff --git a/api/payments/storage/model/plan_template.go b/api/payments/storage/model/plan_template.go index b33d7c35..b106ebd4 100644 --- a/api/payments/storage/model/plan_template.go +++ b/api/payments/storage/model/plan_template.go @@ -42,8 +42,8 @@ func (t *PaymentPlanTemplate) Normalize() { if t == nil { return } - t.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.FromRail)))) - t.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(t.ToRail)))) + t.FromRail = normalizeRail(t.FromRail) + t.ToRail = normalizeRail(t.ToRail) t.Network = strings.ToUpper(strings.TrimSpace(t.Network)) if len(t.Steps) == 0 { return @@ -51,7 +51,7 @@ func (t *PaymentPlanTemplate) Normalize() { for i := range t.Steps { step := &t.Steps[i] step.StepID = strings.TrimSpace(step.StepID) - step.Rail = Rail(strings.ToUpper(strings.TrimSpace(string(step.Rail)))) + step.Rail = normalizeRail(step.Rail) step.Operation = strings.ToLower(strings.TrimSpace(step.Operation)) step.ReportVisibility = NormalizeReportVisibility(step.ReportVisibility) step.CommitPolicy = normalizeCommitPolicy(step.CommitPolicy) diff --git a/api/payments/storage/model/rails.go b/api/payments/storage/model/rails.go new file mode 100644 index 00000000..5619a482 --- /dev/null +++ b/api/payments/storage/model/rails.go @@ -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) +} diff --git a/api/payments/storage/model/rails_test.go b/api/payments/storage/model/rails_test.go new file mode 100644 index 00000000..5412451e --- /dev/null +++ b/api/payments/storage/model/rails_test.go @@ -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) + } + }) + } +} diff --git a/api/payments/storage/model/route.go b/api/payments/storage/model/route.go index 2144072c..63b59e9c 100644 --- a/api/payments/storage/model/route.go +++ b/api/payments/storage/model/route.go @@ -28,8 +28,8 @@ func (r *PaymentRoute) Normalize() { if r == nil { return } - r.FromRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.FromRail)))) - r.ToRail = Rail(strings.ToUpper(strings.TrimSpace(string(r.ToRail)))) + r.FromRail = normalizeRail(r.FromRail) + r.ToRail = normalizeRail(r.ToRail) r.Network = strings.ToUpper(strings.TrimSpace(r.Network)) } diff --git a/api/payments/storage/mongo/store/plan_templates.go b/api/payments/storage/mongo/store/plan_templates.go index cbfb55c1..8b5f7682 100644 --- a/api/payments/storage/mongo/store/plan_templates.go +++ b/api/payments/storage/mongo/store/plan_templates.go @@ -123,6 +123,7 @@ func (p *PlanTemplates) GetByID(ctx context.Context, id bson.ObjectID) (*model.P } return nil, err } + entity.Normalize() return entity, nil } @@ -133,11 +134,15 @@ func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTempl query := repository.Query() - if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" { - query = query.Filter(repository.Field("fromRail"), from) + if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 { + query = query.Filter(repository.Field("fromRail"), from[0]) + } else if len(from) > 1 { + query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...) } - if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" { - query = query.Filter(repository.Field("toRail"), to) + if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 { + query = query.Filter(repository.Field("toRail"), to[0]) + } else if len(to) > 1 { + query = query.In(repository.Field("toRail"), stringSliceToAny(to)...) } if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" { query = query.Filter(repository.Field("network"), network) @@ -152,6 +157,7 @@ func (p *PlanTemplates) List(ctx context.Context, filter *model.PaymentPlanTempl if err := cur.Decode(item); err != nil { return err } + item.Normalize() templates = append(templates, item) return nil } diff --git a/api/payments/storage/mongo/store/routes.go b/api/payments/storage/mongo/store/routes.go index 49fba147..fe2a8060 100644 --- a/api/payments/storage/mongo/store/routes.go +++ b/api/payments/storage/mongo/store/routes.go @@ -120,6 +120,7 @@ func (r *Routes) GetByID(ctx context.Context, id bson.ObjectID) (*model.PaymentR } return nil, err } + entity.Normalize() return entity, nil } @@ -130,11 +131,15 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m query := repository.Query() - if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" { - query = query.Filter(repository.Field("fromRail"), from) + if from := normalizedRailFilterValues(filter.FromRail); len(from) == 1 { + query = query.Filter(repository.Field("fromRail"), from[0]) + } else if len(from) > 1 { + query = query.In(repository.Field("fromRail"), stringSliceToAny(from)...) } - if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" { - query = query.Filter(repository.Field("toRail"), to) + if to := normalizedRailFilterValues(filter.ToRail); len(to) == 1 { + query = query.Filter(repository.Field("toRail"), to[0]) + } else if len(to) > 1 { + query = query.In(repository.Field("toRail"), stringSliceToAny(to)...) } if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" { query = query.Filter(repository.Field("network"), network) @@ -149,6 +154,7 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m if err := cur.Decode(item); err != nil { return err } + item.Normalize() routes = append(routes, item) return nil } @@ -163,3 +169,38 @@ func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*m } var _ storage.RoutesStore = (*Routes)(nil) + +func normalizedRailFilterValues(rail model.Rail) []string { + clean := strings.ToUpper(strings.TrimSpace(string(rail))) + if clean == "" { + return nil + } + + if parsed := model.ParseRail(string(rail)); parsed != model.RailUnspecified { + switch parsed { + case model.RailCrypto: + return []string{string(model.RailCrypto), "RAIL_CRYPTO"} + case model.RailProviderSettlement: + return []string{string(model.RailProviderSettlement), "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT"} + case model.RailLedger: + return []string{string(model.RailLedger), "RAIL_LEDGER"} + case model.RailCardPayout: + return []string{string(model.RailCardPayout), "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT"} + case model.RailFiatOnRamp: + return []string{string(model.RailFiatOnRamp), "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP"} + case model.RailFiatOffRamp: + return []string{string(model.RailFiatOffRamp), "FIAT_OFFRAMP", "RAIL_OFFRAMP", "RAIL_FIAT_OFFRAMP"} + default: + return []string{string(parsed)} + } + } + return []string{clean} +} + +func stringSliceToAny(values []string) []any { + out := make([]any, 0, len(values)) + for _, value := range values { + out = append(out, value) + } + return out +} diff --git a/api/pkg/discovery/gatewayid_test.go b/api/pkg/discovery/gatewayid_test.go index fd30e453..f0c8162d 100644 --- a/api/pkg/discovery/gatewayid_test.go +++ b/api/pkg/discovery/gatewayid_test.go @@ -10,7 +10,7 @@ func TestStableGatewayID(t *testing.T) { want string }{ {name: "prefix and key", prefix: "crypto_rail_gateway", key: " TRON ", want: "crypto_rail_gateway_tron"}, - {name: "prefix trailing underscore", prefix: "payment_gateway_", key: " PROVIDER_SETTLEMENT ", want: "payment_gateway_provider_settlement"}, + {name: "prefix trailing underscore", prefix: "payment_gateway_", key: " SETTLEMENT ", want: "payment_gateway_settlement"}, {name: "missing key", prefix: "payment_gateway", key: " ", want: "payment_gateway_unknown"}, {name: "missing prefix", prefix: " ", key: "TRON", want: "tron"}, } @@ -35,7 +35,7 @@ func TestStableCryptoRailGatewayID(t *testing.T) { } func TestStablePaymentGatewayID(t *testing.T) { - if got, want := StablePaymentGatewayID(" PROVIDER_SETTLEMENT "), "payment_gateway_provider_settlement"; got != want { + if got, want := StablePaymentGatewayID(" SETTLEMENT "), "payment_gateway_settlement"; got != want { t.Fatalf("unexpected stable id: got=%q want=%q", got, want) } if got, want := StablePaymentGatewayID(""), "payment_gateway_unknown"; got != want { diff --git a/api/pkg/discovery/rail_vocab.go b/api/pkg/discovery/rail_vocab.go index 61163cd7..c0249802 100644 --- a/api/pkg/discovery/rail_vocab.go +++ b/api/pkg/discovery/rail_vocab.go @@ -4,10 +4,10 @@ import "strings" const ( RailCrypto = "CRYPTO" - RailProviderSettlement = "PROVIDER_SETTLEMENT" + RailProviderSettlement = "SETTLEMENT" RailLedger = "LEDGER" - RailCardPayout = "CARD_PAYOUT" - RailFiatOnRamp = "FIAT_ONRAMP" + RailCardPayout = "CARD" + RailFiatOnRamp = "ONRAMP" ) const ( @@ -48,7 +48,30 @@ var knownRailOperations = map[string]struct{}{ // NormalizeRail canonicalizes a rail token. func NormalizeRail(value string) string { - return strings.ToUpper(strings.TrimSpace(value)) + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" { + return "" + } + clean = strings.ReplaceAll(clean, "-", "_") + clean = strings.ReplaceAll(clean, " ", "_") + for strings.Contains(clean, "__") { + clean = strings.ReplaceAll(clean, "__", "_") + } + + switch clean { + case RailCrypto, "RAIL_CRYPTO": + return RailCrypto + case RailProviderSettlement, "PROVIDER_SETTLEMENT", "RAIL_SETTLEMENT", "RAIL_PROVIDER_SETTLEMENT": + return RailProviderSettlement + case RailLedger, "RAIL_LEDGER": + return RailLedger + case RailCardPayout, "CARD_PAYOUT", "RAIL_CARD", "RAIL_CARD_PAYOUT": + return RailCardPayout + case RailFiatOnRamp, "FIAT_ONRAMP", "RAIL_ONRAMP", "RAIL_FIAT_ONRAMP": + return RailFiatOnRamp + default: + return clean + } } // IsKnownRail reports whether the value is a recognized payment rail. @@ -60,8 +83,8 @@ func IsKnownRail(value string) bool { // NormalizeRailOperation canonicalizes a rail operation token. func NormalizeRailOperation(value string) string { clean := strings.ToUpper(strings.TrimSpace(value)) - if strings.HasPrefix(clean, "RAIL_OPERATION_") { - clean = strings.TrimPrefix(clean, "RAIL_OPERATION_") + if after, ok := strings.CutPrefix(clean, "RAIL_OPERATION_"); ok { + clean = after } return clean } @@ -140,3 +163,11 @@ func CardPayoutRailGatewayOperations() []string { RailOperationObserveConfirm, } } + +// ProviderSettlementRailGatewayOperations returns canonical operations for settlement gateways. +func ProviderSettlementRailGatewayOperations() []string { + return []string{ + RailOperationFXConvert, + RailOperationObserveConfirm, + } +} diff --git a/api/pkg/discovery/rail_vocab_test.go b/api/pkg/discovery/rail_vocab_test.go index 8c5e7d26..7bb0cf4f 100644 --- a/api/pkg/discovery/rail_vocab_test.go +++ b/api/pkg/discovery/rail_vocab_test.go @@ -47,3 +47,15 @@ func TestIsKnownRail(t *testing.T) { t.Fatalf("did not expect telegram rail to be known") } } + +func TestNormalizeRailAliases(t *testing.T) { + if got := NormalizeRail("provider_settlement"); got != RailProviderSettlement { + t.Fatalf("provider_settlement alias mismatch: got=%q want=%q", got, RailProviderSettlement) + } + if got := NormalizeRail("card_payout"); got != RailCardPayout { + t.Fatalf("card_payout alias mismatch: got=%q want=%q", got, RailCardPayout) + } + if got := NormalizeRail("RAIL_SETTLEMENT"); got != RailProviderSettlement { + t.Fatalf("RAIL_SETTLEMENT alias mismatch: got=%q want=%q", got, RailProviderSettlement) + } +} diff --git a/api/pkg/messaging/internal/natsb/broker.go b/api/pkg/messaging/internal/natsb/broker.go index daa7a7d2..b0d88fb5 100644 --- a/api/pkg/messaging/internal/natsb/broker.go +++ b/api/pkg/messaging/internal/natsb/broker.go @@ -34,40 +34,108 @@ type envConfig struct { } const defaultConsumerBufferSize = 1024 +const redactedNATSPassword = "xxxxx" -func sanitizeNATSURL(rawURL string) string { +func buildSafePublishableNATSURL(rawURL string) string { if rawURL == "" { return rawURL } parts := strings.Split(rawURL, ",") - sanitized := make([]string, 0, len(parts)) + safe := make([]string, 0, len(parts)) for _, part := range parts { trimmed := strings.TrimSpace(part) if trimmed == "" { continue } - if !strings.Contains(trimmed, "://") { - sanitized = append(sanitized, trimmed) + built, ok := buildSafePublishableNATSEntry(trimmed) + if !ok { + safe = append(safe, trimmed) continue } - - parsed, err := url.Parse(trimmed) - if err != nil { - sanitized = append(sanitized, trimmed) - continue - } - if parsed.User == nil { - sanitized = append(sanitized, trimmed) - continue - } - sanitized = append(sanitized, parsed.Redacted()) + safe = append(safe, built) } - if len(sanitized) == 0 { + if len(safe) == 0 { return strings.TrimSpace(rawURL) } - return strings.Join(sanitized, ",") + return strings.Join(safe, ",") +} + +func buildSafePublishableNATSEntry(raw string) (string, bool) { + parsed, err := url.Parse(raw) + if err == nil && parsed.Host != "" { + safe := &url.URL{ + Scheme: parsed.Scheme, + Host: parsed.Host, + Path: parsed.Path, + RawPath: parsed.RawPath, + RawQuery: parsed.RawQuery, + Fragment: parsed.Fragment, + } + if safe.Scheme == "" { + safe.Scheme = "nats" + } + if parsed.User != nil { + username := parsed.User.Username() + if username == "" { + username = redactedNATSPassword + } + safe.User = url.UserPassword(username, redactedNATSPassword) + } + return safe.String(), true + } + + return buildSafePublishableFromAuthority(raw) +} + +func buildSafePublishableFromAuthority(raw string) (string, bool) { + scheme := "nats" + authorityAndSuffix := raw + if schemeIndex := strings.Index(raw, "://"); schemeIndex >= 0 { + if candidate := strings.TrimSpace(raw[:schemeIndex]); candidate != "" { + scheme = candidate + } + authorityAndSuffix = raw[schemeIndex+3:] + } + + authorityEnd := strings.IndexAny(authorityAndSuffix, "/?#") + if authorityEnd < 0 { + authorityEnd = len(authorityAndSuffix) + } + + authority := authorityAndSuffix[:authorityEnd] + suffix := authorityAndSuffix[authorityEnd:] + atIndex := strings.LastIndex(authority, "@") + hostPort := authority + username := "" + if atIndex >= 0 { + userInfo := authority[:atIndex] + hostPort = authority[atIndex+1:] + if hostPort == "" { + return "", false + } + username = userInfo + if colonIndex := strings.Index(userInfo, ":"); colonIndex >= 0 { + username = userInfo[:colonIndex] + } + if username == "" { + username = redactedNATSPassword + } + } + + if hostPort == "" || strings.ContainsAny(hostPort, " \t\r\n") { + return "", false + } + + safe := &url.URL{ + Scheme: scheme, + Host: hostPort, + } + if username != "" { + safe.User = url.UserPassword(username, redactedNATSPassword) + } + return safe.String() + suffix, true } // loadEnv gathers and validates connection details from environment variables @@ -144,7 +212,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e } natsURL = u.String() } - sanitizedNATSURL := sanitizeNATSURL(natsURL) + publishableNATSURL := buildSafePublishableNATSURL(natsURL) opts := []nats.Option{ nats.Name(settings.NATSName), @@ -156,7 +224,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e zap.String("broker", settings.NATSName), } if conn != nil { - fields = append(fields, zap.String("connected_url", sanitizeNATSURL(conn.ConnectedUrl()))) + fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(conn.ConnectedUrl()))) } if err != nil { fields = append(fields, zap.Error(err)) @@ -168,7 +236,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e zap.String("broker", settings.NATSName), } if conn != nil { - fields = append(fields, zap.String("connected_url", sanitizeNATSURL(conn.ConnectedUrl()))) + fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(conn.ConnectedUrl()))) } l.Info("Reconnected to NATS", fields...) }), @@ -178,7 +246,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e } if conn != nil { if url := conn.ConnectedUrl(); url != "" { - fields = append(fields, zap.String("connected_url", sanitizeNATSURL(url))) + fields = append(fields, zap.String("connected_url", buildSafePublishableNATSURL(url))) } if err := conn.LastError(); err != nil { fields = append(fields, zap.Error(err)) @@ -208,7 +276,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e } if res.nc, err = nats.Connect(natsURL, opts...); err != nil { - l.Error("Failed to connect to NATS", zap.String("url", sanitizedNATSURL), zap.Error(err)) + l.Error("Failed to connect to NATS", zap.String("url", publishableNATSURL), zap.Error(err)) return nil, err } if res.js, err = res.nc.JetStream(); err != nil { @@ -216,7 +284,7 @@ func NewNatsBroker(logger mlogger.Logger, settings *nc.Settings) (*NatsBroker, e } logger.Info("Connected to NATS", zap.String("broker", settings.NATSName), - zap.String("url", sanitizedNATSURL)) + zap.String("url", publishableNATSURL)) return res, nil } diff --git a/api/pkg/messaging/internal/natsb/broker_test.go b/api/pkg/messaging/internal/natsb/broker_test.go index a1212363..4152a962 100644 --- a/api/pkg/messaging/internal/natsb/broker_test.go +++ b/api/pkg/messaging/internal/natsb/broker_test.go @@ -5,14 +5,14 @@ import ( "testing" ) -func TestSanitizeNATSURL(t *testing.T) { +func TestBuildSafePublishableNATSURL(t *testing.T) { t.Parallel() t.Run("redacts single URL credentials", func(t *testing.T) { t.Parallel() raw := "nats://alice:supersecret@localhost:4222" - sanitized := sanitizeNATSURL(raw) + sanitized := buildSafePublishableNATSURL(raw) if strings.Contains(sanitized, "supersecret") { t.Fatalf("expected password to be redacted, got %q", sanitized) @@ -22,11 +22,25 @@ func TestSanitizeNATSURL(t *testing.T) { } }) + t.Run("redacts credentials in gateway URL format", func(t *testing.T) { + t.Parallel() + + raw := "nats://dev_nats:nats_password_123@dev-nats:4222" + sanitized := buildSafePublishableNATSURL(raw) + + if strings.Contains(sanitized, "nats_password_123") { + t.Fatalf("expected password to be redacted, got %q", sanitized) + } + if !strings.Contains(sanitized, "dev_nats:xxxxx@dev-nats:4222") { + t.Fatalf("expected sanitized URL with redacted password, got %q", sanitized) + } + }) + t.Run("keeps URL without credentials unchanged", func(t *testing.T) { t.Parallel() raw := "nats://localhost:4222" - sanitized := sanitizeNATSURL(raw) + sanitized := buildSafePublishableNATSURL(raw) if sanitized != raw { t.Fatalf("expected URL without credentials to remain unchanged, got %q", sanitized) } @@ -36,7 +50,7 @@ func TestSanitizeNATSURL(t *testing.T) { t.Parallel() raw := " nats://alice:one@localhost:4222, nats://bob:two@localhost:4223 " - sanitized := sanitizeNATSURL(raw) + sanitized := buildSafePublishableNATSURL(raw) if strings.Contains(sanitized, "one") || strings.Contains(sanitized, "two") { t.Fatalf("expected passwords to be redacted, got %q", sanitized) @@ -50,9 +64,37 @@ func TestSanitizeNATSURL(t *testing.T) { t.Parallel() raw := "not a url" - sanitized := sanitizeNATSURL(raw) + sanitized := buildSafePublishableNATSURL(raw) if sanitized != raw { t.Fatalf("expected invalid URL to remain unchanged, got %q", sanitized) } }) + + t.Run("redacts malformed URL credentials via fallback", func(t *testing.T) { + t.Parallel() + + raw := "nats://alice:pa%ss@localhost:4222" + sanitized := buildSafePublishableNATSURL(raw) + + if strings.Contains(sanitized, "pa%ss") { + t.Fatalf("expected malformed password to be redacted, got %q", sanitized) + } + if !strings.Contains(sanitized, "alice:xxxxx@localhost:4222") { + t.Fatalf("expected fallback redaction to preserve host and username, got %q", sanitized) + } + }) + + t.Run("redacts URL without scheme when user info is present", func(t *testing.T) { + t.Parallel() + + raw := "alice:topsecret@localhost:4222" + sanitized := buildSafePublishableNATSURL(raw) + + if strings.Contains(sanitized, "topsecret") { + t.Fatalf("expected password to be redacted, got %q", sanitized) + } + if !strings.Contains(sanitized, "alice:xxxxx@localhost:4222") { + t.Fatalf("expected sanitized authority with redacted password, got %q", sanitized) + } + }) } diff --git a/api/proto/common/gateway/v1/gateway.proto b/api/proto/common/gateway/v1/gateway.proto index 5bec241d..20f1a0af 100644 --- a/api/proto/common/gateway/v1/gateway.proto +++ b/api/proto/common/gateway/v1/gateway.proto @@ -26,10 +26,11 @@ enum Operation { enum Rail { RAIL_UNSPECIFIED = 0; RAIL_CRYPTO = 1; - RAIL_PROVIDER_SETTLEMENT = 2; + RAIL_SETTLEMENT = 2; RAIL_LEDGER = 3; - RAIL_CARD_PAYOUT = 4; - RAIL_FIAT_ONRAMP = 5; + RAIL_CARD = 4; + RAIL_ONRAMP = 5; + RAIL_OFFRAMP = 6; } // Operations supported in a payment plan. diff --git a/api/server/go.sum b/api/server/go.sum index af103f5f..34698344 100644 --- a/api/server/go.sum +++ b/api/server/go.sum @@ -361,8 +361,8 @@ golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8T golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d h1:EocjzKLywydp5uZ5tJ79iP6Q0UjDnyiHkGRWxuPBP8s= -google.golang.org/genproto/googleapis/api v0.0.0-20260217215200-42d3e9bedb6d/go.mod h1:48U2I+QQUYhsFrg2SY6r+nJzeOtjey7j//WBESw+qyQ= +google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc h1:ULD+ToGXUIU6Pkzr1ARxdyvwfHbelw+agoFDRbLg4TU= +google.golang.org/genproto/googleapis/api v0.0.0-20260223185530-2f722ef697dc/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc h1:51Wupg8spF+5FC6D+iMKbOddFjMckETnNnEiZ+HX37s= google.golang.org/genproto/googleapis/rpc v0.0.0-20260223185530-2f722ef697dc/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= google.golang.org/grpc v1.79.1 h1:zGhSi45ODB9/p3VAawt9a+O/MULLl9dpizzNNpq7flY= diff --git a/ci/dev/chain-gateway.dockerfile b/ci/dev/chain-gateway.dockerfile index 1217dd31..8d0e7eca 100644 --- a/ci/dev/chain-gateway.dockerfile +++ b/ci/dev/chain-gateway.dockerfile @@ -11,6 +11,7 @@ WORKDIR /src COPY api/proto ./api/proto COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh @@ -25,6 +26,7 @@ WORKDIR /src # Copy generated proto and pkg from builder COPY --from=builder /src/api/proto ./api/proto COPY --from=builder /src/api/pkg ./api/pkg +COPY --from=builder /src/api/gateway/common ./api/gateway/common # Copy dev-specific entrypoint script COPY ci/dev/entrypoints/chain-gateway.sh /app/entrypoint.sh diff --git a/ci/dev/mntx-gateway.dockerfile b/ci/dev/mntx-gateway.dockerfile index 3d497d5a..b2f89fc2 100644 --- a/ci/dev/mntx-gateway.dockerfile +++ b/ci/dev/mntx-gateway.dockerfile @@ -11,6 +11,7 @@ WORKDIR /src COPY api/proto ./api/proto COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh @@ -25,6 +26,7 @@ WORKDIR /src # Copy generated proto and pkg from builder COPY --from=builder /src/api/proto ./api/proto COPY --from=builder /src/api/pkg ./api/pkg +COPY --from=builder /src/api/gateway/common ./api/gateway/common # Source code will be mounted at runtime WORKDIR /src/api/gateway/mntx diff --git a/ci/dev/tgsettle-gateway.dockerfile b/ci/dev/tgsettle-gateway.dockerfile index b8ff3e32..f791d21a 100644 --- a/ci/dev/tgsettle-gateway.dockerfile +++ b/ci/dev/tgsettle-gateway.dockerfile @@ -11,6 +11,7 @@ WORKDIR /src COPY api/proto ./api/proto COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh @@ -25,6 +26,7 @@ WORKDIR /src # Copy generated proto and pkg from builder COPY --from=builder /src/api/proto ./api/proto COPY --from=builder /src/api/pkg ./api/pkg +COPY --from=builder /src/api/gateway/common ./api/gateway/common # Source code will be mounted at runtime WORKDIR /src/api/gateway/tgsettle diff --git a/ci/dev/tron-gateway.dockerfile b/ci/dev/tron-gateway.dockerfile index 8a203edc..2ecebf29 100644 --- a/ci/dev/tron-gateway.dockerfile +++ b/ci/dev/tron-gateway.dockerfile @@ -11,6 +11,7 @@ WORKDIR /src COPY api/proto ./api/proto COPY api/pkg ./api/pkg +COPY api/gateway/common ./api/gateway/common COPY ci/scripts/proto/generate.sh ./ci/scripts/proto/ RUN bash ci/scripts/proto/generate.sh @@ -25,6 +26,7 @@ WORKDIR /src # Copy generated proto and pkg from builder COPY --from=builder /src/api/proto ./api/proto COPY --from=builder /src/api/pkg ./api/pkg +COPY --from=builder /src/api/gateway/common ./api/gateway/common # Copy dev-specific entrypoint script COPY ci/dev/entrypoints/tron-gateway.sh /app/entrypoint.sh diff --git a/ci/scripts/chain_gateway/build-image.sh b/ci/scripts/chain_gateway/build-image.sh index ed90ca64..d2419989 100755 --- a/ci/scripts/chain_gateway/build-image.sh +++ b/ci/scripts/chain_gateway/build-image.sh @@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then BUILD_CONTEXT="/workspace" fi +# Gateway modules use a local replace (../common); ensure build context contains shared code. +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then + if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then + echo "[chain-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2 + BUILD_CONTEXT="${REPO_ROOT}" + fi +fi + +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then + echo "[chain-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2 + exit 67 +fi +if [ ! -f "${BUILD_CONTEXT}/${CHAIN_GATEWAY_DOCKERFILE}" ]; then + echo "[chain-gateway-build] dockerfile not found in build context: ${CHAIN_GATEWAY_DOCKERFILE}" >&2 + exit 68 +fi + /kaniko/executor \ --context "${BUILD_CONTEXT}" \ --dockerfile "${CHAIN_GATEWAY_DOCKERFILE}" \ diff --git a/ci/scripts/mntx/build-image.sh b/ci/scripts/mntx/build-image.sh index cacf96b3..81dd471a 100644 --- a/ci/scripts/mntx/build-image.sh +++ b/ci/scripts/mntx/build-image.sh @@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then BUILD_CONTEXT="/workspace" fi +# Gateway modules use a local replace (../common); ensure build context contains shared code. +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then + if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then + echo "[mntx-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2 + BUILD_CONTEXT="${REPO_ROOT}" + fi +fi + +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then + echo "[mntx-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2 + exit 67 +fi +if [ ! -f "${BUILD_CONTEXT}/${MNTX_GATEWAY_DOCKERFILE}" ]; then + echo "[mntx-gateway-build] dockerfile not found in build context: ${MNTX_GATEWAY_DOCKERFILE}" >&2 + exit 68 +fi + /kaniko/executor \ --context "${BUILD_CONTEXT}" \ --dockerfile "${MNTX_GATEWAY_DOCKERFILE}" \ diff --git a/ci/scripts/tgsettle/build-image.sh b/ci/scripts/tgsettle/build-image.sh index cef8f1a6..a592afa1 100755 --- a/ci/scripts/tgsettle/build-image.sh +++ b/ci/scripts/tgsettle/build-image.sh @@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then BUILD_CONTEXT="/workspace" fi +# Gateway modules use a local replace (../common); ensure build context contains shared code. +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then + if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then + echo "[tgsettle-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2 + BUILD_CONTEXT="${REPO_ROOT}" + fi +fi + +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then + echo "[tgsettle-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2 + exit 67 +fi +if [ ! -f "${BUILD_CONTEXT}/${TGSETTLE_GATEWAY_DOCKERFILE}" ]; then + echo "[tgsettle-gateway-build] dockerfile not found in build context: ${TGSETTLE_GATEWAY_DOCKERFILE}" >&2 + exit 68 +fi + /kaniko/executor \ --context "${BUILD_CONTEXT}" \ --dockerfile "${TGSETTLE_GATEWAY_DOCKERFILE}" \ diff --git a/ci/scripts/tron_gateway/build-image.sh b/ci/scripts/tron_gateway/build-image.sh index 490ce280..e1744f61 100755 --- a/ci/scripts/tron_gateway/build-image.sh +++ b/ci/scripts/tron_gateway/build-image.sh @@ -73,6 +73,23 @@ if [ ! -d "${BUILD_CONTEXT}" ]; then BUILD_CONTEXT="/workspace" fi +# Gateway modules use a local replace (../common); ensure build context contains shared code. +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ] || [ ! -f "${BUILD_CONTEXT}/${TRON_GATEWAY_DOCKERFILE}" ]; then + if [ -d "${REPO_ROOT}/api/gateway/common" ] && [ -f "${REPO_ROOT}/${TRON_GATEWAY_DOCKERFILE}" ]; then + echo "[tron-gateway-build] build context ${BUILD_CONTEXT} is incomplete; falling back to ${REPO_ROOT}" >&2 + BUILD_CONTEXT="${REPO_ROOT}" + fi +fi + +if [ ! -d "${BUILD_CONTEXT}/api/gateway/common" ]; then + echo "[tron-gateway-build] build context ${BUILD_CONTEXT} missing api/gateway/common" >&2 + exit 67 +fi +if [ ! -f "${BUILD_CONTEXT}/${TRON_GATEWAY_DOCKERFILE}" ]; then + echo "[tron-gateway-build] dockerfile not found in build context: ${TRON_GATEWAY_DOCKERFILE}" >&2 + exit 68 +fi + /kaniko/executor \ --context "${BUILD_CONTEXT}" \ --dockerfile "${TRON_GATEWAY_DOCKERFILE}" \ diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 95943698..306753f3 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -616,6 +616,7 @@ services: dev-chain-gateway-vault-agent: { condition: service_healthy } volumes: - ./api/gateway/chain:/src/api/gateway/chain + - ./api/gateway/common:/src/api/gateway/common - ./api/gateway/chain/config.dev.yml:/app/config.yml:ro - dev-chain-gateway-vault-run:/run/vault:ro ports: @@ -693,6 +694,7 @@ services: dev-tron-gateway-vault-agent: { condition: service_healthy } volumes: - ./api/gateway/tron:/src/api/gateway/tron + - ./api/gateway/common:/src/api/gateway/common - ./api/gateway/tron/config.dev.yml:/app/config.yml:ro - dev-tron-gateway-vault-run:/run/vault:ro ports: @@ -738,6 +740,7 @@ services: dev-vault: { condition: service_healthy } volumes: - ./api/gateway/mntx:/src/api/gateway/mntx + - ./api/gateway/common:/src/api/gateway/common - ./api/gateway/mntx/config.dev.yml:/app/config.yml:ro ports: - "50075:50075" @@ -781,6 +784,7 @@ services: dev-vault: { condition: service_healthy } volumes: - ./api/gateway/tgsettle:/src/api/gateway/tgsettle + - ./api/gateway/common:/src/api/gateway/common - ./api/gateway/tgsettle/config.dev.yml:/app/config.yml:ro ports: - "50080:50080"