diff --git a/api/billing/fees/internal/server/internal/serverimp.go b/api/billing/fees/internal/server/internal/serverimp.go index 01f85cb..8e0d6c6 100644 --- a/api/billing/fees/internal/server/internal/serverimp.go +++ b/api/billing/fees/internal/server/internal/serverimp.go @@ -26,6 +26,7 @@ type Imp struct { config *config app *grpcapp.App[storage.Repository] oracleClient oracleclient.Client + service *fees.Service } type config struct { @@ -65,6 +66,9 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { func (i *Imp) Shutdown() { if i.app == nil { + if i.service != nil { + i.service.Shutdown() + } if i.oracleClient != nil { _ = i.oracleClient.Close() } @@ -76,6 +80,10 @@ func (i *Imp) Shutdown() { timeout = i.config.Runtime.ShutdownTimeout() } + if i.service != nil { + i.service.Shutdown() + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) i.app.Shutdown(ctx) cancel() @@ -121,7 +129,9 @@ func (i *Imp) Start() error { if oracleClient != nil { opts = append(opts, fees.WithOracleClient(oracleClient)) } - return fees.NewService(logger, repo, producer, opts...), nil + svc := fees.NewService(logger, repo, producer, opts...) + i.service = svc + return svc, nil } app, err := grpcapp.NewApp(i.logger, "billing_fees", cfg.Config, i.debug, repoFactory, serviceFactory) diff --git a/api/billing/fees/internal/service/fees/service.go b/api/billing/fees/internal/service/fees/service.go index bcf74df..5e65892 100644 --- a/api/billing/fees/internal/service/fees/service.go +++ b/api/billing/fees/internal/service/fees/service.go @@ -8,6 +8,7 @@ import ( "strings" "time" + "github.com/tech/sendico/billing/fees/internal/appversion" internalcalculator "github.com/tech/sendico/billing/fees/internal/service/fees/internal/calculator" "github.com/tech/sendico/billing/fees/internal/service/fees/internal/resolver" "github.com/tech/sendico/billing/fees/storage" @@ -15,9 +16,11 @@ import ( oracleclient "github.com/tech/sendico/fx/oracle/client" "github.com/tech/sendico/pkg/api/routers" clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" "go.mongodb.org/mongo-driver/bson/primitive" @@ -36,6 +39,7 @@ type Service struct { calculator Calculator oracle oracleclient.Client resolver FeeResolver + announcer *discovery.Announcer feesv1.UnimplementedFeeEngineServer } @@ -62,6 +66,8 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro svc.resolver = resolver.New(repo.Plans(), svc.logger) } + svc.startDiscoveryAnnouncer() + return svc } @@ -71,6 +77,28 @@ func (s *Service) Register(router routers.GRPC) error { }) } +func (s *Service) Shutdown() { + if s == nil { + return + } + if s.announcer != nil { + s.announcer.Stop() + } +} + +func (s *Service) startDiscoveryAnnouncer() { + if s == nil || s.producer == nil { + return + } + announce := discovery.Announcement{ + Service: "BILLING_FEES", + Operations: []string{"fee.calc"}, + Version: appversion.Create().Short(), + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FeePlans), announce) + s.announcer.Start() +} + func (s *Service) QuoteFees(ctx context.Context, req *feesv1.QuoteFeesRequest) (resp *feesv1.QuoteFeesResponse, err error) { var ( meta *feesv1.RequestMeta diff --git a/api/fx/ingestor/internal/app/app.go b/api/fx/ingestor/internal/app/app.go index 38f2055..9836135 100644 --- a/api/fx/ingestor/internal/app/app.go +++ b/api/fx/ingestor/internal/app/app.go @@ -12,7 +12,10 @@ import ( mongostorage "github.com/tech/sendico/fx/storage/mongo" "github.com/tech/sendico/pkg/api/routers/health" "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" + msgproducer "github.com/tech/sendico/pkg/messaging/producer" "github.com/tech/sendico/pkg/mlogger" "go.uber.org/zap" ) @@ -68,6 +71,24 @@ func (a *App) Run(ctx context.Context) error { return err } + var announcer *discovery.Announcer + if cfg := a.cfg.Messaging; cfg != nil && cfg.Driver != "" { + broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), cfg) + if err != nil { + a.logger.Warn("Failed to initialize discovery broker", zap.Error(err)) + } else { + producer := msgproducer.NewProducer(a.logger.Named("discovery_producer"), broker) + announce := discovery.Announcement{ + Service: "FX_INGESTOR", + Operations: []string{"fx.ingest"}, + Version: appversion.Create().Short(), + } + announcer = discovery.NewAnnouncer(a.logger, producer, "fx_ingestor", announce) + announcer.Start() + defer announcer.Stop() + } + } + a.logger.Info("Starting FX ingestor service", zap.String("version", appversion.Create().Info())) metricsSrv.SetStatus(health.SSRunning) diff --git a/api/fx/ingestor/internal/config/config.go b/api/fx/ingestor/internal/config/config.go index 53394bf..1b0fdc7 100644 --- a/api/fx/ingestor/internal/config/config.go +++ b/api/fx/ingestor/internal/config/config.go @@ -8,16 +8,18 @@ import ( mmodel "github.com/tech/sendico/fx/ingestor/internal/model" "github.com/tech/sendico/pkg/db" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/messaging" "gopkg.in/yaml.v3" ) const defaultPollInterval = 30 * time.Second type Config struct { - PollIntervalSeconds int `yaml:"poll_interval_seconds"` - Market MarketConfig `yaml:"market"` - Database *db.Config `yaml:"database"` - Metrics *MetricsConfig `yaml:"metrics"` + PollIntervalSeconds int `yaml:"poll_interval_seconds"` + Market MarketConfig `yaml:"market"` + Database *db.Config `yaml:"database"` + Metrics *MetricsConfig `yaml:"metrics"` + Messaging *messaging.Config `yaml:"messaging"` pairs []Pair pairsBySource map[mmodel.Driver][]PairConfig diff --git a/api/fx/oracle/internal/server/internal/serverimp.go b/api/fx/oracle/internal/server/internal/serverimp.go index 5946005..8d4dc83 100644 --- a/api/fx/oracle/internal/server/internal/serverimp.go +++ b/api/fx/oracle/internal/server/internal/serverimp.go @@ -22,8 +22,9 @@ type Imp struct { file string debug bool - config *grpcapp.Config - app *grpcapp.App[storage.Repository] + config *grpcapp.Config + app *grpcapp.App[storage.Repository] + service *oracle.Service } func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { @@ -38,6 +39,9 @@ func (i *Imp) Shutdown() { if i.app == nil { return } + if i.service != nil { + i.service.Shutdown() + } timeout := 15 * time.Second if i.config != nil && i.config.Runtime != nil { timeout = i.config.Runtime.ShutdownTimeout() @@ -59,10 +63,12 @@ func (i *Imp) Start() error { } serviceFactory := func(logger mlogger.Logger, repo storage.Repository, producer msg.Producer) (grpcapp.Service, error) { - return oracle.NewService(logger, repo, producer), nil + svc := oracle.NewService(logger, repo, producer) + i.service = svc + return svc, nil } - app, err := grpcapp.NewApp(i.logger, "fx_oracle", cfg, i.debug, repoFactory, serviceFactory) + app, err := grpcapp.NewApp(i.logger, "fx", cfg, i.debug, repoFactory, serviceFactory) if err != nil { return err } diff --git a/api/fx/oracle/internal/service/oracle/service.go b/api/fx/oracle/internal/service/oracle/service.go index c6e0f22..512833a 100644 --- a/api/fx/oracle/internal/service/oracle/service.go +++ b/api/fx/oracle/internal/service/oracle/service.go @@ -6,10 +6,12 @@ import ( "strings" "time" + "github.com/tech/sendico/fx/oracle/internal/appversion" "github.com/tech/sendico/fx/storage" "github.com/tech/sendico/fx/storage/model" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers/gsresponse" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" pmessaging "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" @@ -36,19 +38,22 @@ var ( ) type Service struct { - logger mlogger.Logger - storage storage.Repository - producer pmessaging.Producer + logger mlogger.Logger + storage storage.Repository + producer pmessaging.Producer + announcer *discovery.Announcer oraclev1.UnimplementedOracleServer } func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging.Producer) *Service { initMetrics() - return &Service{ + svc := &Service{ logger: logger.Named("oracle"), storage: repo, producer: prod, } + svc.startDiscoveryAnnouncer() + return svc } func (s *Service) Register(router routers.GRPC) error { @@ -57,6 +62,28 @@ func (s *Service) Register(router routers.GRPC) error { }) } +func (s *Service) Shutdown() { + if s == nil { + return + } + if s.announcer != nil { + s.announcer.Stop() + } +} + +func (s *Service) startDiscoveryAnnouncer() { + if s == nil || s.producer == nil { + return + } + announce := discovery.Announcement{ + Service: "FX_ORACLE", + Operations: []string{"fx.quote"}, + Version: appversion.Create().Short(), + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.FXOracle), announce) + s.announcer.Start() +} + func (s *Service) GetQuote(ctx context.Context, req *oraclev1.GetQuoteRequest) (*oraclev1.GetQuoteResponse, error) { start := time.Now() responder := s.getQuoteResponder(ctx, req) diff --git a/api/gateway/chain/client/rail_gateway.go b/api/gateway/chain/client/rail_gateway.go new file mode 100644 index 0000000..2f427f1 --- /dev/null +++ b/api/gateway/chain/client/rail_gateway.go @@ -0,0 +1,258 @@ +package client + +import ( + "context" + "strings" + + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/payments/rail" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +// RailGatewayConfig defines metadata for the rail gateway adapter. +type RailGatewayConfig struct { + Rail string + Network string + Capabilities rail.RailCapabilities +} + +type chainRailGateway struct { + client Client + rail string + network string + capabilities rail.RailCapabilities +} + +// NewRailGateway wraps a chain gateway client into a rail gateway adapter. +func NewRailGateway(client Client, cfg RailGatewayConfig) rail.RailGateway { + railName := strings.ToUpper(strings.TrimSpace(cfg.Rail)) + if railName == "" { + railName = "CRYPTO" + } + return &chainRailGateway{ + client: client, + rail: railName, + network: strings.ToUpper(strings.TrimSpace(cfg.Network)), + capabilities: cfg.Capabilities, + } +} + +func (g *chainRailGateway) Rail() string { + return g.rail +} + +func (g *chainRailGateway) Network() string { + return g.network +} + +func (g *chainRailGateway) Capabilities() rail.RailCapabilities { + return g.capabilities +} + +func (g *chainRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { + if g.client == nil { + return rail.RailResult{}, merrors.Internal("chain gateway: client is required") + } + orgRef := strings.TrimSpace(req.OrganizationRef) + if orgRef == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: organization_ref is required") + } + source := strings.TrimSpace(req.FromAccountID) + if source == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: from_account_id is required") + } + destRef := strings.TrimSpace(req.ToAccountID) + if destRef == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: to_account_id is required") + } + currency := strings.TrimSpace(req.Currency) + amountValue := strings.TrimSpace(req.Amount) + if currency == "" || amountValue == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: amount is required") + } + reqNetwork := strings.TrimSpace(req.Network) + if g.network != "" && reqNetwork != "" && !strings.EqualFold(g.network, reqNetwork) { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: network mismatch") + } + if strings.TrimSpace(req.IdempotencyKey) == "" { + return rail.RailResult{}, merrors.InvalidArgument("chain gateway: idempotency_key is required") + } + + dest, err := g.resolveDestination(ctx, destRef, strings.TrimSpace(req.DestinationMemo)) + if err != nil { + return rail.RailResult{}, err + } + + fees := toServiceFees(req.Fees) + if len(fees) == 0 && req.Fee != nil { + if amt := moneyFromRail(req.Fee); amt != nil { + fees = []*chainv1.ServiceFeeBreakdown{{ + FeeCode: "fee", + Amount: amt, + }} + } + } + + resp, err := g.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: strings.TrimSpace(req.IdempotencyKey), + OrganizationRef: orgRef, + SourceWalletRef: source, + Destination: dest, + Amount: &moneyv1.Money{ + Currency: currency, + Amount: amountValue, + }, + Fees: fees, + Metadata: cloneMetadata(req.Metadata), + ClientReference: strings.TrimSpace(req.ClientReference), + }) + if err != nil { + return rail.RailResult{}, err + } + if resp == nil || resp.GetTransfer() == nil { + return rail.RailResult{}, merrors.Internal("chain gateway: missing transfer response") + } + + transfer := resp.GetTransfer() + return rail.RailResult{ + ReferenceID: strings.TrimSpace(transfer.GetTransferRef()), + Status: statusFromTransfer(transfer.GetStatus()), + FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), + }, nil +} + +func (g *chainRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { + if g.client == nil { + return rail.ObserveResult{}, merrors.Internal("chain gateway: client is required") + } + ref := strings.TrimSpace(referenceID) + if ref == "" { + return rail.ObserveResult{}, merrors.InvalidArgument("chain gateway: reference_id is required") + } + resp, err := g.client.GetTransfer(ctx, &chainv1.GetTransferRequest{TransferRef: ref}) + if err != nil { + return rail.ObserveResult{}, err + } + if resp == nil || resp.GetTransfer() == nil { + return rail.ObserveResult{}, merrors.Internal("chain gateway: missing transfer response") + } + transfer := resp.GetTransfer() + return rail.ObserveResult{ + ReferenceID: ref, + Status: statusFromTransfer(transfer.GetStatus()), + FinalAmount: railMoneyFromProto(transfer.GetNetAmount()), + }, nil +} + +func (g *chainRailGateway) resolveDestination(ctx context.Context, destRef, memo string) (*chainv1.TransferDestination, error) { + managed, err := g.isManagedWallet(ctx, destRef) + if err != nil { + return nil, err + } + if managed { + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: destRef}, + }, nil + } + return &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: destRef}, + Memo: memo, + }, nil +} + +func (g *chainRailGateway) isManagedWallet(ctx context.Context, walletRef string) (bool, error) { + resp, err := g.client.GetManagedWallet(ctx, &chainv1.GetManagedWalletRequest{WalletRef: walletRef}) + if err != nil { + if status.Code(err) == codes.NotFound { + return false, nil + } + return false, err + } + if resp == nil || resp.GetWallet() == nil { + return false, nil + } + return true, nil +} + +func statusFromTransfer(status chainv1.TransferStatus) string { + switch status { + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + return rail.TransferStatusSuccess + case chainv1.TransferStatus_TRANSFER_FAILED: + return rail.TransferStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return rail.TransferStatusRejected + case chainv1.TransferStatus_TRANSFER_SIGNING, + chainv1.TransferStatus_TRANSFER_PENDING, + chainv1.TransferStatus_TRANSFER_SUBMITTED: + return rail.TransferStatusPending + default: + return rail.TransferStatusPending + } +} + +func toServiceFees(fees []rail.FeeBreakdown) []*chainv1.ServiceFeeBreakdown { + if len(fees) == 0 { + return nil + } + result := make([]*chainv1.ServiceFeeBreakdown, 0, len(fees)) + for _, fee := range fees { + amount := moneyFromRail(fee.Amount) + if amount == nil { + continue + } + result = append(result, &chainv1.ServiceFeeBreakdown{ + FeeCode: strings.TrimSpace(fee.FeeCode), + Amount: amount, + Description: strings.TrimSpace(fee.Description), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func moneyFromRail(m *rail.Money) *moneyv1.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.GetCurrency()) + amount := strings.TrimSpace(m.GetAmount()) + if currency == "" || amount == "" { + return nil + } + return &moneyv1.Money{ + Currency: currency, + Amount: amount, + } +} + +func railMoneyFromProto(m *moneyv1.Money) *rail.Money { + if m == nil { + return nil + } + currency := strings.TrimSpace(m.GetCurrency()) + amount := strings.TrimSpace(m.GetAmount()) + if currency == "" || amount == "" { + return nil + } + return &rail.Money{ + Currency: currency, + Amount: amount, + } +} + +func cloneMetadata(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + result := make(map[string]string, len(input)) + for key, value := range input { + result[key] = value + } + return result +} diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index c538c04..63a2941 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -22,7 +22,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 1408b9b..c73ea16 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f h1:a5PUgHGinaD6XrLmIDLQmGHocjIjBsBAcR5gALjZvMU= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251230134950-44c893854e3f/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/api/gateway/chain/internal/server/internal/serverimp.go b/api/gateway/chain/internal/server/internal/serverimp.go index 04be1c6..f5dac2e 100644 --- a/api/gateway/chain/internal/server/internal/serverimp.go +++ b/api/gateway/chain/internal/server/internal/serverimp.go @@ -36,6 +36,7 @@ type Imp struct { app *grpcapp.App[storage.Repository] rpcClients *rpcclient.Clients + service *gatewayservice.Service } type config struct { @@ -100,6 +101,10 @@ func (i *Imp) Shutdown() { timeout = i.config.Runtime.ShutdownTimeout() } + if i.service != nil { + i.service.Shutdown() + } + ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() @@ -151,7 +156,9 @@ func (i *Imp) Start() error { gatewayservice.WithDriverRegistry(driverRegistry), gatewayservice.WithSettings(cfg.Settings), } - return gatewayservice.NewService(logger, repo, producer, opts...), nil + svc := gatewayservice.NewService(logger, repo, producer, opts...) + i.service = svc + return svc, nil } app, err := grpcapp.NewApp(i.logger, "chain", cfg.Config, i.debug, repoFactory, serviceFactory) diff --git a/api/gateway/chain/internal/service/gateway/service.go b/api/gateway/chain/internal/service/gateway/service.go index 6d50a6f..1768ad0 100644 --- a/api/gateway/chain/internal/service/gateway/service.go +++ b/api/gateway/chain/internal/service/gateway/service.go @@ -3,6 +3,7 @@ package gateway import ( "context" + "github.com/tech/sendico/gateway/chain/internal/appversion" "github.com/tech/sendico/gateway/chain/internal/keymanager" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands" "github.com/tech/sendico/gateway/chain/internal/service/gateway/commands/transfer" @@ -14,6 +15,7 @@ import ( "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers/gsresponse" clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/discovery" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" @@ -47,6 +49,7 @@ type Service struct { networkRegistry *rpcclient.Registry drivers *drivers.Registry commands commands.Registry + announcers []*discovery.Announcer chainv1.UnimplementedChainGatewayServiceServer } @@ -83,6 +86,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, producer msg.Pro Wallet: commandsWalletDeps(svc), Transfer: commandsTransferDeps(svc), }) + svc.startDiscoveryAnnouncers() return svc } @@ -94,6 +98,17 @@ func (s *Service) Register(router routers.GRPC) error { }) } +func (s *Service) Shutdown() { + if s == nil { + return + } + for _, announcer := range s.announcers { + if announcer != nil { + announcer.Stop() + } + } +} + func (s *Service) CreateManagedWallet(ctx context.Context, req *chainv1.CreateManagedWalletRequest) (*chainv1.CreateManagedWalletResponse, error) { return executeUnary(ctx, s, "CreateManagedWallet", s.commands.CreateManagedWallet.Execute, req) } @@ -174,3 +189,29 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method observeRPC(method, err, svc.clock.Now().Sub(start)) return resp, err } + +func (s *Service) startDiscoveryAnnouncers() { + if s == nil || s.producer == nil || len(s.networks) == 0 { + return + } + version := appversion.Create().Short() + for _, network := range s.networks { + currencies := []string{shared.NativeCurrency(network)} + for _, token := range network.TokenConfigs { + if token.Symbol != "" { + currencies = append(currencies, token.Symbol) + } + } + announce := discovery.Announcement{ + Service: "CRYPTO_RAIL_GATEWAY", + Rail: "CRYPTO", + Network: network.Name, + Operations: []string{"balance.read", "payin.crypto", "payout.crypto", "fee.send"}, + Currencies: currencies, + Version: version, + } + announcer := discovery.NewAnnouncer(s.logger, s.producer, string(mservice.ChainGateway), announce) + announcer.Start() + s.announcers = append(s.announcers, announcer) + } +} diff --git a/api/gateway/mntx/README.md b/api/gateway/mntx/README.md index 52e5dbb..eff7d1d 100644 --- a/api/gateway/mntx/README.md +++ b/api/gateway/mntx/README.md @@ -3,7 +3,7 @@ This service now supports Monetix “payout by card”. ## Runtime entry points -- gRPC: `MntxGatewayService.CreateCardPayout` and `GetCardPayoutStatus`. +- gRPC: `MntxGatewayService.CreateCardPayout`, `CreateCardTokenPayout`, `GetCardPayoutStatus`, `ListGatewayInstances`. - Callback HTTP server (default): `:8084/monetix/callback` for Monetix payout status notifications. - Metrics: Prometheus on `:9404/metrics`. @@ -13,6 +13,7 @@ This service now supports Monetix “payout by card”. - `MONETIX_PROJECT_ID` – integer project ID - `MONETIX_SECRET_KEY` – signature secret - Optional: `allowed_currencies`, `require_customer_address`, `request_timeout_seconds` +- Gateway descriptor: `gateway.id`, optional `gateway.currencies`, `gateway.limits` - Callback server: `MNTX_GATEWAY_HTTP_PORT` (exposed as 8084), `http.callback.path`, optional `allowed_cidrs` ## Outbound request (CreateCardPayout) @@ -39,7 +40,8 @@ Signature: HMAC-SHA256 over the JSON body (without `signature`), using `MONETIX_ - `sendico_mntx_gateway_card_payout_requests_total{outcome}` - `sendico_mntx_gateway_card_payout_request_latency_seconds{outcome}` - `sendico_mntx_gateway_card_payout_callbacks_total{status}` -- Existing RPC/payout counters remain for compatibility. +- `sendico_mntx_gateway_rpc_requests_total{method,status}` +- `sendico_mntx_gateway_rpc_latency_seconds{method}` ## Notes / PCI - PAN is only logged in masked form; do not persist raw PAN. diff --git a/api/gateway/mntx/client/client.go b/api/gateway/mntx/client/client.go index ce29920..ef57b18 100644 --- a/api/gateway/mntx/client/client.go +++ b/api/gateway/mntx/client/client.go @@ -17,6 +17,7 @@ type Client interface { CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) CreateCardTokenPayout(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) + ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) Close() error } @@ -96,3 +97,9 @@ func (g *gatewayClient) GetCardPayoutStatus(ctx context.Context, req *mntxv1.Get defer cancel() return g.client.GetCardPayoutStatus(ctx, req) } + +func (g *gatewayClient) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) { + ctx, cancel := g.callContext(ctx, "ListGatewayInstances") + defer cancel() + return g.client.ListGatewayInstances(ctx, req) +} diff --git a/api/gateway/mntx/client/fake.go b/api/gateway/mntx/client/fake.go index f4d9023..2673534 100644 --- a/api/gateway/mntx/client/fake.go +++ b/api/gateway/mntx/client/fake.go @@ -11,6 +11,7 @@ type Fake struct { CreateCardPayoutFn func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) CreateCardTokenPayoutFn func(ctx context.Context, req *mntxv1.CardTokenPayoutRequest) (*mntxv1.CardTokenPayoutResponse, error) GetCardPayoutStatusFn func(ctx context.Context, req *mntxv1.GetCardPayoutStatusRequest) (*mntxv1.GetCardPayoutStatusResponse, error) + ListGatewayInstancesFn func(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) } func (f *Fake) CreateCardPayout(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { @@ -34,4 +35,11 @@ func (f *Fake) GetCardPayoutStatus(ctx context.Context, req *mntxv1.GetCardPayou return &mntxv1.GetCardPayoutStatusResponse{}, nil } +func (f *Fake) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) { + if f.ListGatewayInstancesFn != nil { + return f.ListGatewayInstancesFn(ctx, req) + } + return &mntxv1.ListGatewayInstancesResponse{}, nil +} + func (f *Fake) Close() error { return nil } diff --git a/api/gateway/mntx/config.yml b/api/gateway/mntx/config.yml index 532eeb3..ba2d1a4 100644 --- a/api/gateway/mntx/config.yml +++ b/api/gateway/mntx/config.yml @@ -32,6 +32,14 @@ monetix: status_success: "success" status_processing: "processing" +gateway: + id: "monetix" + is_enabled: true + # network: "VISA_DIRECT" + # currencies: ["RUB"] + # limits: + # min_amount: "0" + http: callback: address: ":8084" diff --git a/api/gateway/mntx/go.mod b/api/gateway/mntx/go.mod index b50f90f..3abc45c 100644 --- a/api/gateway/mntx/go.mod +++ b/api/gateway/mntx/go.mod @@ -6,7 +6,6 @@ replace github.com/tech/sendico/pkg => ../../pkg require ( github.com/go-chi/chi/v5 v5.2.3 - github.com/google/uuid v1.6.0 github.com/prometheus/client_golang v1.23.2 github.com/shopspring/decimal v1.4.0 github.com/tech/sendico/pkg v0.1.0 @@ -23,6 +22,7 @@ require ( github.com/casbin/govaluate v1.10.0 // indirect github.com/casbin/mongodb-adapter/v3 v3.7.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/google/uuid v1.6.0 // indirect github.com/golang/snappy v1.0.0 // indirect github.com/klauspost/compress v1.18.2 // indirect github.com/mattn/go-colorable v0.1.14 // indirect diff --git a/api/gateway/mntx/go.sum b/api/gateway/mntx/go.sum index 2242948..d72371c 100644 --- a/api/gateway/mntx/go.sum +++ b/api/gateway/mntx/go.sum @@ -40,6 +40,8 @@ github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2 github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U= github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI= github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= @@ -57,8 +59,6 @@ github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs= github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= -github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= -github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk= github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= diff --git a/api/gateway/mntx/internal/server/internal/serverimp.go b/api/gateway/mntx/internal/server/internal/serverimp.go index 57b2c7a..1a015f4 100644 --- a/api/gateway/mntx/internal/server/internal/serverimp.go +++ b/api/gateway/mntx/internal/server/internal/serverimp.go @@ -12,12 +12,14 @@ import ( "time" "github.com/go-chi/chi/v5" + "github.com/tech/sendico/gateway/mntx/internal/appversion" mntxservice "github.com/tech/sendico/gateway/mntx/internal/service/gateway" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/merrors" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" "github.com/tech/sendico/pkg/server/grpcapp" "go.uber.org/zap" "gopkg.in/yaml.v3" @@ -28,14 +30,16 @@ type Imp struct { file string debug bool - config *config - app *grpcapp.App[struct{}] - http *http.Server + config *config + app *grpcapp.App[struct{}] + http *http.Server + service *mntxservice.Service } type config struct { *grpcapp.Config `yaml:",inline"` Monetix monetixConfig `yaml:"monetix"` + Gateway gatewayConfig `yaml:"gateway"` HTTP httpConfig `yaml:"http"` } @@ -53,6 +57,33 @@ type monetixConfig struct { StatusProcessing string `yaml:"status_processing"` } +type gatewayConfig struct { + ID string `yaml:"id"` + Network string `yaml:"network"` + Currencies []string `yaml:"currencies"` + IsEnabled *bool `yaml:"is_enabled"` + Limits limitsConfig `yaml:"limits"` +} + +type limitsConfig struct { + MinAmount string `yaml:"min_amount"` + MaxAmount string `yaml:"max_amount"` + PerTxMaxFee string `yaml:"per_tx_max_fee"` + PerTxMinAmount string `yaml:"per_tx_min_amount"` + PerTxMaxAmount string `yaml:"per_tx_max_amount"` + VolumeLimit map[string]string `yaml:"volume_limit"` + VelocityLimit map[string]int `yaml:"velocity_limit"` + CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"` +} + +type limitsOverrideCfg struct { + MaxVolume string `yaml:"max_volume"` + MinAmount string `yaml:"min_amount"` + MaxAmount string `yaml:"max_amount"` + MaxFee string `yaml:"max_fee"` + MaxOps int `yaml:"max_ops"` +} + type httpConfig struct { Callback callbackConfig `yaml:"callback"` } @@ -86,6 +117,9 @@ func (i *Imp) Shutdown() { ctx, cancel := context.WithTimeout(context.Background(), timeout) defer cancel() + if i.service != nil { + i.service.Shutdown() + } if i.http != nil { _ = i.http.Shutdown(ctx) i.http = nil @@ -131,6 +165,17 @@ func (i *Imp) Start() error { zap.String("status_processing", monetixCfg.ProcessingStatus()), ) + gatewayDescriptor := resolveGatewayDescriptor(cfg.Gateway, monetixCfg) + if gatewayDescriptor != nil { + i.logger.Info("Gateway descriptor resolved", + zap.String("id", gatewayDescriptor.GetId()), + zap.String("rail", gatewayDescriptor.GetRail().String()), + zap.String("network", gatewayDescriptor.GetNetwork()), + zap.Int("currencies", len(gatewayDescriptor.GetCurrencies())), + zap.Bool("enabled", gatewayDescriptor.GetIsEnabled()), + ) + } + i.logger.Info("Callback configuration resolved", zap.String("address", callbackCfg.Address), zap.String("path", callbackCfg.Path), @@ -142,8 +187,10 @@ func (i *Imp) Start() error { svc := mntxservice.NewService(logger, mntxservice.WithProducer(producer), mntxservice.WithMonetixConfig(monetixCfg), + mntxservice.WithGatewayDescriptor(gatewayDescriptor), mntxservice.WithHTTPClient(&http.Client{Timeout: monetixCfg.Timeout()}), ) + i.service = svc if err := i.startHTTPCallbackServer(svc, callbackCfg); err != nil { return nil, err @@ -243,6 +290,129 @@ func (i *Imp) resolveMonetixConfig(cfg monetixConfig) (monetix.Config, error) { }, nil } +func resolveGatewayDescriptor(cfg gatewayConfig, monetixCfg monetix.Config) *gatewayv1.GatewayInstanceDescriptor { + id := strings.TrimSpace(cfg.ID) + if id == "" { + id = "monetix" + } + + network := strings.ToUpper(strings.TrimSpace(cfg.Network)) + currencies := normalizeCurrencies(cfg.Currencies) + if len(currencies) == 0 { + currencies = normalizeCurrencies(monetixCfg.AllowedCurrencies) + } + + enabled := true + if cfg.IsEnabled != nil { + enabled = *cfg.IsEnabled + } + + limits := buildGatewayLimits(cfg.Limits) + if limits == nil { + limits = &gatewayv1.Limits{MinAmount: "0"} + } + + version := strings.TrimSpace(appversion.Version) + + return &gatewayv1.GatewayInstanceDescriptor{ + Id: id, + Rail: gatewayv1.Rail_RAIL_CARD_PAYOUT, + Network: network, + Currencies: currencies, + Capabilities: &gatewayv1.RailCapabilities{ + CanPayOut: true, + CanPayIn: false, + CanReadBalance: false, + CanSendFee: false, + RequiresObserveConfirm: false, + }, + Limits: limits, + Version: version, + IsEnabled: enabled, + } +} + +func normalizeCurrencies(values []string) []string { + if len(values) == 0 { + return nil + } + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" || seen[clean] { + continue + } + seen[clean] = true + result = append(result, clean) + } + return result +} + +func buildGatewayLimits(cfg limitsConfig) *gatewayv1.Limits { + hasValue := strings.TrimSpace(cfg.MinAmount) != "" || + strings.TrimSpace(cfg.MaxAmount) != "" || + strings.TrimSpace(cfg.PerTxMaxFee) != "" || + strings.TrimSpace(cfg.PerTxMinAmount) != "" || + strings.TrimSpace(cfg.PerTxMaxAmount) != "" || + len(cfg.VolumeLimit) > 0 || + len(cfg.VelocityLimit) > 0 || + len(cfg.CurrencyLimits) > 0 + if !hasValue { + return nil + } + + limits := &gatewayv1.Limits{ + MinAmount: strings.TrimSpace(cfg.MinAmount), + MaxAmount: strings.TrimSpace(cfg.MaxAmount), + PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee), + PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount), + PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount), + } + + if len(cfg.VolumeLimit) > 0 { + limits.VolumeLimit = map[string]string{} + for key, value := range cfg.VolumeLimit { + bucket := strings.TrimSpace(key) + amount := strings.TrimSpace(value) + if bucket == "" || amount == "" { + continue + } + limits.VolumeLimit[bucket] = amount + } + } + + if len(cfg.VelocityLimit) > 0 { + limits.VelocityLimit = map[string]int32{} + for key, value := range cfg.VelocityLimit { + bucket := strings.TrimSpace(key) + if bucket == "" { + continue + } + limits.VelocityLimit[bucket] = int32(value) + } + } + + if len(cfg.CurrencyLimits) > 0 { + limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{} + for key, override := range cfg.CurrencyLimits { + currency := strings.ToUpper(strings.TrimSpace(key)) + if currency == "" { + continue + } + limits.CurrencyLimits[currency] = &gatewayv1.LimitsOverride{ + MaxVolume: strings.TrimSpace(override.MaxVolume), + MinAmount: strings.TrimSpace(override.MinAmount), + MaxAmount: strings.TrimSpace(override.MaxAmount), + MaxFee: strings.TrimSpace(override.MaxFee), + MaxOps: int32(override.MaxOps), + } + } + } + + return limits +} + type callbackRuntimeConfig struct { Address string Path string diff --git a/api/gateway/mntx/internal/service/gateway/instances.go b/api/gateway/mntx/internal/service/gateway/instances.go new file mode 100644 index 0000000..0c28e75 --- /dev/null +++ b/api/gateway/mntx/internal/service/gateway/instances.go @@ -0,0 +1,63 @@ +package gateway + +import ( + "context" + + "github.com/tech/sendico/pkg/api/routers/gsresponse" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +// ListGatewayInstances exposes the Monetix gateway instance descriptors. +func (s *Service) ListGatewayInstances(ctx context.Context, req *mntxv1.ListGatewayInstancesRequest) (*mntxv1.ListGatewayInstancesResponse, error) { + return executeUnary(ctx, s, "ListGatewayInstances", s.handleListGatewayInstances, req) +} + +func (s *Service) handleListGatewayInstances(_ context.Context, _ *mntxv1.ListGatewayInstancesRequest) gsresponse.Responder[mntxv1.ListGatewayInstancesResponse] { + items := make([]*gatewayv1.GatewayInstanceDescriptor, 0, 1) + if s.gatewayDescriptor != nil { + items = append(items, cloneGatewayDescriptor(s.gatewayDescriptor)) + } + return gsresponse.Success(&mntxv1.ListGatewayInstancesResponse{Items: items}) +} + +func cloneGatewayDescriptor(src *gatewayv1.GatewayInstanceDescriptor) *gatewayv1.GatewayInstanceDescriptor { + if src == nil { + return nil + } + cp := *src + if src.Currencies != nil { + cp.Currencies = append([]string(nil), src.Currencies...) + } + if src.Capabilities != nil { + cap := *src.Capabilities + cp.Capabilities = &cap + } + if src.Limits != nil { + limits := *src.Limits + if src.Limits.VolumeLimit != nil { + limits.VolumeLimit = map[string]string{} + for key, value := range src.Limits.VolumeLimit { + limits.VolumeLimit[key] = value + } + } + if src.Limits.VelocityLimit != nil { + limits.VelocityLimit = map[string]int32{} + for key, value := range src.Limits.VelocityLimit { + limits.VelocityLimit[key] = value + } + } + if src.Limits.CurrencyLimits != nil { + limits.CurrencyLimits = map[string]*gatewayv1.LimitsOverride{} + for key, value := range src.Limits.CurrencyLimits { + if value == nil { + continue + } + clone := *value + limits.CurrencyLimits[key] = &clone + } + } + cp.Limits = &limits + } + return &cp +} diff --git a/api/gateway/mntx/internal/service/gateway/metrics.go b/api/gateway/mntx/internal/service/gateway/metrics.go index d34e004..dde3be3 100644 --- a/api/gateway/mntx/internal/service/gateway/metrics.go +++ b/api/gateway/mntx/internal/service/gateway/metrics.go @@ -2,15 +2,12 @@ package gateway import ( "errors" - "strings" "sync" "time" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promauto" - "github.com/shopspring/decimal" "github.com/tech/sendico/pkg/merrors" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ) var ( @@ -18,11 +15,6 @@ var ( rpcLatency *prometheus.HistogramVec rpcStatus *prometheus.CounterVec - - payoutCounter *prometheus.CounterVec - payoutAmountTotal *prometheus.CounterVec - payoutErrorCount *prometheus.CounterVec - payoutMissedAmounts *prometheus.CounterVec ) func initMetrics() { @@ -42,33 +34,6 @@ func initMetrics() { Help: "Total number of RPC invocations grouped by method and status.", }, []string{"method", "status"}) - payoutCounter = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "sendico", - Subsystem: "mntx_gateway", - Name: "payouts_total", - Help: "Total payouts processed grouped by outcome.", - }, []string{"status"}) - - payoutAmountTotal = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "sendico", - Subsystem: "mntx_gateway", - Name: "payout_amount_total", - Help: "Total payout amount grouped by outcome and currency.", - }, []string{"status", "currency"}) - - payoutErrorCount = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "sendico", - Subsystem: "mntx_gateway", - Name: "payout_errors_total", - Help: "Payout failures grouped by reason.", - }, []string{"reason"}) - - payoutMissedAmounts = promauto.NewCounterVec(prometheus.CounterOpts{ - Namespace: "sendico", - Subsystem: "mntx_gateway", - Name: "payout_missed_amount_total", - Help: "Total payout volume that failed grouped by reason and currency.", - }, []string{"reason", "currency"}) }) } @@ -81,71 +46,6 @@ func observeRPC(method string, err error, duration time.Duration) { } } -func observePayoutSuccess(amount *moneyv1.Money) { - if payoutCounter != nil { - payoutCounter.WithLabelValues("processed").Inc() - } - value, currency := monetaryValue(amount) - if value > 0 && payoutAmountTotal != nil { - payoutAmountTotal.WithLabelValues("processed", currency).Add(value) - } -} - -func observePayoutError(reason string, amount *moneyv1.Money) { - reason = reasonLabel(reason) - if payoutCounter != nil { - payoutCounter.WithLabelValues("failed").Inc() - } - if payoutErrorCount != nil { - payoutErrorCount.WithLabelValues(reason).Inc() - } - value, currency := monetaryValue(amount) - if value <= 0 { - return - } - if payoutAmountTotal != nil { - payoutAmountTotal.WithLabelValues("failed", currency).Add(value) - } - if payoutMissedAmounts != nil { - payoutMissedAmounts.WithLabelValues(reason, currency).Add(value) - } -} - -func monetaryValue(amount *moneyv1.Money) (float64, string) { - if amount == nil { - return 0, "unknown" - } - val := strings.TrimSpace(amount.Amount) - if val == "" { - return 0, currencyLabel(amount.Currency) - } - dec, err := decimal.NewFromString(val) - if err != nil { - return 0, currencyLabel(amount.Currency) - } - f, _ := dec.Float64() - if f < 0 { - return 0, currencyLabel(amount.Currency) - } - return f, currencyLabel(amount.Currency) -} - -func currencyLabel(code string) string { - code = strings.ToUpper(strings.TrimSpace(code)) - if code == "" { - return "unknown" - } - return code -} - -func reasonLabel(reason string) string { - reason = strings.TrimSpace(reason) - if reason == "" { - return "unknown" - } - return strings.ToLower(reason) -} - func statusLabel(err error) string { switch { case err == nil: diff --git a/api/gateway/mntx/internal/service/gateway/options.go b/api/gateway/mntx/internal/service/gateway/options.go index 12e4b3c..ce95918 100644 --- a/api/gateway/mntx/internal/service/gateway/options.go +++ b/api/gateway/mntx/internal/service/gateway/options.go @@ -6,6 +6,7 @@ import ( "github.com/tech/sendico/gateway/mntx/internal/service/monetix" "github.com/tech/sendico/pkg/clock" msg "github.com/tech/sendico/pkg/messaging" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" ) // Option configures optional service dependencies. @@ -42,3 +43,12 @@ func WithMonetixConfig(cfg monetix.Config) Option { s.config = cfg } } + +// WithGatewayDescriptor sets the self-declared gateway instance descriptor. +func WithGatewayDescriptor(descriptor *gatewayv1.GatewayInstanceDescriptor) Option { + return func(s *Service) { + if descriptor != nil { + s.gatewayDescriptor = descriptor + } + } +} diff --git a/api/gateway/mntx/internal/service/gateway/payout_get.go b/api/gateway/mntx/internal/service/gateway/payout_get.go deleted file mode 100644 index bd7c2d0..0000000 --- a/api/gateway/mntx/internal/service/gateway/payout_get.go +++ /dev/null @@ -1,36 +0,0 @@ -package gateway - -import ( - "context" - "fmt" - "strings" - - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - "github.com/tech/sendico/pkg/mservice" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - "go.uber.org/zap" -) - -func (s *Service) GetPayout(ctx context.Context, req *mntxv1.GetPayoutRequest) (*mntxv1.GetPayoutResponse, error) { - return executeUnary(ctx, s, "GetPayout", s.handleGetPayout, req) -} - -func (s *Service) handleGetPayout(_ context.Context, req *mntxv1.GetPayoutRequest) gsresponse.Responder[mntxv1.GetPayoutResponse] { - ref := strings.TrimSpace(req.GetPayoutRef()) - log := s.logger.Named("payout") - log.Info("Get payout request received", zap.String("payout_ref", ref)) - if ref == "" { - log.Warn("Get payout request missing payout_ref") - return gsresponse.InvalidArgument[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.InvalidArgument("payout_ref is required", "payout_ref")) - } - - payout, ok := s.store.Get(ref) - if !ok { - log.Warn("Payout not found", zap.String("payout_ref", ref)) - return gsresponse.NotFound[mntxv1.GetPayoutResponse](s.logger, mservice.MntxGateway, merrors.NoData(fmt.Sprintf("payout %s not found", ref))) - } - - log.Info("Payout retrieved", zap.String("payout_ref", ref), zap.String("status", payout.GetStatus().String())) - return gsresponse.Success(&mntxv1.GetPayoutResponse{Payout: payout}) -} diff --git a/api/gateway/mntx/internal/service/gateway/payout_store.go b/api/gateway/mntx/internal/service/gateway/payout_store.go deleted file mode 100644 index c12405a..0000000 --- a/api/gateway/mntx/internal/service/gateway/payout_store.go +++ /dev/null @@ -1,46 +0,0 @@ -package gateway - -import ( - "sync" - - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - "google.golang.org/protobuf/proto" -) - -type payoutStore struct { - mu sync.RWMutex - payouts map[string]*mntxv1.Payout -} - -func newPayoutStore() *payoutStore { - return &payoutStore{ - payouts: make(map[string]*mntxv1.Payout), - } -} - -func (s *payoutStore) Save(p *mntxv1.Payout) { - if p == nil { - return - } - s.mu.Lock() - defer s.mu.Unlock() - s.payouts[p.GetPayoutRef()] = clonePayout(p) -} - -func (s *payoutStore) Get(ref string) (*mntxv1.Payout, bool) { - s.mu.RLock() - defer s.mu.RUnlock() - p, ok := s.payouts[ref] - return clonePayout(p), ok -} - -func clonePayout(p *mntxv1.Payout) *mntxv1.Payout { - if p == nil { - return nil - } - cloned := proto.Clone(p) - if cp, ok := cloned.(*mntxv1.Payout); ok { - return cp - } - return nil -} diff --git a/api/gateway/mntx/internal/service/gateway/payout_submit.go b/api/gateway/mntx/internal/service/gateway/payout_submit.go deleted file mode 100644 index 72767c6..0000000 --- a/api/gateway/mntx/internal/service/gateway/payout_submit.go +++ /dev/null @@ -1,144 +0,0 @@ -package gateway - -import ( - "context" - "strings" - "time" - - "github.com/tech/sendico/pkg/api/routers/gsresponse" - "github.com/tech/sendico/pkg/merrors" - messaging "github.com/tech/sendico/pkg/messaging/envelope" - "github.com/tech/sendico/pkg/model" - nm "github.com/tech/sendico/pkg/model/notification" - "github.com/tech/sendico/pkg/mservice" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - "go.uber.org/zap" - "google.golang.org/protobuf/encoding/protojson" - "google.golang.org/protobuf/types/known/timestamppb" -) - -func (s *Service) SubmitPayout(ctx context.Context, req *mntxv1.SubmitPayoutRequest) (*mntxv1.SubmitPayoutResponse, error) { - return executeUnary(ctx, s, "SubmitPayout", s.handleSubmitPayout, req) -} - -func (s *Service) handleSubmitPayout(_ context.Context, req *mntxv1.SubmitPayoutRequest) gsresponse.Responder[mntxv1.SubmitPayoutResponse] { - log := s.logger.Named("payout") - log.Info("Submit payout request received", - zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())), - zap.String("organization_ref", strings.TrimSpace(req.GetOrganizationRef())), - zap.String("currency", strings.TrimSpace(req.GetAmount().GetCurrency())), - zap.String("amount", strings.TrimSpace(req.GetAmount().GetAmount())), - ) - - payout, err := s.buildPayout(req) - if err != nil { - log.Warn("Submit payout validation failed", zap.Error(err)) - return gsresponse.Auto[mntxv1.SubmitPayoutResponse](s.logger, mservice.MntxGateway, err) - } - - s.store.Save(payout) - s.emitEvent(payout, nm.NAPending) - go s.completePayout(payout, strings.TrimSpace(req.GetSimulatedFailureReason())) - - log.Info("Payout accepted", zap.String("payout_ref", payout.GetPayoutRef()), zap.String("status", payout.GetStatus().String())) - return gsresponse.Success(&mntxv1.SubmitPayoutResponse{Payout: payout}) -} - -func (s *Service) buildPayout(req *mntxv1.SubmitPayoutRequest) (*mntxv1.Payout, error) { - if req == nil { - return nil, newPayoutError("invalid_request", merrors.InvalidArgument("request cannot be empty")) - } - - idempotencyKey := strings.TrimSpace(req.IdempotencyKey) - if idempotencyKey == "" { - return nil, newPayoutError("missing_idempotency_key", merrors.InvalidArgument("idempotency_key is required", "idempotency_key")) - } - - orgRef := strings.TrimSpace(req.OrganizationRef) - if orgRef == "" { - return nil, newPayoutError("missing_organization_ref", merrors.InvalidArgument("organization_ref is required", "organization_ref")) - } - - if err := validateAmount(req.Amount); err != nil { - return nil, err - } - - if err := validateDestination(req.Destination); err != nil { - return nil, err - } - - if reason := strings.TrimSpace(req.SimulatedFailureReason); reason != "" { - return nil, newPayoutError(normalizeReason(reason), merrors.InvalidArgument("simulated payout failure requested")) - } - - now := timestamppb.New(s.clock.Now()) - payout := &mntxv1.Payout{ - PayoutRef: newPayoutRef(), - IdempotencyKey: idempotencyKey, - OrganizationRef: orgRef, - Destination: req.Destination, - Amount: req.Amount, - Description: strings.TrimSpace(req.Description), - Metadata: req.Metadata, - Status: mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING, - CreatedAt: now, - UpdatedAt: now, - } - - return payout, nil -} - -func (s *Service) completePayout(original *mntxv1.Payout, simulatedFailure string) { - log := s.logger.Named("payout") - outcome := clonePayout(original) - if outcome == nil { - return - } - - // Simulate async processing delay for realism. - time.Sleep(150 * time.Millisecond) - - outcome.UpdatedAt = timestamppb.New(s.clock.Now()) - - if simulatedFailure != "" { - outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED - outcome.FailureReason = simulatedFailure - observePayoutError(simulatedFailure, outcome.Amount) - s.store.Save(outcome) - s.emitEvent(outcome, nm.NAUpdated) - log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String()), zap.String("failure_reason", simulatedFailure)) - return - } - - outcome.Status = mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED - observePayoutSuccess(outcome.Amount) - s.store.Save(outcome) - s.emitEvent(outcome, nm.NAUpdated) - log.Info("Payout completed", zap.String("payout_ref", outcome.GetPayoutRef()), zap.String("status", outcome.GetStatus().String())) -} - -func (s *Service) emitEvent(payout *mntxv1.Payout, action nm.NotificationAction) { - if payout == nil || s.producer == nil { - return - } - - payload, err := protojson.Marshal(&mntxv1.PayoutStatusChangedEvent{Payout: payout}) - if err != nil { - s.logger.Warn("Failed to marshal payout event", zapError(err)) - return - } - - env := messaging.CreateEnvelope(string(mservice.MntxGateway), model.NewNotification(mservice.MntxGateway, action)) - if _, err := env.Wrap(payload); err != nil { - s.logger.Warn("Failed to wrap payout event payload", zapError(err)) - return - } - - if err := s.producer.SendMessage(env); err != nil { - s.logger.Warn("Failed to publish payout event", zapError(err)) - } -} - -func zapError(err error) zap.Field { - return zap.Error(err) -} diff --git a/api/gateway/mntx/internal/service/gateway/payout_validation.go b/api/gateway/mntx/internal/service/gateway/payout_validation.go deleted file mode 100644 index debb7df..0000000 --- a/api/gateway/mntx/internal/service/gateway/payout_validation.go +++ /dev/null @@ -1,106 +0,0 @@ -package gateway - -import ( - "strconv" - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/pkg/merrors" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" -) - -func validateAmount(amount *moneyv1.Money) error { - if amount == nil { - return newPayoutError("missing_amount", merrors.InvalidArgument("amount is required", "amount")) - } - - if strings.TrimSpace(amount.Currency) == "" { - return newPayoutError("missing_currency", merrors.InvalidArgument("amount currency is required", "amount.currency")) - } - - val := strings.TrimSpace(amount.Amount) - if val == "" { - return newPayoutError("missing_amount_value", merrors.InvalidArgument("amount value is required", "amount.amount")) - } - dec, err := decimal.NewFromString(val) - if err != nil { - return newPayoutError("invalid_amount", merrors.InvalidArgument("amount must be a decimal value", "amount.amount")) - } - if dec.Sign() <= 0 { - return newPayoutError("non_positive_amount", merrors.InvalidArgument("amount must be positive", "amount.amount")) - } - return nil -} - -func validateDestination(dest *mntxv1.PayoutDestination) error { - if dest == nil { - return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination")) - } - - if bank := dest.GetBankAccount(); bank != nil { - return validateBankAccount(bank) - } - - if card := dest.GetCard(); card != nil { - return validateCardDestination(card) - } - - return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include bank_account or card", "destination")) -} - -func validateBankAccount(dest *mntxv1.BankAccount) error { - if dest == nil { - return newPayoutError("missing_destination", merrors.InvalidArgument("destination is required", "destination")) - } - iban := strings.TrimSpace(dest.Iban) - holder := strings.TrimSpace(dest.AccountHolder) - - if iban == "" && holder == "" { - return newPayoutError("invalid_destination", merrors.InvalidArgument("destination must include iban or account_holder", "destination")) - } - return nil -} - -func validateCardDestination(card *mntxv1.CardDestination) error { - if card == nil { - return newPayoutError("missing_destination", merrors.InvalidArgument("destination.card is required", "destination.card")) - } - - pan := strings.TrimSpace(card.GetPan()) - token := strings.TrimSpace(card.GetToken()) - if pan == "" && token == "" { - return newPayoutError("invalid_card_destination", merrors.InvalidArgument("card destination must include pan or token", "destination.card")) - } - - if strings.TrimSpace(card.GetCardholderName()) == "" { - return newPayoutError("missing_cardholder_name", merrors.InvalidArgument("cardholder_name is required", "destination.card.cardholder_name")) - } - - month := strings.TrimSpace(card.GetExpMonth()) - year := strings.TrimSpace(card.GetExpYear()) - if pan != "" { - if err := validateExpiry(month, year); err != nil { - return err - } - } - - return nil -} - -func validateExpiry(month, year string) error { - if month == "" || year == "" { - return newPayoutError("missing_expiry", merrors.InvalidArgument("exp_month and exp_year are required for card payouts", "destination.card.expiry")) - } - - m, err := strconv.Atoi(month) - if err != nil || m < 1 || m > 12 { - return newPayoutError("invalid_expiry_month", merrors.InvalidArgument("exp_month must be between 01 and 12", "destination.card.exp_month")) - } - - if _, err := strconv.Atoi(year); err != nil || len(year) < 2 { - return newPayoutError("invalid_expiry_year", merrors.InvalidArgument("exp_year must be numeric", "destination.card.exp_year")) - } - - return nil -} diff --git a/api/gateway/mntx/internal/service/gateway/service.go b/api/gateway/mntx/internal/service/gateway/service.go index ec751a3..d06e5ae 100644 --- a/api/gateway/mntx/internal/service/gateway/service.go +++ b/api/gateway/mntx/internal/service/gateway/service.go @@ -5,28 +5,31 @@ import ( "net/http" "strings" - "github.com/google/uuid" + "github.com/tech/sendico/gateway/mntx/internal/appversion" "github.com/tech/sendico/gateway/mntx/internal/service/monetix" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/api/routers/gsresponse" clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/discovery" msg "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" "go.uber.org/zap" "google.golang.org/grpc" ) type Service struct { - logger mlogger.Logger - clock clockpkg.Clock - producer msg.Producer - store *payoutStore - cardStore *cardPayoutStore - config monetix.Config - httpClient *http.Client - card *cardPayoutProcessor + logger mlogger.Logger + clock clockpkg.Clock + producer msg.Producer + cardStore *cardPayoutStore + config monetix.Config + httpClient *http.Client + card *cardPayoutProcessor + gatewayDescriptor *gatewayv1.GatewayInstanceDescriptor + announcer *discovery.Announcer mntxv1.UnimplementedMntxGatewayServiceServer } @@ -58,7 +61,6 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service { svc := &Service{ logger: logger.Named("service"), clock: clockpkg.NewSystem(), - store: newPayoutStore(), cardStore: newCardPayoutStore(), config: monetix.DefaultConfig(), } @@ -86,6 +88,7 @@ func NewService(logger mlogger.Logger, opts ...Option) *Service { } svc.card = newCardPayoutProcessor(svc.logger, svc.config, svc.clock, svc.cardStore, svc.httpClient, svc.producer) + svc.startDiscoveryAnnouncer() return svc } @@ -97,6 +100,15 @@ func (s *Service) Register(router routers.GRPC) error { }) } +func (s *Service) Shutdown() { + if s == nil { + return + } + if s.announcer != nil { + s.announcer.Stop() + } +} + func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method string, handler func(context.Context, *TReq) gsresponse.Responder[TResp], req *TReq) (*TResp, error) { log := svc.logger.Named("rpc") log.Info("RPC request started", zap.String("method", method)) @@ -114,10 +126,6 @@ func executeUnary[TReq any, TResp any](ctx context.Context, svc *Service, method return resp, err } -func newPayoutRef() string { - return "pyt_" + strings.ReplaceAll(uuid.New().String(), "-", "") -} - func normalizeReason(reason string) string { return strings.ToLower(strings.TrimSpace(reason)) } @@ -128,3 +136,59 @@ func newPayoutError(reason string, err error) error { err: err, } } + +func (s *Service) startDiscoveryAnnouncer() { + if s == nil || s.producer == nil { + return + } + announce := discovery.Announcement{ + Service: "CARD_PAYOUT_RAIL_GATEWAY", + Rail: "CARD_PAYOUT", + Operations: []string{"payout.card"}, + Version: appversion.Create().Short(), + } + if s.gatewayDescriptor != nil { + if id := strings.TrimSpace(s.gatewayDescriptor.GetId()); id != "" { + announce.ID = id + } + announce.Network = strings.TrimSpace(s.gatewayDescriptor.GetNetwork()) + announce.Currencies = append([]string(nil), s.gatewayDescriptor.GetCurrencies()...) + announce.Limits = limitsFromDescriptor(s.gatewayDescriptor.GetLimits()) + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.MntxGateway), announce) + s.announcer.Start() +} + +func limitsFromDescriptor(src *gatewayv1.Limits) *discovery.Limits { + if src == nil { + return nil + } + limits := &discovery.Limits{ + MinAmount: strings.TrimSpace(src.GetMinAmount()), + MaxAmount: strings.TrimSpace(src.GetMaxAmount()), + VolumeLimit: map[string]string{}, + VelocityLimit: map[string]int{}, + } + for key, value := range src.GetVolumeLimit() { + k := strings.TrimSpace(key) + v := strings.TrimSpace(value) + if k == "" || v == "" { + continue + } + limits.VolumeLimit[k] = v + } + for key, value := range src.GetVelocityLimit() { + k := strings.TrimSpace(key) + if k == "" { + continue + } + limits.VelocityLimit[k] = int(value) + } + if len(limits.VolumeLimit) == 0 { + limits.VolumeLimit = nil + } + if len(limits.VelocityLimit) == 0 { + limits.VelocityLimit = nil + } + return limits +} diff --git a/api/ledger/client/client.go b/api/ledger/client/client.go index f6ecfce..a82c4ae 100644 --- a/api/ledger/client/client.go +++ b/api/ledger/client/client.go @@ -8,6 +8,8 @@ import ( "time" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/payments/rail" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials" @@ -16,6 +18,10 @@ import ( // Client exposes typed helpers around the ledger gRPC API. type Client interface { + ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) + CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) + HoldBalance(ctx context.Context, accountID string, amount string) error + CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) ListAccounts(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) PostCreditWithCharges(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) @@ -95,6 +101,80 @@ func (c *ledgerClient) Close() error { return nil } +func (c *ledgerClient) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) { + if strings.TrimSpace(accountID) == "" { + return nil, merrors.InvalidArgument("ledger: account_id is required") + } + resp, err := c.GetBalance(ctx, &ledgerv1.GetBalanceRequest{LedgerAccountRef: strings.TrimSpace(accountID)}) + if err != nil { + return nil, err + } + if resp == nil || resp.GetBalance() == nil { + return nil, merrors.Internal("ledger: balance response missing") + } + return cloneMoney(resp.GetBalance()), nil +} + +func (c *ledgerClient) CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) { + orgRef := strings.TrimSpace(tx.OrganizationRef) + if orgRef == "" { + return "", merrors.InvalidArgument("ledger: organization_ref is required") + } + accountRef := strings.TrimSpace(tx.LedgerAccountRef) + if accountRef == "" { + return "", merrors.InvalidArgument("ledger: ledger_account_ref is required") + } + money := &moneyv1.Money{ + Currency: strings.TrimSpace(tx.Currency), + Amount: strings.TrimSpace(tx.Amount), + } + if money.GetCurrency() == "" || money.GetAmount() == "" { + return "", merrors.InvalidArgument("ledger: amount is required") + } + + description := strings.TrimSpace(tx.Description) + metadata := ledgerTxMetadata(tx.Metadata, tx) + + switch { + case isLedgerRail(tx.FromRail) && !isLedgerRail(tx.ToRail): + resp, err := c.PostDebitWithCharges(ctx, &ledgerv1.PostDebitRequest{ + IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey), + OrganizationRef: orgRef, + LedgerAccountRef: accountRef, + Money: money, + Description: description, + Charges: tx.Charges, + Metadata: metadata, + ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef), + }) + if err != nil { + return "", err + } + return strings.TrimSpace(resp.GetJournalEntryRef()), nil + case isLedgerRail(tx.ToRail) && !isLedgerRail(tx.FromRail): + resp, err := c.PostCreditWithCharges(ctx, &ledgerv1.PostCreditRequest{ + IdempotencyKey: strings.TrimSpace(tx.IdempotencyKey), + OrganizationRef: orgRef, + LedgerAccountRef: accountRef, + Money: money, + Description: description, + Charges: tx.Charges, + Metadata: metadata, + ContraLedgerAccountRef: strings.TrimSpace(tx.ContraLedgerAccountRef), + }) + if err != nil { + return "", err + } + return strings.TrimSpace(resp.GetJournalEntryRef()), nil + default: + return "", merrors.InvalidArgument("ledger: unsupported transaction direction") + } +} + +func (c *ledgerClient) HoldBalance(ctx context.Context, accountID string, amount string) error { + return merrors.NotImplemented("ledger: hold balance not supported") +} + func (c *ledgerClient) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() @@ -156,3 +236,57 @@ func (c *ledgerClient) callContext(ctx context.Context) (context.Context, contex } return context.WithTimeout(ctx, timeout) } + +func isLedgerRail(value string) bool { + return strings.EqualFold(strings.TrimSpace(value), "LEDGER") +} + +func cloneMoney(input *moneyv1.Money) *moneyv1.Money { + if input == nil { + return nil + } + return &moneyv1.Money{ + Currency: input.GetCurrency(), + Amount: input.GetAmount(), + } +} + +func cloneMetadata(input map[string]string) map[string]string { + if len(input) == 0 { + return nil + } + out := make(map[string]string, len(input)) + for k, v := range input { + out[k] = v + } + return out +} + +func ledgerTxMetadata(base map[string]string, tx rail.LedgerTx) map[string]string { + meta := cloneMetadata(base) + if meta == nil { + meta = map[string]string{} + } + if val := strings.TrimSpace(tx.PaymentPlanID); val != "" { + meta["payment_plan_id"] = val + } + if val := strings.TrimSpace(tx.FromRail); val != "" { + meta["from_rail"] = val + } + if val := strings.TrimSpace(tx.ToRail); val != "" { + meta["to_rail"] = val + } + if val := strings.TrimSpace(tx.ExternalReferenceID); val != "" { + meta["external_reference_id"] = val + } + if val := strings.TrimSpace(tx.FXRateUsed); val != "" { + meta["fx_rate_used"] = val + } + if val := strings.TrimSpace(tx.FeeAmount); val != "" { + meta["fee_amount"] = val + } + if len(meta) == 0 { + return nil + } + return meta +} diff --git a/api/ledger/client/fake.go b/api/ledger/client/fake.go index dc76879..7ef6b74 100644 --- a/api/ledger/client/fake.go +++ b/api/ledger/client/fake.go @@ -3,13 +3,18 @@ package client import ( "context" + "github.com/tech/sendico/pkg/payments/rail" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ) // Fake implements Client for tests. type Fake struct { - CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) - ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) + ReadBalanceFn func(ctx context.Context, accountID string) (*moneyv1.Money, error) + CreateTransactionFn func(ctx context.Context, tx rail.LedgerTx) (string, error) + HoldBalanceFn func(ctx context.Context, accountID string, amount string) error + CreateAccountFn func(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) + ListAccountsFn func(ctx context.Context, req *ledgerv1.ListAccountsRequest) (*ledgerv1.ListAccountsResponse, error) PostCreditWithChargesFn func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) PostDebitWithChargesFn func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) TransferInternalFn func(ctx context.Context, req *ledgerv1.TransferRequest) (*ledgerv1.PostResponse, error) @@ -20,6 +25,27 @@ type Fake struct { CloseFn func() error } +func (f *Fake) ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) { + if f.ReadBalanceFn != nil { + return f.ReadBalanceFn(ctx, accountID) + } + return &moneyv1.Money{}, nil +} + +func (f *Fake) CreateTransaction(ctx context.Context, tx rail.LedgerTx) (string, error) { + if f.CreateTransactionFn != nil { + return f.CreateTransactionFn(ctx, tx) + } + return "", nil +} + +func (f *Fake) HoldBalance(ctx context.Context, accountID string, amount string) error { + if f.HoldBalanceFn != nil { + return f.HoldBalanceFn(ctx, accountID, amount) + } + return nil +} + func (f *Fake) CreateAccount(ctx context.Context, req *ledgerv1.CreateAccountRequest) (*ledgerv1.CreateAccountResponse, error) { if f.CreateAccountFn != nil { return f.CreateAccountFn(ctx, req) diff --git a/api/ledger/internal/service/ledger/service.go b/api/ledger/internal/service/ledger/service.go index c2fafbd..a88a88a 100644 --- a/api/ledger/internal/service/ledger/service.go +++ b/api/ledger/internal/service/ledger/service.go @@ -16,11 +16,14 @@ import ( moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" tracev1 "github.com/tech/sendico/pkg/proto/common/trace/v1" + "github.com/tech/sendico/ledger/internal/appversion" "github.com/tech/sendico/ledger/storage" "github.com/tech/sendico/pkg/api/routers" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" pmessaging "github.com/tech/sendico/pkg/messaging" "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" ) @@ -35,10 +38,11 @@ var ( ) type Service struct { - logger mlogger.Logger - storage storage.Repository - producer pmessaging.Producer - fees feesDependency + logger mlogger.Logger + storage storage.Repository + producer pmessaging.Producer + fees feesDependency + announcer *discovery.Announcer outbox struct { once sync.Once @@ -72,6 +76,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, prod pmessaging. } service.startOutboxPublisher() + service.startDiscoveryAnnouncer() return service } @@ -184,11 +189,27 @@ func (s *Service) Shutdown() { if s == nil { return } + if s.announcer != nil { + s.announcer.Stop() + } if s.outbox.cancel != nil { s.outbox.cancel() } } +func (s *Service) startDiscoveryAnnouncer() { + if s == nil || s.producer == nil { + return + } + announce := discovery.Announcement{ + Service: "LEDGER", + Operations: []string{"balance.read", "ledger.debit", "ledger.credit"}, + Version: appversion.Create().Short(), + } + s.announcer = discovery.NewAnnouncer(s.logger, s.producer, string(mservice.Ledger), announce) + s.announcer.Start() +} + func (s *Service) startOutboxPublisher() { if s.storage == nil || s.producer == nil { return diff --git a/api/notification/go.mod b/api/notification/go.mod index 30e7b35..3647605 100644 --- a/api/notification/go.mod +++ b/api/notification/go.mod @@ -5,7 +5,7 @@ go 1.25.3 replace github.com/tech/sendico/pkg => ../pkg require ( - github.com/amplitude/analytics-go v1.2.0 + github.com/amplitude/analytics-go v1.3.0 github.com/go-chi/chi/v5 v5.2.3 github.com/mitchellh/mapstructure v1.5.0 github.com/nicksnyder/go-i18n/v2 v2.6.0 diff --git a/api/notification/go.sum b/api/notification/go.sum index 395be82..5d8318c 100644 --- a/api/notification/go.sum +++ b/api/notification/go.sum @@ -6,8 +6,8 @@ github.com/BurntSushi/toml v1.5.0 h1:W5quZX/G/csjUnuI8SUYlsHs9M38FC7znL0lIO+DvMg github.com/BurntSushi/toml v1.5.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/amplitude/analytics-go v1.2.0 h1:+WUKyAAKwlmSM8d03QWG+NjnrQIyc6VJRGPNkaa2ckI= -github.com/amplitude/analytics-go v1.2.0/go.mod h1:kAQG8OQ6aPOxZrEZ3+/NFCfxdYSyjqXZhgkjWFD3/vo= +github.com/amplitude/analytics-go v1.3.0 h1:Lgj31fWThQ6hdDHO0RPxQfy/D7d8K+aqWsBa+IGTxQk= +github.com/amplitude/analytics-go v1.3.0/go.mod h1:kAQG8OQ6aPOxZrEZ3+/NFCfxdYSyjqXZhgkjWFD3/vo= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= diff --git a/api/notification/internal/server/notificationimp/notification.go b/api/notification/internal/server/notificationimp/notification.go index 4f81929..5c84c45 100644 --- a/api/notification/internal/server/notificationimp/notification.go +++ b/api/notification/internal/server/notificationimp/notification.go @@ -4,8 +4,10 @@ import ( "context" "github.com/tech/sendico/notification/interface/api" + "github.com/tech/sendico/notification/internal/appversion" mmail "github.com/tech/sendico/notification/internal/server/notificationimp/mail" "github.com/tech/sendico/notification/internal/server/notificationimp/telegram" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/domainprovider" "github.com/tech/sendico/pkg/merrors" na "github.com/tech/sendico/pkg/messaging/notifications/account" @@ -19,10 +21,11 @@ import ( ) type NotificationAPI struct { - logger mlogger.Logger - client mmail.Client - dp domainprovider.DomainProvider - tg telegram.Client + logger mlogger.Logger + client mmail.Client + dp domainprovider.DomainProvider + tg telegram.Client + announcer *discovery.Announcer } func (a *NotificationAPI) Name() mservice.Type { @@ -30,6 +33,9 @@ func (a *NotificationAPI) Name() mservice.Type { } func (a *NotificationAPI) Finish(_ context.Context) error { + if a.announcer != nil { + a.announcer.Stop() + } return nil } @@ -91,6 +97,14 @@ func CreateAPI(a api.API) (*NotificationAPI, error) { return nil, err } + announce := discovery.Announcement{ + Service: "NOTIFICATIONS", + Operations: []string{"notify.send"}, + Version: appversion.Create().Short(), + } + p.announcer = discovery.NewAnnouncer(p.logger, a.Register().Producer(), string(mservice.Notifications), announce) + p.announcer.Start() + return p, nil } diff --git a/api/payments/orchestrator/config.yml b/api/payments/orchestrator/config.yml index c78f6f0..7d6b339 100644 --- a/api/payments/orchestrator/config.yml +++ b/api/payments/orchestrator/config.yml @@ -70,3 +70,15 @@ card_gateways: fee_ledger_accounts: monetix: "ledger:fees:monetix" + +# gateway_instances: +# - id: "crypto-tron" +# rail: "CRYPTO" +# network: "TRON" +# currencies: ["USDT"] +# capabilities: +# can_pay_out: true +# can_send_fee: true +# limits: +# min_amount: "0" +# max_amount: "100000" diff --git a/api/payments/orchestrator/internal/server/internal/serverimp.go b/api/payments/orchestrator/internal/server/internal/serverimp.go index b025934..888bc1a 100644 --- a/api/payments/orchestrator/internal/server/internal/serverimp.go +++ b/api/payments/orchestrator/internal/server/internal/serverimp.go @@ -11,13 +11,19 @@ import ( chainclient "github.com/tech/sendico/gateway/chain/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" + "github.com/tech/sendico/payments/orchestrator/internal/appversion" "github.com/tech/sendico/payments/orchestrator/internal/service/orchestrator" "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" mongostorage "github.com/tech/sendico/payments/orchestrator/storage/mongo" "github.com/tech/sendico/pkg/api/routers" "github.com/tech/sendico/pkg/db" + "github.com/tech/sendico/pkg/discovery" msg "github.com/tech/sendico/pkg/messaging" + msgproducer "github.com/tech/sendico/pkg/messaging/producer" "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/mservice" + "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" "github.com/tech/sendico/pkg/server/grpcapp" "go.uber.org/zap" @@ -32,24 +38,28 @@ type Imp struct { file string debug bool - config *config - app *grpcapp.App[storage.Repository] - feesConn *grpc.ClientConn - ledgerClient ledgerclient.Client - gatewayClient chainclient.Client - mntxClient mntxclient.Client - oracleClient oracleclient.Client + config *config + app *grpcapp.App[storage.Repository] + discoverySvc *discovery.RegistryService + discoveryReg *discovery.Registry + discoveryAnnouncer *discovery.Announcer + feesConn *grpc.ClientConn + ledgerClient ledgerclient.Client + gatewayClient chainclient.Client + mntxClient mntxclient.Client + oracleClient oracleclient.Client } type config struct { - *grpcapp.Config `yaml:",inline"` - Fees clientConfig `yaml:"fees"` - Ledger clientConfig `yaml:"ledger"` - Gateway clientConfig `yaml:"gateway"` - Mntx clientConfig `yaml:"mntx"` - Oracle clientConfig `yaml:"oracle"` - CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"` - FeeAccounts map[string]string `yaml:"fee_ledger_accounts"` + *grpcapp.Config `yaml:",inline"` + Fees clientConfig `yaml:"fees"` + Ledger clientConfig `yaml:"ledger"` + Gateway clientConfig `yaml:"gateway"` + Mntx clientConfig `yaml:"mntx"` + Oracle clientConfig `yaml:"oracle"` + CardGateways map[string]cardGatewayRouteConfig `yaml:"card_gateways"` + FeeAccounts map[string]string `yaml:"fee_ledger_accounts"` + GatewayInstances []gatewayInstanceConfig `yaml:"gateway_instances"` } type clientConfig struct { @@ -65,6 +75,44 @@ type cardGatewayRouteConfig struct { FeeWalletRef string `yaml:"fee_wallet_ref"` } +type gatewayInstanceConfig struct { + ID string `yaml:"id"` + Rail string `yaml:"rail"` + Network string `yaml:"network"` + Currencies []string `yaml:"currencies"` + Capabilities gatewayCapabilitiesConfig `yaml:"capabilities"` + Limits limitsConfig `yaml:"limits"` + Version string `yaml:"version"` + IsEnabled *bool `yaml:"is_enabled"` +} + +type gatewayCapabilitiesConfig struct { + CanPayIn bool `yaml:"can_pay_in"` + CanPayOut bool `yaml:"can_pay_out"` + CanReadBalance bool `yaml:"can_read_balance"` + CanSendFee bool `yaml:"can_send_fee"` + RequiresObserveConfirm bool `yaml:"requires_observe_confirm"` +} + +type limitsConfig struct { + MinAmount string `yaml:"min_amount"` + MaxAmount string `yaml:"max_amount"` + PerTxMaxFee string `yaml:"per_tx_max_fee"` + PerTxMinAmount string `yaml:"per_tx_min_amount"` + PerTxMaxAmount string `yaml:"per_tx_max_amount"` + VolumeLimit map[string]string `yaml:"volume_limit"` + VelocityLimit map[string]int `yaml:"velocity_limit"` + CurrencyLimits map[string]limitsOverrideCfg `yaml:"currency_limits"` +} + +type limitsOverrideCfg struct { + MaxVolume string `yaml:"max_volume"` + MinAmount string `yaml:"min_amount"` + MaxAmount string `yaml:"max_amount"` + MaxFee string `yaml:"max_fee"` + MaxOps int `yaml:"max_ops"` +} + func (c clientConfig) address() string { return strings.TrimSpace(c.Address) } @@ -92,6 +140,12 @@ func Create(logger mlogger.Logger, file string, debug bool) (*Imp, error) { } func (i *Imp) Shutdown() { + if i.discoveryAnnouncer != nil { + i.discoveryAnnouncer.Stop() + } + if i.discoverySvc != nil { + i.discoverySvc.Stop() + } if i.app != nil { timeout := 15 * time.Second if i.config != nil && i.config.Runtime != nil { @@ -126,6 +180,32 @@ func (i *Imp) Start() error { } i.config = cfg + if cfg.Messaging != nil && cfg.Messaging.Driver != "" { + broker, err := msg.CreateMessagingBroker(i.logger.Named("discovery_bus"), cfg.Messaging) + if err != nil { + i.logger.Warn("Failed to initialise discovery broker", zap.Error(err)) + } else { + producer := msgproducer.NewProducer(i.logger.Named("discovery_producer"), broker) + registry := discovery.NewRegistry() + svc, err := discovery.NewRegistryService(i.logger, broker, producer, registry, string(mservice.PaymentOrchestrator)) + if err != nil { + i.logger.Warn("Failed to start discovery registry service", zap.Error(err)) + } else { + svc.Start() + i.discoverySvc = svc + i.discoveryReg = registry + i.logger.Info("Discovery registry service started") + } + announce := discovery.Announcement{ + Service: "PAYMENTS_ORCHESTRATOR", + Operations: []string{"payment.quote", "payment.initiate"}, + Version: appversion.Create().Short(), + } + i.discoveryAnnouncer = discovery.NewAnnouncer(i.logger, producer, string(mservice.PaymentOrchestrator), announce) + i.discoveryAnnouncer.Start() + } + } + repoFactory := func(logger mlogger.Logger, conn *db.MongoConnection) (storage.Repository, error) { return mongostorage.New(logger, conn) } @@ -166,6 +246,9 @@ func (i *Imp) Start() error { if gatewayClient != nil { opts = append(opts, orchestrator.WithChainGatewayClient(gatewayClient)) } + if railGateways := buildRailGateways(gatewayClient, cfg.GatewayInstances); len(railGateways) > 0 { + opts = append(opts, orchestrator.WithRailGateways(railGateways)) + } if mntxClient != nil { opts = append(opts, orchestrator.WithMntxGateway(mntxClient)) } @@ -178,6 +261,9 @@ func (i *Imp) Start() error { if feeAccounts := buildFeeLedgerAccounts(cfg.FeeAccounts); len(feeAccounts) > 0 { opts = append(opts, orchestrator.WithFeeLedgerAccounts(feeAccounts)) } + if registry := buildGatewayRegistry(i.logger, mntxClient, cfg.GatewayInstances, i.discoveryReg); registry != nil { + opts = append(opts, orchestrator.WithGatewayRegistry(registry)) + } return orchestrator.NewService(logger, repo, opts...), nil } @@ -382,3 +468,178 @@ func buildFeeLedgerAccounts(src map[string]string) map[string]string { } return result } + +func buildGatewayRegistry(logger mlogger.Logger, mntxClient mntxclient.Client, src []gatewayInstanceConfig, registry *discovery.Registry) orchestrator.GatewayRegistry { + static := buildGatewayInstances(logger, src) + staticRegistry := orchestrator.NewGatewayRegistry(logger, mntxClient, static) + discoveryRegistry := orchestrator.NewDiscoveryGatewayRegistry(logger, registry) + return orchestrator.NewCompositeGatewayRegistry(logger, staticRegistry, discoveryRegistry) +} + +func buildRailGateways(chainClient chainclient.Client, src []gatewayInstanceConfig) map[string]rail.RailGateway { + if chainClient == nil || len(src) == 0 { + return nil + } + instances := buildGatewayInstances(nil, src) + if len(instances) == 0 { + return nil + } + result := map[string]rail.RailGateway{} + for _, inst := range instances { + if inst == nil || !inst.IsEnabled { + continue + } + if inst.Rail != model.RailCrypto { + continue + } + cfg := chainclient.RailGatewayConfig{ + Rail: string(inst.Rail), + Network: inst.Network, + Capabilities: rail.RailCapabilities{ + CanPayIn: inst.Capabilities.CanPayIn, + CanPayOut: inst.Capabilities.CanPayOut, + CanReadBalance: inst.Capabilities.CanReadBalance, + CanSendFee: inst.Capabilities.CanSendFee, + RequiresObserveConfirm: inst.Capabilities.RequiresObserveConfirm, + }, + } + result[inst.ID] = chainclient.NewRailGateway(chainClient, cfg) + } + if len(result) == 0 { + return nil + } + return result +} + +func buildGatewayInstances(logger mlogger.Logger, src []gatewayInstanceConfig) []*model.GatewayInstanceDescriptor { + if len(src) == 0 { + return nil + } + if logger != nil { + logger = logger.Named("gateway_instances") + } + result := make([]*model.GatewayInstanceDescriptor, 0, len(src)) + for _, cfg := range src { + id := strings.TrimSpace(cfg.ID) + if id == "" { + if logger != nil { + logger.Warn("Gateway instance skipped: missing id") + } + continue + } + rail := parseRail(cfg.Rail) + if rail == model.RailUnspecified { + if logger != nil { + logger.Warn("Gateway instance skipped: invalid rail", zap.String("id", id), zap.String("rail", cfg.Rail)) + } + continue + } + enabled := true + if cfg.IsEnabled != nil { + enabled = *cfg.IsEnabled + } + result = append(result, &model.GatewayInstanceDescriptor{ + ID: id, + Rail: rail, + Network: strings.ToUpper(strings.TrimSpace(cfg.Network)), + Currencies: normalizeCurrencies(cfg.Currencies), + Capabilities: model.RailCapabilities{ + CanPayIn: cfg.Capabilities.CanPayIn, + CanPayOut: cfg.Capabilities.CanPayOut, + CanReadBalance: cfg.Capabilities.CanReadBalance, + CanSendFee: cfg.Capabilities.CanSendFee, + RequiresObserveConfirm: cfg.Capabilities.RequiresObserveConfirm, + }, + Limits: buildGatewayLimits(cfg.Limits), + Version: strings.TrimSpace(cfg.Version), + IsEnabled: enabled, + }) + } + return result +} + +func parseRail(value string) model.Rail { + switch strings.ToUpper(strings.TrimSpace(value)) { + 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 + } +} + +func normalizeCurrencies(values []string) []string { + if len(values) == 0 { + return nil + } + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" || seen[clean] { + continue + } + seen[clean] = true + result = append(result, clean) + } + return result +} + +func buildGatewayLimits(cfg limitsConfig) model.Limits { + limits := model.Limits{ + MinAmount: strings.TrimSpace(cfg.MinAmount), + MaxAmount: strings.TrimSpace(cfg.MaxAmount), + PerTxMaxFee: strings.TrimSpace(cfg.PerTxMaxFee), + PerTxMinAmount: strings.TrimSpace(cfg.PerTxMinAmount), + PerTxMaxAmount: strings.TrimSpace(cfg.PerTxMaxAmount), + } + + if len(cfg.VolumeLimit) > 0 { + limits.VolumeLimit = map[string]string{} + for key, value := range cfg.VolumeLimit { + bucket := strings.TrimSpace(key) + amount := strings.TrimSpace(value) + if bucket == "" || amount == "" { + continue + } + limits.VolumeLimit[bucket] = amount + } + } + + if len(cfg.VelocityLimit) > 0 { + limits.VelocityLimit = map[string]int{} + for key, value := range cfg.VelocityLimit { + bucket := strings.TrimSpace(key) + if bucket == "" { + continue + } + limits.VelocityLimit[bucket] = value + } + } + + if len(cfg.CurrencyLimits) > 0 { + limits.CurrencyLimits = map[string]model.LimitsOverride{} + for key, override := range cfg.CurrencyLimits { + currency := strings.ToUpper(strings.TrimSpace(key)) + if currency == "" { + continue + } + limits.CurrencyLimits[currency] = model.LimitsOverride{ + MaxVolume: strings.TrimSpace(override.MaxVolume), + MinAmount: strings.TrimSpace(override.MinAmount), + MaxAmount: strings.TrimSpace(override.MaxAmount), + MaxFee: strings.TrimSpace(override.MaxFee), + MaxOps: override.MaxOps, + } + } + } + + return limits +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout.go deleted file mode 100644 index d418c66..0000000 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout.go +++ /dev/null @@ -1,702 +0,0 @@ -package orchestrator - -import ( - "context" - "strings" - - "github.com/shopspring/decimal" - "github.com/tech/sendico/payments/orchestrator/storage/model" - "github.com/tech/sendico/pkg/merrors" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.uber.org/zap" -) - -const ( - defaultCardGateway = "monetix" - - stepCodeGasTopUp = "gas_top_up" - stepCodeFundingTransfer = "funding_transfer" - stepCodeCardPayout = "card_payout" - stepCodeFeeTransfer = "fee_transfer" -) - -func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { - if len(s.deps.cardRoutes) == 0 { - s.logger.Warn("card routing not configured", zap.String("gateway", gateway)) - return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured") - } - key := strings.ToLower(strings.TrimSpace(gateway)) - if key == "" { - key = defaultCardGateway - } - route, ok := s.deps.cardRoutes[key] - if !ok { - s.logger.Warn("card routing missing for gateway", zap.String("gateway", key)) - return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) - } - if strings.TrimSpace(route.FundingAddress) == "" { - s.logger.Warn("card routing missing funding address", zap.String("gateway", key)) - return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) - } - return route, nil -} - -func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - intent := payment.Intent - source := intent.Source.ManagedWallet - if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { - return merrors.InvalidArgument("card funding: source managed wallet is required") - } - if !s.deps.gateway.available() { - s.logger.Warn("card funding aborted: chain gateway unavailable") - return merrors.InvalidArgument("card funding: chain gateway unavailable") - } - - route, err := s.cardRoute(defaultCardGateway) - if err != nil { - return err - } - sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef) - fundingAddress := strings.TrimSpace(route.FundingAddress) - feeWalletRef := strings.TrimSpace(route.FeeWalletRef) - - amount := cloneMoney(intent.Amount) - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: amount is required") - } - - payoutAmount, err := cardPayoutAmount(payment) - if err != nil { - return err - } - - feeMoney := (*moneyv1.Money)(nil) - if quote != nil { - feeMoney = quote.GetExpectedFeeTotal() - } - if feeMoney == nil && payment.LastQuote != nil { - feeMoney = payment.LastQuote.ExpectedFeeTotal - } - feeDecimal := decimal.Zero - if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" { - if strings.TrimSpace(feeMoney.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: fee currency is required") - } - feeDecimal, err = decimalFromMoney(feeMoney) - if err != nil { - return err - } - } - feeRequired := feeDecimal.IsPositive() - - fundingDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress}, - } - fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, amount) - if err != nil { - return err - } - - var feeTransferFee *moneyv1.Money - if feeRequired { - if feeWalletRef == "" { - return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists") - } - feeDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, - } - feeTransferFee, err = s.estimateTransferNetworkFee(ctx, sourceWalletRef, feeDest, feeMoney) - if err != nil { - return err - } - } - - totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee) - if err != nil { - return err - } - - var estimatedTotalFee *moneyv1.Money - if gasCurrency != "" && !totalFee.IsNegative() { - estimatedTotalFee = makeMoney(gasCurrency, totalFee) - } - - var topUpMoney *moneyv1.Money - var topUpFee *moneyv1.Money - topUpPositive := false - if estimatedTotalFee != nil { - computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{ - WalletRef: sourceWalletRef, - EstimatedTotalFee: estimatedTotalFee, - }) - if err != nil { - s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - if computeResp != nil { - topUpMoney = computeResp.GetTopupAmount() - } - if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" { - amountDec, err := decimalFromMoney(topUpMoney) - if err != nil { - return err - } - topUpPositive = amountDec.IsPositive() - } - if topUpMoney != nil && topUpPositive { - if strings.TrimSpace(topUpMoney.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: gas top-up currency is required") - } - if feeWalletRef == "" { - return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up") - } - topUpDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, - } - topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney) - if err != nil { - return err - } - } - } - - plan := ensureExecutionPlan(payment) - var gasStep *model.ExecutionStep - if topUpMoney != nil && topUpPositive { - gasStep = ensureExecutionStep(plan, stepCodeGasTopUp) - gasStep.Description = "Top up native gas from fee wallet" - gasStep.Amount = cloneMoney(topUpMoney) - gasStep.NetworkFee = cloneMoney(topUpFee) - gasStep.SourceWalletRef = feeWalletRef - gasStep.DestinationRef = sourceWalletRef - } - - fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) - fundStep.Description = "Transfer payout amount to card funding wallet" - fundStep.Amount = cloneMoney(amount) - fundStep.NetworkFee = cloneMoney(fundingFee) - fundStep.SourceWalletRef = sourceWalletRef - fundStep.DestinationRef = fundingAddress - - cardStep := ensureExecutionStep(plan, stepCodeCardPayout) - cardStep.Description = "Submit card payout" - cardStep.Amount = cloneMoney(payoutAmount) - if card := intent.Destination.Card; card != nil { - if masked := strings.TrimSpace(card.MaskedPan); masked != "" { - cardStep.DestinationRef = masked - } - } - - if feeRequired { - step := ensureExecutionStep(plan, stepCodeFeeTransfer) - step.Description = "Transfer fee to fee wallet" - step.Amount = cloneMoney(feeMoney) - step.NetworkFee = cloneMoney(feeTransferFee) - step.SourceWalletRef = sourceWalletRef - step.DestinationRef = feeWalletRef - } - - updateExecutionPlanTotalNetworkFee(plan) - - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - - if topUpMoney != nil && topUpPositive { - ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:gas", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: feeWalletRef, - TargetWalletRef: sourceWalletRef, - EstimatedTotalFee: estimatedTotalFee, - Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, - }) - if gasErr != nil { - s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) - return gasErr - } - if gasStep != nil { - actual := (*moneyv1.Money)(nil) - if ensureResp != nil { - actual = ensureResp.GetTopupAmount() - if transfer := ensureResp.GetTransfer(); transfer != nil { - gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef()) - } - } - actualPositive := false - if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" { - actualDec, err := decimalFromMoney(actual) - if err != nil { - return err - } - actualPositive = actualDec.IsPositive() - } - if actual != nil && actualPositive { - gasStep.Amount = cloneMoney(actual) - if strings.TrimSpace(actual.GetCurrency()) == "" { - return merrors.InvalidArgument("card funding: gas top-up currency is required") - } - topUpDest := &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, - } - topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual) - if err != nil { - return err - } - gasStep.NetworkFee = cloneMoney(topUpFee) - } else { - gasStep.Amount = nil - gasStep.NetworkFee = nil - } - } - if gasStep != nil { - s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) - } - updateExecutionPlanTotalNetworkFee(plan) - } - - // Transfer payout amount to funding wallet. - fundReq := &chainv1.SubmitTransferRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:fund", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: sourceWalletRef, - Destination: &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress}, - }, - Amount: amount, - Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, - } - fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, fundReq) - if err != nil { - s.logger.Warn("card funding transfer failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - if fundResp != nil && fundResp.GetTransfer() != nil { - exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef()) - fundStep.TransferRef = exec.ChainTransferRef - } - s.logger.Info("card funding transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef)) - - payment.Execution = exec - return nil -} - -func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error { - if payment == nil { - return merrors.InvalidArgument("payment is required") - } - intent := payment.Intent - card := intent.Destination.Card - if card == nil { - return merrors.InvalidArgument("card payout: card endpoint is required") - } - amount, err := cardPayoutAmount(payment) - if err != nil { - return err - } - amtDec, err := decimalFromMoney(amount) - if err != nil { - return err - } - minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() - - payoutID := payment.PaymentRef - currency := strings.TrimSpace(amount.GetCurrency()) - holder := strings.TrimSpace(card.Cardholder) - meta := cloneMetadata(payment.Metadata) - customer := intent.Customer - customerID := "" - customerFirstName := "" - customerMiddleName := "" - customerLastName := "" - customerIP := "" - customerZip := "" - customerCountry := "" - customerState := "" - customerCity := "" - customerAddress := "" - if customer != nil { - customerID = strings.TrimSpace(customer.ID) - customerFirstName = strings.TrimSpace(customer.FirstName) - customerMiddleName = strings.TrimSpace(customer.MiddleName) - customerLastName = strings.TrimSpace(customer.LastName) - customerIP = strings.TrimSpace(customer.IP) - customerZip = strings.TrimSpace(customer.Zip) - customerCountry = strings.TrimSpace(customer.Country) - customerState = strings.TrimSpace(customer.State) - customerCity = strings.TrimSpace(customer.City) - customerAddress = strings.TrimSpace(customer.Address) - } - if customerFirstName == "" { - customerFirstName = strings.TrimSpace(card.Cardholder) - } - if customerLastName == "" { - customerLastName = strings.TrimSpace(card.CardholderSurname) - } - if customerID == "" { - return merrors.InvalidArgument("card payout: customer id is required") - } - if customerFirstName == "" { - return merrors.InvalidArgument("card payout: customer first name is required") - } - if customerLastName == "" { - return merrors.InvalidArgument("card payout: customer last name is required") - } - if customerIP == "" { - return merrors.InvalidArgument("card payout: customer ip is required") - } - - var ( - state *mntxv1.CardPayoutState - ) - - if token := strings.TrimSpace(card.Token); token != "" { - req := &mntxv1.CardTokenPayoutRequest{ - PayoutId: payoutID, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardToken: token, - CardHolder: holder, - MaskedPan: strings.TrimSpace(card.MaskedPan), - Metadata: meta, - } - resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) - if err != nil { - s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - state = resp.GetPayout() - } else if pan := strings.TrimSpace(card.Pan); pan != "" { - req := &mntxv1.CardPayoutRequest{ - PayoutId: payoutID, - CustomerId: customerID, - CustomerFirstName: customerFirstName, - CustomerMiddleName: customerMiddleName, - CustomerLastName: customerLastName, - CustomerIp: customerIP, - CustomerZip: customerZip, - CustomerCountry: customerCountry, - CustomerState: customerState, - CustomerCity: customerCity, - CustomerAddress: customerAddress, - AmountMinor: minor, - Currency: currency, - CardPan: pan, - CardExpYear: card.ExpYear, - CardExpMonth: card.ExpMonth, - CardHolder: holder, - Metadata: meta, - } - resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) - if err != nil { - s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) - return err - } - state = resp.GetPayout() - } else { - return merrors.InvalidArgument("card payout: either token or pan must be provided") - } - - if state == nil { - return merrors.Internal("card payout: missing payout state") - } - recordCardPayoutState(payment, state) - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - if exec.CardPayoutRef == "" { - exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) - } - payment.Execution = exec - - plan := ensureExecutionPlan(payment) - if plan != nil { - step := ensureExecutionStep(plan, stepCodeCardPayout) - step.Description = "Submit card payout" - step.Amount = cloneMoney(amount) - if masked := strings.TrimSpace(card.MaskedPan); masked != "" { - step.DestinationRef = masked - } - if exec.CardPayoutRef != "" { - step.TransferRef = exec.CardPayoutRef - } - updateExecutionPlanTotalNetworkFee(plan) - } - - feeMoney := (*moneyv1.Money)(nil) - if payment.LastQuote != nil { - feeMoney = payment.LastQuote.ExpectedFeeTotal - } - if feeMoney != nil && strings.TrimSpace(feeMoney.GetAmount()) != "" { - if strings.TrimSpace(feeMoney.GetCurrency()) == "" { - return merrors.InvalidArgument("card payout: fee currency is required") - } - feeDecimal, err := decimalFromMoney(feeMoney) - if err != nil { - return err - } - if feeDecimal.IsPositive() { - if !s.deps.gateway.available() { - s.logger.Warn("card fee aborted: chain gateway unavailable") - return merrors.InvalidArgument("card payout: chain gateway unavailable") - } - sourceWallet := intent.Source.ManagedWallet - if sourceWallet == nil || strings.TrimSpace(sourceWallet.ManagedWalletRef) == "" { - return merrors.InvalidArgument("card payout: source managed wallet is required") - } - route, err := s.cardRoute(defaultCardGateway) - if err != nil { - return err - } - feeWalletRef := strings.TrimSpace(route.FeeWalletRef) - if feeWalletRef == "" { - return merrors.InvalidArgument("card payout: fee wallet ref is required when fee exists") - } - feeReq := &chainv1.SubmitTransferRequest{ - IdempotencyKey: payment.IdempotencyKey + ":card:fee", - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: strings.TrimSpace(sourceWallet.ManagedWalletRef), - Destination: &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, - }, - Amount: feeMoney, - Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, - } - feeResp, feeErr := s.deps.gateway.client.SubmitTransfer(ctx, feeReq) - if feeErr != nil { - s.logger.Warn("card fee transfer failed", zap.Error(feeErr), zap.String("payment_ref", payment.PaymentRef)) - return feeErr - } - if feeResp != nil && feeResp.GetTransfer() != nil { - exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef()) - } - s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) - - if plan != nil { - step := ensureExecutionStep(plan, stepCodeFeeTransfer) - step.Description = "Transfer fee to fee wallet" - step.Amount = cloneMoney(feeMoney) - step.SourceWalletRef = strings.TrimSpace(sourceWallet.ManagedWalletRef) - step.DestinationRef = feeWalletRef - step.TransferRef = exec.FeeTransferRef - updateExecutionPlanTotalNetworkFee(plan) - } - } - } - - s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef)) - - return nil -} - -func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) { - if payment == nil || state == nil { - return - } - if payment.CardPayout == nil { - payment.CardPayout = &model.CardPayout{} - } - payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId()) - payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId()) - payment.CardPayout.Status = state.GetStatus().String() - payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage()) - payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode()) - if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil { - payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country) - } - if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil { - payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan) - } - payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId()) -} - -func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) { - if payment == nil || payout == nil { - return - } - recordCardPayoutState(payment, payout) - - if payment.Execution == nil { - payment.Execution = &model.ExecutionRefs{} - } - if payment.Execution.CardPayoutRef == "" { - payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId()) - } - - payment.State = mapMntxStatusToState(payout.GetStatus()) - switch payout.GetStatus() { - case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: - payment.FailureCode = model.PaymentFailureCodeUnspecified - payment.FailureReason = "" - case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: - payment.FailureCode = model.PaymentFailureCodePolicy - payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) - default: - // leave as-is for pending/unspecified - } -} - -func cardPayoutAmount(payment *model.Payment) (*moneyv1.Money, error) { - if payment == nil { - return nil, merrors.InvalidArgument("payment is required") - } - amount := cloneMoney(payment.Intent.Amount) - if payment.LastQuote != nil { - settlement := payment.LastQuote.ExpectedSettlementAmount - if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" { - amount = cloneMoney(settlement) - } - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("card payout: amount is required") - } - return amount, nil -} - -func (s *Service) estimateTransferNetworkFee(ctx context.Context, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) { - if !s.deps.gateway.available() { - return nil, merrors.InvalidArgument("chain gateway unavailable") - } - sourceWalletRef = strings.TrimSpace(sourceWalletRef) - if sourceWalletRef == "" { - return nil, merrors.InvalidArgument("source wallet ref is required") - } - if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { - return nil, merrors.InvalidArgument("amount is required") - } - - resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{ - SourceWalletRef: sourceWalletRef, - Destination: destination, - Amount: cloneMoney(amount), - }) - if err != nil { - s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - if resp == nil { - s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - fee := resp.GetNetworkFee() - if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { - s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef)) - return nil, merrors.Internal("chain_gateway_fee_estimation_failed") - } - return cloneMoney(fee), nil -} - -func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) { - total := decimal.Zero - currency := "" - for _, fee := range fees { - if fee == nil { - continue - } - amount := strings.TrimSpace(fee.GetAmount()) - feeCurrency := strings.TrimSpace(fee.GetCurrency()) - if amount == "" || feeCurrency == "" { - return decimal.Zero, "", merrors.InvalidArgument("network fee is required") - } - value, err := decimalFromMoney(fee) - if err != nil { - return decimal.Zero, "", err - } - if currency == "" { - currency = feeCurrency - } else if !strings.EqualFold(currency, feeCurrency) { - return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch") - } - total = total.Add(value) - } - return total, currency, nil -} - -func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan { - if payment == nil { - return nil - } - if payment.ExecutionPlan == nil { - payment.ExecutionPlan = &model.ExecutionPlan{} - } - return payment.ExecutionPlan -} - -func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep { - if plan == nil { - return nil - } - code = strings.TrimSpace(code) - if code == "" { - return nil - } - for _, step := range plan.Steps { - if step == nil { - continue - } - if strings.EqualFold(step.Code, code) { - if step.Code == "" { - step.Code = code - } - return step - } - } - step := &model.ExecutionStep{Code: code} - plan.Steps = append(plan.Steps, step) - return step -} - -func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) { - if plan == nil { - return - } - total := decimal.Zero - currency := "" - hasFee := false - for _, step := range plan.Steps { - if step == nil || step.NetworkFee == nil { - continue - } - fee := step.NetworkFee - if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { - continue - } - if currency == "" { - currency = strings.TrimSpace(fee.GetCurrency()) - } else if !strings.EqualFold(currency, fee.GetCurrency()) { - continue - } - value, err := decimalFromMoney(fee) - if err != nil { - continue - } - total = total.Add(value) - hasFee = true - } - if !hasFee || currency == "" { - plan.TotalNetworkFee = nil - return - } - plan.TotalNetworkFee = makeMoney(currency, total) -} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go new file mode 100644 index 0000000..eff9594 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_constants.go @@ -0,0 +1,10 @@ +package orchestrator + +const ( + defaultCardGateway = "monetix" + + stepCodeGasTopUp = "gas_top_up" + stepCodeFundingTransfer = "funding_transfer" + stepCodeCardPayout = "card_payout" + stepCodeFeeTransfer = "fee_transfer" +) diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go new file mode 100644 index 0000000..83abef0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_funding.go @@ -0,0 +1,353 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.uber.org/zap" +) + +func (s *Service) submitCardFundingTransfers(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + intent := payment.Intent + source := intent.Source.ManagedWallet + if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { + return merrors.InvalidArgument("card funding: source managed wallet is required") + } + if !s.deps.gateway.available() { + s.logger.Warn("card funding aborted: chain gateway unavailable") + return merrors.InvalidArgument("card funding: chain gateway unavailable") + } + + route, err := s.cardRoute(defaultCardGateway) + if err != nil { + return err + } + sourceWalletRef := strings.TrimSpace(source.ManagedWalletRef) + fundingAddress := strings.TrimSpace(route.FundingAddress) + feeWalletRef := strings.TrimSpace(route.FeeWalletRef) + + intentAmount := cloneMoney(intent.Amount) + if intentAmount == nil || strings.TrimSpace(intentAmount.GetAmount()) == "" || strings.TrimSpace(intentAmount.GetCurrency()) == "" { + return merrors.InvalidArgument("card funding: amount is required") + } + intentAmountProto := protoMoney(intentAmount) + + payoutAmount, err := cardPayoutAmount(payment) + if err != nil { + return err + } + + var feeAmount *paymenttypes.Money + if quote != nil { + feeAmount = moneyFromProto(quote.GetExpectedFeeTotal()) + } + if feeAmount == nil && payment.LastQuote != nil { + feeAmount = cloneMoney(payment.LastQuote.ExpectedFeeTotal) + } + feeDecimal := decimal.Zero + if feeAmount != nil && strings.TrimSpace(feeAmount.GetAmount()) != "" { + if strings.TrimSpace(feeAmount.GetCurrency()) == "" { + return merrors.InvalidArgument("card funding: fee currency is required") + } + feeDecimal, err = decimalFromMoney(feeAmount) + if err != nil { + return err + } + } + feeRequired := feeDecimal.IsPositive() + feeAmountProto := protoMoney(feeAmount) + + fundingDest := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: fundingAddress}, + } + fundingFee, err := s.estimateTransferNetworkFee(ctx, sourceWalletRef, fundingDest, intentAmountProto) + if err != nil { + return err + } + + var feeTransferFee *moneyv1.Money + if feeRequired { + if feeWalletRef == "" { + return merrors.InvalidArgument("card funding: fee wallet ref is required when fee exists") + } + feeDest := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, + } + feeTransferFee, err = s.estimateTransferNetworkFee(ctx, sourceWalletRef, feeDest, feeAmountProto) + if err != nil { + return err + } + } + + totalFee, gasCurrency, err := sumNetworkFees(fundingFee, feeTransferFee) + if err != nil { + return err + } + + var estimatedTotalFee *moneyv1.Money + if gasCurrency != "" && !totalFee.IsNegative() { + estimatedTotalFee = makeMoney(gasCurrency, totalFee) + } + + var topUpMoney *moneyv1.Money + var topUpFee *moneyv1.Money + topUpPositive := false + if estimatedTotalFee != nil { + computeResp, err := s.deps.gateway.client.ComputeGasTopUp(ctx, &chainv1.ComputeGasTopUpRequest{ + WalletRef: sourceWalletRef, + EstimatedTotalFee: estimatedTotalFee, + }) + if err != nil { + s.logger.Warn("card funding gas top-up compute failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + return err + } + if computeResp != nil { + topUpMoney = computeResp.GetTopupAmount() + } + if topUpMoney != nil && strings.TrimSpace(topUpMoney.GetAmount()) != "" { + amountDec, err := decimalFromMoney(topUpMoney) + if err != nil { + return err + } + topUpPositive = amountDec.IsPositive() + } + if topUpMoney != nil && topUpPositive { + if strings.TrimSpace(topUpMoney.GetCurrency()) == "" { + return merrors.InvalidArgument("card funding: gas top-up currency is required") + } + if feeWalletRef == "" { + return merrors.InvalidArgument("card funding: fee wallet ref is required for gas top-up") + } + topUpDest := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, + } + topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, topUpMoney) + if err != nil { + return err + } + } + } + + plan := ensureExecutionPlan(payment) + var gasStep *model.ExecutionStep + var feeStep *model.ExecutionStep + if topUpMoney != nil && topUpPositive { + gasStep = ensureExecutionStep(plan, stepCodeGasTopUp) + setExecutionStepRole(gasStep, executionStepRoleSource) + setExecutionStepStatus(gasStep, executionStepStatusPlanned) + gasStep.Description = "Top up native gas from fee wallet" + gasStep.Amount = moneyFromProto(topUpMoney) + gasStep.NetworkFee = moneyFromProto(topUpFee) + gasStep.SourceWalletRef = feeWalletRef + gasStep.DestinationRef = sourceWalletRef + } + + fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) + setExecutionStepRole(fundStep, executionStepRoleSource) + setExecutionStepStatus(fundStep, executionStepStatusPlanned) + fundStep.Description = "Transfer payout amount to card funding wallet" + fundStep.Amount = cloneMoney(intentAmount) + fundStep.NetworkFee = moneyFromProto(fundingFee) + fundStep.SourceWalletRef = sourceWalletRef + fundStep.DestinationRef = fundingAddress + + if feeRequired { + feeStep = ensureExecutionStep(plan, stepCodeFeeTransfer) + setExecutionStepRole(feeStep, executionStepRoleSource) + setExecutionStepStatus(feeStep, executionStepStatusPlanned) + feeStep.Description = "Transfer fee to fee wallet" + feeStep.Amount = cloneMoney(feeAmount) + feeStep.NetworkFee = moneyFromProto(feeTransferFee) + feeStep.SourceWalletRef = sourceWalletRef + feeStep.DestinationRef = feeWalletRef + } + + cardStep := ensureExecutionStep(plan, stepCodeCardPayout) + setExecutionStepRole(cardStep, executionStepRoleConsumer) + setExecutionStepStatus(cardStep, executionStepStatusPlanned) + cardStep.Description = "Submit card payout" + cardStep.Amount = cloneMoney(payoutAmount) + if card := intent.Destination.Card; card != nil { + if masked := strings.TrimSpace(card.MaskedPan); masked != "" { + cardStep.DestinationRef = masked + } + } + + updateExecutionPlanTotalNetworkFee(plan) + + exec := payment.Execution + if exec == nil { + exec = &model.ExecutionRefs{} + } + + if topUpMoney != nil && topUpPositive { + ensureResp, gasErr := s.deps.gateway.client.EnsureGasTopUp(ctx, &chainv1.EnsureGasTopUpRequest{ + IdempotencyKey: payment.IdempotencyKey + ":card:gas", + OrganizationRef: payment.OrganizationRef.Hex(), + SourceWalletRef: feeWalletRef, + TargetWalletRef: sourceWalletRef, + EstimatedTotalFee: estimatedTotalFee, + Metadata: cloneMetadata(payment.Metadata), + ClientReference: payment.PaymentRef, + }) + if gasErr != nil { + s.logger.Warn("card gas top-up transfer failed", zap.Error(gasErr), zap.String("payment_ref", payment.PaymentRef)) + return gasErr + } + if gasStep != nil { + actual := (*moneyv1.Money)(nil) + if ensureResp != nil { + actual = ensureResp.GetTopupAmount() + if transfer := ensureResp.GetTransfer(); transfer != nil { + gasStep.TransferRef = strings.TrimSpace(transfer.GetTransferRef()) + } + } + actualPositive := false + if actual != nil && strings.TrimSpace(actual.GetAmount()) != "" { + actualDec, err := decimalFromMoney(actual) + if err != nil { + return err + } + actualPositive = actualDec.IsPositive() + } + if actual != nil && actualPositive { + gasStep.Amount = moneyFromProto(actual) + if strings.TrimSpace(actual.GetCurrency()) == "" { + return merrors.InvalidArgument("card funding: gas top-up currency is required") + } + topUpDest := &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: sourceWalletRef}, + } + topUpFee, err = s.estimateTransferNetworkFee(ctx, feeWalletRef, topUpDest, actual) + if err != nil { + return err + } + gasStep.NetworkFee = moneyFromProto(topUpFee) + setExecutionStepStatus(gasStep, executionStepStatusSubmitted) + } else { + gasStep.Amount = nil + gasStep.NetworkFee = nil + gasStep.TransferRef = "" + setExecutionStepStatus(gasStep, executionStepStatusSkipped) + } + } + if gasStep != nil { + s.logger.Info("card gas top-up transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", gasStep.TransferRef)) + } + updateExecutionPlanTotalNetworkFee(plan) + } + + fundResp, err := s.deps.gateway.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: payment.IdempotencyKey + ":card:fund", + OrganizationRef: payment.OrganizationRef.Hex(), + SourceWalletRef: sourceWalletRef, + Destination: fundingDest, + Amount: cloneProtoMoney(intentAmountProto), + Metadata: cloneMetadata(payment.Metadata), + ClientReference: payment.PaymentRef, + }) + if err != nil { + return err + } + if fundResp != nil && fundResp.GetTransfer() != nil { + exec.ChainTransferRef = strings.TrimSpace(fundResp.GetTransfer().GetTransferRef()) + fundStep.TransferRef = exec.ChainTransferRef + } + setExecutionStepStatus(fundStep, executionStepStatusSubmitted) + updateExecutionPlanTotalNetworkFee(plan) + + if feeRequired { + feeResp, err := s.deps.gateway.client.SubmitTransfer(ctx, &chainv1.SubmitTransferRequest{ + IdempotencyKey: payment.IdempotencyKey + ":card:fee", + OrganizationRef: payment.OrganizationRef.Hex(), + SourceWalletRef: sourceWalletRef, + Destination: &chainv1.TransferDestination{ + Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: feeWalletRef}, + }, + Amount: cloneProtoMoney(feeAmountProto), + Metadata: cloneMetadata(payment.Metadata), + ClientReference: payment.PaymentRef, + }) + if err != nil { + return err + } + if feeResp != nil && feeResp.GetTransfer() != nil { + exec.FeeTransferRef = strings.TrimSpace(feeResp.GetTransfer().GetTransferRef()) + feeStep.TransferRef = exec.FeeTransferRef + } + setExecutionStepStatus(feeStep, executionStepStatusSubmitted) + s.logger.Info("card fee transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.FeeTransferRef)) + } + + payment.Execution = exec + return nil +} + +func (s *Service) estimateTransferNetworkFee(ctx context.Context, sourceWalletRef string, destination *chainv1.TransferDestination, amount *moneyv1.Money) (*moneyv1.Money, error) { + if !s.deps.gateway.available() { + return nil, merrors.InvalidArgument("chain gateway unavailable") + } + sourceWalletRef = strings.TrimSpace(sourceWalletRef) + if sourceWalletRef == "" { + return nil, merrors.InvalidArgument("source wallet ref is required") + } + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("amount is required") + } + + resp, err := s.deps.gateway.client.EstimateTransferFee(ctx, &chainv1.EstimateTransferFeeRequest{ + SourceWalletRef: sourceWalletRef, + Destination: destination, + Amount: cloneProtoMoney(amount), + }) + if err != nil { + s.logger.Warn("chain gateway fee estimation failed", zap.Error(err), zap.String("source_wallet_ref", sourceWalletRef)) + return nil, merrors.Internal("chain_gateway_fee_estimation_failed") + } + if resp == nil { + s.logger.Warn("chain gateway fee estimation returned empty response", zap.String("source_wallet_ref", sourceWalletRef)) + return nil, merrors.Internal("chain_gateway_fee_estimation_failed") + } + fee := resp.GetNetworkFee() + if fee == nil || strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { + s.logger.Warn("chain gateway fee estimation missing network fee", zap.String("source_wallet_ref", sourceWalletRef)) + return nil, merrors.Internal("chain_gateway_fee_estimation_failed") + } + return cloneProtoMoney(fee), nil +} + +func sumNetworkFees(fees ...*moneyv1.Money) (decimal.Decimal, string, error) { + total := decimal.Zero + currency := "" + for _, fee := range fees { + if fee == nil { + continue + } + amount := strings.TrimSpace(fee.GetAmount()) + feeCurrency := strings.TrimSpace(fee.GetCurrency()) + if amount == "" || feeCurrency == "" { + return decimal.Zero, "", merrors.InvalidArgument("network fee is required") + } + value, err := decimalFromMoney(fee) + if err != nil { + return decimal.Zero, "", err + } + if currency == "" { + currency = feeCurrency + } else if !strings.EqualFold(currency, feeCurrency) { + return decimal.Zero, "", merrors.InvalidArgument("network fee currency mismatch") + } + total = total.Add(value) + } + return total, currency, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go new file mode 100644 index 0000000..0ddcbda --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_helpers.go @@ -0,0 +1,80 @@ +package orchestrator + +import ( + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/orchestrator/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func ensureExecutionPlan(payment *model.Payment) *model.ExecutionPlan { + if payment == nil { + return nil + } + if payment.ExecutionPlan == nil { + payment.ExecutionPlan = &model.ExecutionPlan{} + } + return payment.ExecutionPlan +} + +func ensureExecutionStep(plan *model.ExecutionPlan, code string) *model.ExecutionStep { + if plan == nil { + return nil + } + code = strings.TrimSpace(code) + if code == "" { + return nil + } + for _, step := range plan.Steps { + if step == nil { + continue + } + if strings.EqualFold(step.Code, code) { + if step.Code == "" { + step.Code = code + } + return step + } + } + step := &model.ExecutionStep{Code: code} + plan.Steps = append(plan.Steps, step) + return step +} + +func updateExecutionPlanTotalNetworkFee(plan *model.ExecutionPlan) { + if plan == nil { + return + } + total := decimal.Zero + currency := "" + hasFee := false + for _, step := range plan.Steps { + if step == nil || step.NetworkFee == nil { + continue + } + fee := step.NetworkFee + if strings.TrimSpace(fee.GetAmount()) == "" || strings.TrimSpace(fee.GetCurrency()) == "" { + continue + } + if currency == "" { + currency = strings.TrimSpace(fee.GetCurrency()) + } else if !strings.EqualFold(currency, fee.GetCurrency()) { + continue + } + value, err := decimalFromMoney(fee) + if err != nil { + continue + } + total = total.Add(value) + hasFee = true + } + if !hasFee || currency == "" { + plan.TotalNetworkFee = nil + return + } + plan.TotalNetworkFee = &paymenttypes.Money{ + Currency: currency, + Amount: total.String(), + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go new file mode 100644 index 0000000..621e693 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_routes.go @@ -0,0 +1,29 @@ +package orchestrator + +import ( + "strings" + + "github.com/tech/sendico/pkg/merrors" + "go.uber.org/zap" +) + +func (s *Service) cardRoute(gateway string) (CardGatewayRoute, error) { + if len(s.deps.cardRoutes) == 0 { + s.logger.Warn("card routing not configured", zap.String("gateway", gateway)) + return CardGatewayRoute{}, merrors.InvalidArgument("card routing not configured") + } + key := strings.ToLower(strings.TrimSpace(gateway)) + if key == "" { + key = defaultCardGateway + } + route, ok := s.deps.cardRoutes[key] + if !ok { + s.logger.Warn("card routing missing for gateway", zap.String("gateway", key)) + return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) + } + if strings.TrimSpace(route.FundingAddress) == "" { + s.logger.Warn("card routing missing funding address", zap.String("gateway", key)) + return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) + } + return route, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go new file mode 100644 index 0000000..b3b10e0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_submit.go @@ -0,0 +1,262 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" +) + +func (s *Service) submitCardPayout(ctx context.Context, payment *model.Payment) error { + if payment == nil { + return merrors.InvalidArgument("payment is required") + } + if payment.Execution != nil && strings.TrimSpace(payment.Execution.CardPayoutRef) != "" { + return nil + } + intent := payment.Intent + card := intent.Destination.Card + if card == nil { + return merrors.InvalidArgument("card payout: card endpoint is required") + } + amount, err := cardPayoutAmount(payment) + if err != nil { + return err + } + amtDec, err := decimalFromMoney(amount) + if err != nil { + return err + } + minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() + + payoutID := payment.PaymentRef + currency := strings.TrimSpace(amount.GetCurrency()) + holder := strings.TrimSpace(card.Cardholder) + meta := cloneMetadata(payment.Metadata) + customer := intent.Customer + customerID := "" + customerFirstName := "" + customerMiddleName := "" + customerLastName := "" + customerIP := "" + customerZip := "" + customerCountry := "" + customerState := "" + customerCity := "" + customerAddress := "" + if customer != nil { + customerID = strings.TrimSpace(customer.ID) + customerFirstName = strings.TrimSpace(customer.FirstName) + customerMiddleName = strings.TrimSpace(customer.MiddleName) + customerLastName = strings.TrimSpace(customer.LastName) + customerIP = strings.TrimSpace(customer.IP) + customerZip = strings.TrimSpace(customer.Zip) + customerCountry = strings.TrimSpace(customer.Country) + customerState = strings.TrimSpace(customer.State) + customerCity = strings.TrimSpace(customer.City) + customerAddress = strings.TrimSpace(customer.Address) + } + if customerFirstName == "" { + customerFirstName = strings.TrimSpace(card.Cardholder) + } + if customerLastName == "" { + customerLastName = strings.TrimSpace(card.CardholderSurname) + } + if customerID == "" { + return merrors.InvalidArgument("card payout: customer id is required") + } + if customerFirstName == "" { + return merrors.InvalidArgument("card payout: customer first name is required") + } + if customerLastName == "" { + return merrors.InvalidArgument("card payout: customer last name is required") + } + if customerIP == "" { + return merrors.InvalidArgument("card payout: customer ip is required") + } + + var ( + state *mntxv1.CardPayoutState + ) + + if token := strings.TrimSpace(card.Token); token != "" { + req := &mntxv1.CardTokenPayoutRequest{ + PayoutId: payoutID, + CustomerId: customerID, + CustomerFirstName: customerFirstName, + CustomerMiddleName: customerMiddleName, + CustomerLastName: customerLastName, + CustomerIp: customerIP, + CustomerZip: customerZip, + CustomerCountry: customerCountry, + CustomerState: customerState, + CustomerCity: customerCity, + CustomerAddress: customerAddress, + AmountMinor: minor, + Currency: currency, + CardToken: token, + CardHolder: holder, + MaskedPan: strings.TrimSpace(card.MaskedPan), + Metadata: meta, + } + resp, err := s.deps.mntx.client.CreateCardTokenPayout(ctx, req) + if err != nil { + s.logger.Warn("card token payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + return err + } + state = resp.GetPayout() + } else if pan := strings.TrimSpace(card.Pan); pan != "" { + req := &mntxv1.CardPayoutRequest{ + PayoutId: payoutID, + CustomerId: customerID, + CustomerFirstName: customerFirstName, + CustomerMiddleName: customerMiddleName, + CustomerLastName: customerLastName, + CustomerIp: customerIP, + CustomerZip: customerZip, + CustomerCountry: customerCountry, + CustomerState: customerState, + CustomerCity: customerCity, + CustomerAddress: customerAddress, + AmountMinor: minor, + Currency: currency, + CardPan: pan, + CardExpYear: card.ExpYear, + CardExpMonth: card.ExpMonth, + CardHolder: holder, + Metadata: meta, + } + resp, err := s.deps.mntx.client.CreateCardPayout(ctx, req) + if err != nil { + s.logger.Warn("card payout failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + return err + } + state = resp.GetPayout() + } else { + return merrors.InvalidArgument("card payout: either token or pan must be provided") + } + + if state == nil { + return merrors.Internal("card payout: missing payout state") + } + recordCardPayoutState(payment, state) + exec := payment.Execution + if exec == nil { + exec = &model.ExecutionRefs{} + } + if exec.CardPayoutRef == "" { + exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) + } + payment.Execution = exec + + plan := ensureExecutionPlan(payment) + if plan != nil { + step := ensureExecutionStep(plan, stepCodeCardPayout) + setExecutionStepRole(step, executionStepRoleConsumer) + step.Description = "Submit card payout" + step.Amount = cloneMoney(amount) + if masked := strings.TrimSpace(card.MaskedPan); masked != "" { + step.DestinationRef = masked + } + if exec.CardPayoutRef != "" { + step.TransferRef = exec.CardPayoutRef + } + setExecutionStepStatus(step, executionStepStatusSubmitted) + updateExecutionPlanTotalNetworkFee(plan) + } + + s.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("payout_id", exec.CardPayoutRef)) + + return nil +} + +func recordCardPayoutState(payment *model.Payment, state *mntxv1.CardPayoutState) { + if payment == nil || state == nil { + return + } + if payment.CardPayout == nil { + payment.CardPayout = &model.CardPayout{} + } + payment.CardPayout.PayoutRef = strings.TrimSpace(state.GetPayoutId()) + payment.CardPayout.ProviderPaymentID = strings.TrimSpace(state.GetProviderPaymentId()) + payment.CardPayout.Status = state.GetStatus().String() + payment.CardPayout.FailureReason = strings.TrimSpace(state.GetProviderMessage()) + payment.CardPayout.ProviderCode = strings.TrimSpace(state.GetProviderCode()) + if payment.CardPayout.CardCountry == "" && payment.Intent.Destination.Card != nil { + payment.CardPayout.CardCountry = strings.TrimSpace(payment.Intent.Destination.Card.Country) + } + if payment.CardPayout.MaskedPan == "" && payment.Intent.Destination.Card != nil { + payment.CardPayout.MaskedPan = strings.TrimSpace(payment.Intent.Destination.Card.MaskedPan) + } + payment.CardPayout.GatewayReference = strings.TrimSpace(state.GetPayoutId()) +} + +func applyCardPayoutUpdate(payment *model.Payment, payout *mntxv1.CardPayoutState) { + if payment == nil || payout == nil { + return + } + recordCardPayoutState(payment, payout) + + if payment.Execution == nil { + payment.Execution = &model.ExecutionRefs{} + } + if payment.Execution.CardPayoutRef == "" { + payment.Execution.CardPayoutRef = strings.TrimSpace(payout.GetPayoutId()) + } + + plan := ensureExecutionPlan(payment) + if plan != nil { + step := findExecutionStepByTransferRef(plan, strings.TrimSpace(payout.GetPayoutId())) + if step == nil { + step = ensureExecutionStep(plan, stepCodeCardPayout) + setExecutionStepRole(step, executionStepRoleConsumer) + if step.TransferRef == "" { + step.TransferRef = payment.Execution.CardPayoutRef + } + } + switch payout.GetStatus() { + case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: + setExecutionStepStatus(step, executionStepStatusConfirmed) + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + setExecutionStepStatus(step, executionStepStatusFailed) + case mntxv1.PayoutStatus_PAYOUT_STATUS_PENDING: + setExecutionStepStatus(step, executionStepStatusSubmitted) + default: + setExecutionStepStatus(step, executionStepStatusPlanned) + } + } + + payment.State = mapMntxStatusToState(payout.GetStatus()) + switch payout.GetStatus() { + case mntxv1.PayoutStatus_PAYOUT_STATUS_PROCESSED: + payment.FailureCode = model.PaymentFailureCodeUnspecified + payment.FailureReason = "" + case mntxv1.PayoutStatus_PAYOUT_STATUS_FAILED: + payment.FailureCode = model.PaymentFailureCodePolicy + payment.FailureReason = strings.TrimSpace(payout.GetProviderMessage()) + default: + // leave as-is for pending/unspecified + } +} + +func cardPayoutAmount(payment *model.Payment) (*paymenttypes.Money, error) { + if payment == nil { + return nil, merrors.InvalidArgument("payment is required") + } + amount := cloneMoney(payment.Intent.Amount) + if payment.LastQuote != nil { + settlement := payment.LastQuote.ExpectedSettlementAmount + if settlement != nil && strings.TrimSpace(settlement.GetAmount()) != "" && strings.TrimSpace(settlement.GetCurrency()) != "" { + amount = cloneMoney(settlement) + } + } + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("card payout: amount is required") + } + return amount, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go index e5b807d..6cb4d2b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/card_payout_test.go @@ -9,6 +9,7 @@ import ( mntxclient "github.com/tech/sendico/gateway/mntx/client" "github.com/tech/sendico/payments/orchestrator/storage/model" mo "github.com/tech/sendico/pkg/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" @@ -101,7 +102,7 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { MaskedPan: "4111", }, }, - Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, }, } @@ -122,8 +123,8 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { if len(ensureCalls) != 1 { t.Fatalf("expected 1 gas top-up ensure call, got %d", len(ensureCalls)) } - if len(submitCalls) != 1 { - t.Fatalf("expected 1 transfer submission, got %d", len(submitCalls)) + if len(submitCalls) != 2 { + t.Fatalf("expected 2 transfer submissions, got %d", len(submitCalls)) } computeCall := computeCalls[0] @@ -153,7 +154,15 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { t.Fatalf("funding amount mismatch: %s %s", fundCall.GetAmount().GetCurrency(), fundCall.GetAmount().GetAmount()) } - if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" { + feeCall := findSubmitCall(t, submitCalls, "pay-1:card:fee") + if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef { + t.Fatalf("fee destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef()) + } + if feeCall.GetAmount().GetCurrency() != "USDT" || feeCall.GetAmount().GetAmount() != "0.35" { + t.Fatalf("fee amount mismatch: %s %s", feeCall.GetAmount().GetCurrency(), feeCall.GetAmount().GetAmount()) + } + + if payment.Execution == nil || payment.Execution.ChainTransferRef != "pay-1:card:fund" || payment.Execution.FeeTransferRef != "pay-1:card:fee" { t.Fatalf("expected funding transfer ref recorded, got %v", payment.Execution) } @@ -192,8 +201,8 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { if feeStep.NetworkFee.GetAmount() != "0.02" || feeStep.NetworkFee.GetCurrency() != "ETH" { t.Fatalf("fee step network fee mismatch: %s %s", feeStep.NetworkFee.GetCurrency(), feeStep.NetworkFee.GetAmount()) } - if feeStep.TransferRef != "" { - t.Fatalf("expected fee step transfer ref to be empty before payout, got %s", feeStep.TransferRef) + if feeStep.TransferRef != "pay-1:card:fee" { + t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef) } if plan.TotalNetworkFee == nil || plan.TotalNetworkFee.GetAmount() != "0.035" || plan.TotalNetworkFee.GetCurrency() != "ETH" { @@ -201,13 +210,10 @@ func TestSubmitCardFundingTransfers_PlansTopUpAndFunding(t *testing.T) { } } -func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) { +func TestSubmitCardPayout_UsesSettlementAmount(t *testing.T) { ctx := context.Background() - const ( - sourceWalletRef = "wallet-src" - feeWalletRef = "wallet-fee" - ) + const sourceWalletRef = "wallet-src" var payoutReq *mntxv1.CardPayoutRequest var submitCalls []*chainv1.SubmitTransferRequest @@ -237,12 +243,6 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) { deps: serviceDependencies{ gateway: gatewayDependency{client: gateway}, mntx: mntxDependency{client: mntx}, - cardRoutes: map[string]CardGatewayRoute{ - defaultCardGateway: { - FundingAddress: "0xfunding", - FeeWalletRef: feeWalletRef, - }, - }, }, } @@ -265,7 +265,7 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) { Cardholder: "Stephan", }, }, - Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, Customer: &model.Customer{ ID: "recipient-1", FirstName: "Stephan", @@ -274,8 +274,8 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) { }, }, LastQuote: &model.PaymentQuoteSnapshot{ - ExpectedSettlementAmount: &moneyv1.Money{Currency: "RUB", Amount: "392.30"}, - ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "0.35"}, + ExpectedSettlementAmount: &paymenttypes.Money{Currency: "RUB", Amount: "392.30"}, + ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "0.35"}, }, } @@ -293,19 +293,9 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) { if payment.Execution == nil || payment.Execution.CardPayoutRef != "payout-1" { t.Fatalf("expected card payout ref recorded, got %v", payment.Execution) } - if payment.Execution.FeeTransferRef != "fee-transfer" { - t.Fatalf("expected fee transfer ref recorded, got %v", payment.Execution) - } - if len(submitCalls) != 1 { - t.Fatalf("expected 1 fee transfer submission, got %d", len(submitCalls)) - } - feeCall := submitCalls[0] - if feeCall.GetSourceWalletRef() != sourceWalletRef { - t.Fatalf("fee transfer source mismatch: %s", feeCall.GetSourceWalletRef()) - } - if feeCall.GetDestination().GetManagedWalletRef() != feeWalletRef { - t.Fatalf("fee transfer destination mismatch: %s", feeCall.GetDestination().GetManagedWalletRef()) + if len(submitCalls) != 0 { + t.Fatalf("expected 0 fee transfer submissions, got %d", len(submitCalls)) } plan := payment.ExecutionPlan @@ -320,13 +310,6 @@ func TestSubmitCardPayout_UsesSettlementAmountAndTransfersFee(t *testing.T) { t.Fatalf("card step amount mismatch: %s %s", cardStep.Amount.GetCurrency(), cardStep.Amount.GetAmount()) } - feeStep := findExecutionStep(t, plan, stepCodeFeeTransfer) - if feeStep.TransferRef != "fee-transfer" { - t.Fatalf("fee step transfer ref mismatch: %s", feeStep.TransferRef) - } - if feeStep.Amount.GetAmount() != "0.35" || feeStep.Amount.GetCurrency() != "USDT" { - t.Fatalf("fee step amount mismatch: %s %s", feeStep.Amount.GetCurrency(), feeStep.Amount.GetAmount()) - } } func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) { @@ -370,7 +353,7 @@ func TestSubmitCardFundingTransfers_RequiresFeeWalletRef(t *testing.T) { MaskedPan: "4111", }, }, - Amount: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, }, } diff --git a/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go new file mode 100644 index 0000000..319a094 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/composite_gateway_registry.go @@ -0,0 +1,64 @@ +package orchestrator + +import ( + "context" + "sort" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type compositeGatewayRegistry struct { + logger mlogger.Logger + registries []GatewayRegistry +} + +func NewCompositeGatewayRegistry(logger mlogger.Logger, registries ...GatewayRegistry) GatewayRegistry { + items := make([]GatewayRegistry, 0, len(registries)) + for _, registry := range registries { + if registry != nil { + items = append(items, registry) + } + } + if len(items) == 0 { + return nil + } + if logger != nil { + logger = logger.Named("gateway_registry") + } + return &compositeGatewayRegistry{ + logger: logger, + registries: items, + } +} + +func (r *compositeGatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if r == nil || len(r.registries) == 0 { + return nil, nil + } + items := map[string]*model.GatewayInstanceDescriptor{} + for _, registry := range r.registries { + list, err := registry.List(ctx) + if err != nil { + if r.logger != nil { + r.logger.Warn("Failed to list gateway registry", zap.Error(err)) + } + continue + } + for _, entry := range list { + if entry == nil || entry.ID == "" { + continue + } + items[entry.ID] = entry + } + } + result := make([]*model.GatewayInstanceDescriptor, 0, len(items)) + for _, entry := range items { + result = append(result, entry) + } + sort.Slice(result, func(i, j int) bool { + return result[i].ID < result[j].ID + }) + return result, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert.go b/api/payments/orchestrator/internal/service/orchestrator/convert.go index 7afd0ae..66bc036 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/convert.go +++ b/api/payments/orchestrator/internal/service/orchestrator/convert.go @@ -5,11 +5,15 @@ import ( "time" "github.com/tech/sendico/payments/orchestrator/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "google.golang.org/protobuf/proto" "google.golang.org/protobuf/types/known/timestamppb" ) @@ -21,10 +25,10 @@ func intentFromProto(src *orchestratorv1.PaymentIntent) model.PaymentIntent { Kind: modelKindFromProto(src.GetKind()), Source: endpointFromProto(src.GetSource()), Destination: endpointFromProto(src.GetDestination()), - Amount: cloneMoney(src.GetAmount()), + Amount: moneyFromProto(src.GetAmount()), RequiresFX: src.GetRequiresFx(), - FeePolicy: src.GetFeePolicy(), - SettlementMode: src.GetSettlementMode(), + FeePolicy: feePolicyFromProto(src.GetFeePolicy()), + SettlementMode: settlementModeFromProto(src.GetSettlementMode()), Attributes: cloneMetadata(src.GetAttributes()), Customer: customerFromProto(src.GetCustomer()), } @@ -54,14 +58,14 @@ func endpointFromProto(src *orchestratorv1.PaymentEndpoint) model.PaymentEndpoin result.Type = model.EndpointTypeManagedWallet result.ManagedWallet = &model.ManagedWalletEndpoint{ ManagedWalletRef: strings.TrimSpace(managed.GetManagedWalletRef()), - Asset: cloneAsset(managed.GetAsset()), + Asset: assetFromProto(managed.GetAsset()), } return result } if external := src.GetExternalChain(); external != nil { result.Type = model.EndpointTypeExternalChain result.ExternalChain = &model.ExternalChainEndpoint{ - Asset: cloneAsset(external.GetAsset()), + Asset: assetFromProto(external.GetAsset()), Address: strings.TrimSpace(external.GetAddress()), Memo: strings.TrimSpace(external.GetMemo()), } @@ -89,8 +93,8 @@ func fxIntentFromProto(src *orchestratorv1.FXIntent) *model.FXIntent { return nil } return &model.FXIntent{ - Pair: clonePair(src.GetPair()), - Side: src.GetSide(), + Pair: pairFromProto(src.GetPair()), + Side: fxSideFromProto(src.GetSide()), Firm: src.GetFirm(), TTLMillis: src.GetTtlMs(), PreferredProvider: strings.TrimSpace(src.GetPreferredProvider()), @@ -103,13 +107,13 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS return nil } return &model.PaymentQuoteSnapshot{ - DebitAmount: cloneMoney(src.GetDebitAmount()), - ExpectedSettlementAmount: cloneMoney(src.GetExpectedSettlementAmount()), - ExpectedFeeTotal: cloneMoney(src.GetExpectedFeeTotal()), - FeeLines: cloneFeeLines(src.GetFeeLines()), - FeeRules: cloneFeeRules(src.GetFeeRules()), - FXQuote: cloneFXQuote(src.GetFxQuote()), - NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()), + DebitAmount: moneyFromProto(src.GetDebitAmount()), + ExpectedSettlementAmount: moneyFromProto(src.GetExpectedSettlementAmount()), + ExpectedFeeTotal: moneyFromProto(src.GetExpectedFeeTotal()), + FeeLines: feeLinesFromProto(src.GetFeeLines()), + FeeRules: feeRulesFromProto(src.GetFeeRules()), + FXQuote: fxQuoteFromProto(src.GetFxQuote()), + NetworkFee: networkFeeFromProto(src.GetNetworkFee()), QuoteRef: strings.TrimSpace(src.GetQuoteRef()), } } @@ -128,6 +132,7 @@ func toProtoPayment(src *model.Payment) *orchestratorv1.Payment { LastQuote: modelQuoteToProto(src.LastQuote), Execution: protoExecutionFromModel(src.Execution), ExecutionPlan: protoExecutionPlanFromModel(src.ExecutionPlan), + PaymentPlan: protoPaymentPlanFromModel(src.PaymentPlan), Metadata: cloneMetadata(src.Metadata), } if src.CardPayout != nil { @@ -158,10 +163,10 @@ func protoIntentFromModel(src model.PaymentIntent) *orchestratorv1.PaymentIntent Kind: protoKindFromModel(src.Kind), Source: protoEndpointFromModel(src.Source), Destination: protoEndpointFromModel(src.Destination), - Amount: cloneMoney(src.Amount), + Amount: protoMoney(src.Amount), RequiresFx: src.RequiresFX, - FeePolicy: src.FeePolicy, - SettlementMode: src.SettlementMode, + FeePolicy: feePolicyToProto(src.FeePolicy), + SettlementMode: settlementModeToProto(src.SettlementMode), Attributes: cloneMetadata(src.Attributes), Customer: protoCustomerFromModel(src.Customer), } @@ -226,7 +231,7 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ManagedWallet{ ManagedWallet: &orchestratorv1.ManagedWalletEndpoint{ ManagedWalletRef: src.ManagedWallet.ManagedWalletRef, - Asset: cloneAsset(src.ManagedWallet.Asset), + Asset: assetToProto(src.ManagedWallet.Asset), }, } } @@ -234,7 +239,7 @@ func protoEndpointFromModel(src model.PaymentEndpoint) *orchestratorv1.PaymentEn if src.ExternalChain != nil { endpoint.Endpoint = &orchestratorv1.PaymentEndpoint_ExternalChain{ ExternalChain: &orchestratorv1.ExternalChainEndpoint{ - Asset: cloneAsset(src.ExternalChain.Asset), + Asset: assetToProto(src.ExternalChain.Asset), Address: src.ExternalChain.Address, Memo: src.ExternalChain.Memo, }, @@ -269,8 +274,8 @@ func protoFXIntentFromModel(src *model.FXIntent) *orchestratorv1.FXIntent { return nil } return &orchestratorv1.FXIntent{ - Pair: clonePair(src.Pair), - Side: src.Side, + Pair: pairToProto(src.Pair), + Side: fxSideToProto(src.Side), Firm: src.Firm, TtlMs: src.TTLMillis, PreferredProvider: src.PreferredProvider, @@ -299,8 +304,8 @@ func protoExecutionStepFromModel(src *model.ExecutionStep) *orchestratorv1.Execu return &orchestratorv1.ExecutionStep{ Code: src.Code, Description: src.Description, - Amount: cloneMoney(src.Amount), - NetworkFee: cloneMoney(src.NetworkFee), + Amount: protoMoney(src.Amount), + NetworkFee: protoMoney(src.NetworkFee), SourceWalletRef: src.SourceWalletRef, DestinationRef: src.DestinationRef, TransferRef: src.TransferRef, @@ -323,22 +328,59 @@ func protoExecutionPlanFromModel(src *model.ExecutionPlan) *orchestratorv1.Execu } return &orchestratorv1.ExecutionPlan{ Steps: steps, - TotalNetworkFee: cloneMoney(src.TotalNetworkFee), + TotalNetworkFee: protoMoney(src.TotalNetworkFee), } } +func protoPaymentStepFromModel(src *model.PaymentStep) *orchestratorv1.PaymentStep { + if src == nil { + return nil + } + return &orchestratorv1.PaymentStep{ + Rail: protoRailFromModel(src.Rail), + GatewayId: strings.TrimSpace(src.GatewayID), + Action: protoRailOperationFromModel(src.Action), + Amount: protoMoney(src.Amount), + Ref: strings.TrimSpace(src.Ref), + } +} + +func protoPaymentPlanFromModel(src *model.PaymentPlan) *orchestratorv1.PaymentPlan { + if src == nil { + return nil + } + steps := make([]*orchestratorv1.PaymentStep, 0, len(src.Steps)) + for _, step := range src.Steps { + if protoStep := protoPaymentStepFromModel(step); protoStep != nil { + steps = append(steps, protoStep) + } + } + if len(steps) == 0 { + steps = nil + } + plan := &orchestratorv1.PaymentPlan{ + Id: strings.TrimSpace(src.ID), + Steps: steps, + IdempotencyKey: strings.TrimSpace(src.IdempotencyKey), + } + if !src.CreatedAt.IsZero() { + plan.CreatedAt = timestamppb.New(src.CreatedAt.UTC()) + } + return plan +} + func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQuote { if src == nil { return nil } return &orchestratorv1.PaymentQuote{ - DebitAmount: cloneMoney(src.DebitAmount), - ExpectedSettlementAmount: cloneMoney(src.ExpectedSettlementAmount), - ExpectedFeeTotal: cloneMoney(src.ExpectedFeeTotal), - FeeLines: cloneFeeLines(src.FeeLines), - FeeRules: cloneFeeRules(src.FeeRules), - FxQuote: cloneFXQuote(src.FXQuote), - NetworkFee: cloneNetworkEstimate(src.NetworkFee), + DebitAmount: protoMoney(src.DebitAmount), + ExpectedSettlementAmount: protoMoney(src.ExpectedSettlementAmount), + ExpectedFeeTotal: protoMoney(src.ExpectedFeeTotal), + FeeLines: feeLinesToProto(src.FeeLines), + FeeRules: feeRulesToProto(src.FeeRules), + FxQuote: fxQuoteToProto(src.FXQuote), + NetworkFee: networkFeeToProto(src.NetworkFee), QuoteRef: strings.TrimSpace(src.QuoteRef), } } @@ -390,6 +432,42 @@ func modelKindFromProto(kind orchestratorv1.PaymentKind) model.PaymentKind { } } +func protoRailFromModel(rail model.Rail) gatewayv1.Rail { + switch strings.ToUpper(strings.TrimSpace(string(rail))) { + case string(model.RailCrypto): + return gatewayv1.Rail_RAIL_CRYPTO + case string(model.RailProviderSettlement): + return gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT + case string(model.RailLedger): + return gatewayv1.Rail_RAIL_LEDGER + case string(model.RailCardPayout): + return gatewayv1.Rail_RAIL_CARD_PAYOUT + case string(model.RailFiatOnRamp): + return gatewayv1.Rail_RAIL_FIAT_ONRAMP + default: + return gatewayv1.Rail_RAIL_UNSPECIFIED + } +} + +func protoRailOperationFromModel(action model.RailOperation) gatewayv1.RailOperation { + switch strings.ToUpper(strings.TrimSpace(string(action))) { + case string(model.RailOperationDebit): + return gatewayv1.RailOperation_RAIL_OPERATION_DEBIT + case string(model.RailOperationCredit): + return gatewayv1.RailOperation_RAIL_OPERATION_CREDIT + case string(model.RailOperationSend): + return gatewayv1.RailOperation_RAIL_OPERATION_SEND + case string(model.RailOperationFee): + return gatewayv1.RailOperation_RAIL_OPERATION_FEE + case string(model.RailOperationObserveConfirm): + return gatewayv1.RailOperation_RAIL_OPERATION_OBSERVE_CONFIRM + case string(model.RailOperationFXConvert): + return gatewayv1.RailOperation_RAIL_OPERATION_FX_CONVERT + default: + return gatewayv1.RailOperation_RAIL_OPERATION_UNSPECIFIED + } +} + func protoStateFromModel(state model.PaymentState) orchestratorv1.PaymentState { switch state { case model.PaymentStateAccepted: @@ -431,34 +509,119 @@ func modelStateFromProto(state orchestratorv1.PaymentState) model.PaymentState { func protoFailureFromModel(code model.PaymentFailureCode) orchestratorv1.PaymentFailureCode { switch code { case model.PaymentFailureCodeBalance: - return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_BALANCE + return orchestratorv1.PaymentFailureCode_FAILURE_BALANCE case model.PaymentFailureCodeLedger: - return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_LEDGER + return orchestratorv1.PaymentFailureCode_FAILURE_LEDGER case model.PaymentFailureCodeFX: - return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FX + return orchestratorv1.PaymentFailureCode_FAILURE_FX case model.PaymentFailureCodeChain: - return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_CHAIN + return orchestratorv1.PaymentFailureCode_FAILURE_CHAIN case model.PaymentFailureCodeFees: - return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_FEES + return orchestratorv1.PaymentFailureCode_FAILURE_FEES case model.PaymentFailureCodePolicy: - return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_POLICY + return orchestratorv1.PaymentFailureCode_FAILURE_POLICY default: - return orchestratorv1.PaymentFailureCode_PAYMENT_FAILURE_CODE_UNSPECIFIED + return orchestratorv1.PaymentFailureCode_FAILURE_UNSPECIFIED } } -func cloneAsset(asset *chainv1.Asset) *chainv1.Asset { - if asset == nil { +func settlementModeFromProto(mode orchestratorv1.SettlementMode) model.SettlementMode { + switch mode { + case orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE: + return model.SettlementModeFixSource + case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: + return model.SettlementModeFixReceived + default: + return model.SettlementModeUnspecified + } +} + +func settlementModeToProto(mode model.SettlementMode) orchestratorv1.SettlementMode { + switch mode { + case model.SettlementModeFixSource: + return orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE + case model.SettlementModeFixReceived: + return orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED + default: + return orchestratorv1.SettlementMode_SETTLEMENT_UNSPECIFIED + } +} + +func moneyFromProto(m *moneyv1.Money) *paymenttypes.Money { + if m == nil { return nil } - return &chainv1.Asset{ - Chain: asset.GetChain(), - TokenSymbol: asset.GetTokenSymbol(), - ContractAddress: asset.GetContractAddress(), + return &paymenttypes.Money{ + Currency: m.GetCurrency(), + Amount: m.GetAmount(), } } -func clonePair(pair *fxv1.CurrencyPair) *fxv1.CurrencyPair { +func protoMoney(m *paymenttypes.Money) *moneyv1.Money { + if m == nil { + return nil + } + return &moneyv1.Money{ + Currency: m.GetCurrency(), + Amount: m.GetAmount(), + } +} + +func feePolicyFromProto(src *feesv1.PolicyOverrides) *paymenttypes.FeePolicy { + if src == nil { + return nil + } + return &paymenttypes.FeePolicy{ + InsufficientNet: insufficientPolicyFromProto(src.GetInsufficientNet()), + } +} + +func feePolicyToProto(src *paymenttypes.FeePolicy) *feesv1.PolicyOverrides { + if src == nil { + return nil + } + return &feesv1.PolicyOverrides{ + InsufficientNet: insufficientPolicyToProto(src.InsufficientNet), + } +} + +func insufficientPolicyFromProto(policy feesv1.InsufficientNetPolicy) paymenttypes.InsufficientNetPolicy { + switch policy { + case feesv1.InsufficientNetPolicy_BLOCK_POSTING: + return paymenttypes.InsufficientNetBlockPosting + case feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH: + return paymenttypes.InsufficientNetSweepOrgCash + case feesv1.InsufficientNetPolicy_INVOICE_LATER: + return paymenttypes.InsufficientNetInvoiceLater + default: + return paymenttypes.InsufficientNetUnspecified + } +} + +func insufficientPolicyToProto(policy paymenttypes.InsufficientNetPolicy) feesv1.InsufficientNetPolicy { + switch policy { + case paymenttypes.InsufficientNetBlockPosting: + return feesv1.InsufficientNetPolicy_BLOCK_POSTING + case paymenttypes.InsufficientNetSweepOrgCash: + return feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH + case paymenttypes.InsufficientNetInvoiceLater: + return feesv1.InsufficientNetPolicy_INVOICE_LATER + default: + return feesv1.InsufficientNetPolicy_INSUFFICIENT_NET_UNSPECIFIED + } +} + +func pairFromProto(pair *fxv1.CurrencyPair) *paymenttypes.CurrencyPair { + if pair == nil { + return nil + } + return &paymenttypes.CurrencyPair{ + Base: pair.GetBase(), + Quote: pair.GetQuote(), + } +} + +func pairToProto(pair *paymenttypes.CurrencyPair) *fxv1.CurrencyPair { if pair == nil { return nil } @@ -468,22 +631,290 @@ func clonePair(pair *fxv1.CurrencyPair) *fxv1.CurrencyPair { } } -func cloneFXQuote(quote *oraclev1.Quote) *oraclev1.Quote { +func fxSideFromProto(side fxv1.Side) paymenttypes.FXSide { + switch side { + case fxv1.Side_BUY_BASE_SELL_QUOTE: + return paymenttypes.FXSideBuyBaseSellQuote + case fxv1.Side_SELL_BASE_BUY_QUOTE: + return paymenttypes.FXSideSellBaseBuyQuote + default: + return paymenttypes.FXSideUnspecified + } +} + +func fxSideToProto(side paymenttypes.FXSide) fxv1.Side { + switch side { + case paymenttypes.FXSideBuyBaseSellQuote: + return fxv1.Side_BUY_BASE_SELL_QUOTE + case paymenttypes.FXSideSellBaseBuyQuote: + return fxv1.Side_SELL_BASE_BUY_QUOTE + default: + return fxv1.Side_SIDE_UNSPECIFIED + } +} + +func fxQuoteFromProto(quote *oraclev1.Quote) *paymenttypes.FXQuote { if quote == nil { return nil } - if cloned, ok := proto.Clone(quote).(*oraclev1.Quote); ok { - return cloned + return &paymenttypes.FXQuote{ + QuoteRef: strings.TrimSpace(quote.GetQuoteRef()), + Pair: pairFromProto(quote.GetPair()), + Side: fxSideFromProto(quote.GetSide()), + Price: decimalFromProto(quote.GetPrice()), + BaseAmount: moneyFromProto(quote.GetBaseAmount()), + QuoteAmount: moneyFromProto(quote.GetQuoteAmount()), + ExpiresAtUnixMs: quote.GetExpiresAtUnixMs(), + Provider: strings.TrimSpace(quote.GetProvider()), + RateRef: strings.TrimSpace(quote.GetRateRef()), + Firm: quote.GetFirm(), } - return nil } -func cloneNetworkEstimate(resp *chainv1.EstimateTransferFeeResponse) *chainv1.EstimateTransferFeeResponse { +func fxQuoteToProto(quote *paymenttypes.FXQuote) *oraclev1.Quote { + if quote == nil { + return nil + } + return &oraclev1.Quote{ + QuoteRef: strings.TrimSpace(quote.QuoteRef), + Pair: pairToProto(quote.Pair), + Side: fxSideToProto(quote.Side), + Price: decimalToProto(quote.Price), + BaseAmount: protoMoney(quote.BaseAmount), + QuoteAmount: protoMoney(quote.QuoteAmount), + ExpiresAtUnixMs: quote.ExpiresAtUnixMs, + Provider: strings.TrimSpace(quote.Provider), + RateRef: strings.TrimSpace(quote.RateRef), + Firm: quote.Firm, + } +} + +func decimalFromProto(value *moneyv1.Decimal) *paymenttypes.Decimal { + if value == nil { + return nil + } + return &paymenttypes.Decimal{Value: value.GetValue()} +} + +func decimalToProto(value *paymenttypes.Decimal) *moneyv1.Decimal { + if value == nil { + return nil + } + return &moneyv1.Decimal{Value: value.GetValue()} +} + +func assetFromProto(asset *chainv1.Asset) *paymenttypes.Asset { + if asset == nil { + return nil + } + return &paymenttypes.Asset{ + Chain: chainNetworkName(asset.GetChain()), + TokenSymbol: asset.GetTokenSymbol(), + ContractAddress: asset.GetContractAddress(), + } +} + +func assetToProto(asset *paymenttypes.Asset) *chainv1.Asset { + if asset == nil { + return nil + } + return &chainv1.Asset{ + Chain: chainNetworkFromName(asset.Chain), + TokenSymbol: asset.TokenSymbol, + ContractAddress: asset.ContractAddress, + } +} + +func networkFeeFromProto(resp *chainv1.EstimateTransferFeeResponse) *paymenttypes.NetworkFeeEstimate { if resp == nil { return nil } - if cloned, ok := proto.Clone(resp).(*chainv1.EstimateTransferFeeResponse); ok { - return cloned + return &paymenttypes.NetworkFeeEstimate{ + NetworkFee: moneyFromProto(resp.GetNetworkFee()), + EstimationContext: strings.TrimSpace(resp.GetEstimationContext()), + } +} + +func networkFeeToProto(resp *paymenttypes.NetworkFeeEstimate) *chainv1.EstimateTransferFeeResponse { + if resp == nil { + return nil + } + return &chainv1.EstimateTransferFeeResponse{ + NetworkFee: protoMoney(resp.NetworkFee), + EstimationContext: strings.TrimSpace(resp.EstimationContext), + } +} + +func feeLinesFromProto(lines []*feesv1.DerivedPostingLine) []*paymenttypes.FeeLine { + if len(lines) == 0 { + return nil + } + result := make([]*paymenttypes.FeeLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + continue + } + result = append(result, &paymenttypes.FeeLine{ + LedgerAccountRef: strings.TrimSpace(line.GetLedgerAccountRef()), + Money: moneyFromProto(line.GetMoney()), + LineType: postingLineTypeFromProto(line.GetLineType()), + Side: entrySideFromProto(line.GetSide()), + Meta: cloneMetadata(line.GetMeta()), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func feeLinesToProto(lines []*paymenttypes.FeeLine) []*feesv1.DerivedPostingLine { + if len(lines) == 0 { + return nil + } + result := make([]*feesv1.DerivedPostingLine, 0, len(lines)) + for _, line := range lines { + if line == nil { + continue + } + result = append(result, &feesv1.DerivedPostingLine{ + LedgerAccountRef: strings.TrimSpace(line.LedgerAccountRef), + Money: protoMoney(line.Money), + LineType: postingLineTypeToProto(line.LineType), + Side: entrySideToProto(line.Side), + Meta: cloneMetadata(line.Meta), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func feeRulesFromProto(rules []*feesv1.AppliedRule) []*paymenttypes.AppliedRule { + if len(rules) == 0 { + return nil + } + result := make([]*paymenttypes.AppliedRule, 0, len(rules)) + for _, rule := range rules { + if rule == nil { + continue + } + result = append(result, &paymenttypes.AppliedRule{ + RuleID: strings.TrimSpace(rule.GetRuleId()), + RuleVersion: strings.TrimSpace(rule.GetRuleVersion()), + Formula: strings.TrimSpace(rule.GetFormula()), + Rounding: roundingModeFromProto(rule.GetRounding()), + TaxCode: strings.TrimSpace(rule.GetTaxCode()), + TaxRate: strings.TrimSpace(rule.GetTaxRate()), + Parameters: cloneMetadata(rule.GetParameters()), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func feeRulesToProto(rules []*paymenttypes.AppliedRule) []*feesv1.AppliedRule { + if len(rules) == 0 { + return nil + } + result := make([]*feesv1.AppliedRule, 0, len(rules)) + for _, rule := range rules { + if rule == nil { + continue + } + result = append(result, &feesv1.AppliedRule{ + RuleId: strings.TrimSpace(rule.RuleID), + RuleVersion: strings.TrimSpace(rule.RuleVersion), + Formula: strings.TrimSpace(rule.Formula), + Rounding: roundingModeToProto(rule.Rounding), + TaxCode: strings.TrimSpace(rule.TaxCode), + TaxRate: strings.TrimSpace(rule.TaxRate), + Parameters: cloneMetadata(rule.Parameters), + }) + } + if len(result) == 0 { + return nil + } + return result +} + +func entrySideFromProto(side accountingv1.EntrySide) paymenttypes.EntrySide { + switch side { + case accountingv1.EntrySide_ENTRY_SIDE_DEBIT: + return paymenttypes.EntrySideDebit + case accountingv1.EntrySide_ENTRY_SIDE_CREDIT: + return paymenttypes.EntrySideCredit + default: + return paymenttypes.EntrySideUnspecified + } +} + +func entrySideToProto(side paymenttypes.EntrySide) accountingv1.EntrySide { + switch side { + case paymenttypes.EntrySideDebit: + return accountingv1.EntrySide_ENTRY_SIDE_DEBIT + case paymenttypes.EntrySideCredit: + return accountingv1.EntrySide_ENTRY_SIDE_CREDIT + default: + return accountingv1.EntrySide_ENTRY_SIDE_UNSPECIFIED + } +} + +func postingLineTypeFromProto(lineType accountingv1.PostingLineType) paymenttypes.PostingLineType { + switch lineType { + case accountingv1.PostingLineType_POSTING_LINE_FEE: + return paymenttypes.PostingLineTypeFee + case accountingv1.PostingLineType_POSTING_LINE_TAX: + return paymenttypes.PostingLineTypeTax + case accountingv1.PostingLineType_POSTING_LINE_SPREAD: + return paymenttypes.PostingLineTypeSpread + case accountingv1.PostingLineType_POSTING_LINE_REVERSAL: + return paymenttypes.PostingLineTypeReversal + default: + return paymenttypes.PostingLineTypeUnspecified + } +} + +func postingLineTypeToProto(lineType paymenttypes.PostingLineType) accountingv1.PostingLineType { + switch lineType { + case paymenttypes.PostingLineTypeFee: + return accountingv1.PostingLineType_POSTING_LINE_FEE + case paymenttypes.PostingLineTypeTax: + return accountingv1.PostingLineType_POSTING_LINE_TAX + case paymenttypes.PostingLineTypeSpread: + return accountingv1.PostingLineType_POSTING_LINE_SPREAD + case paymenttypes.PostingLineTypeReversal: + return accountingv1.PostingLineType_POSTING_LINE_REVERSAL + default: + return accountingv1.PostingLineType_POSTING_LINE_TYPE_UNSPECIFIED + } +} + +func roundingModeFromProto(mode moneyv1.RoundingMode) paymenttypes.RoundingMode { + switch mode { + case moneyv1.RoundingMode_ROUND_HALF_EVEN: + return paymenttypes.RoundingModeHalfEven + case moneyv1.RoundingMode_ROUND_HALF_UP: + return paymenttypes.RoundingModeHalfUp + case moneyv1.RoundingMode_ROUND_DOWN: + return paymenttypes.RoundingModeDown + default: + return paymenttypes.RoundingModeUnspecified + } +} + +func roundingModeToProto(mode paymenttypes.RoundingMode) moneyv1.RoundingMode { + switch mode { + case paymenttypes.RoundingModeHalfEven: + return moneyv1.RoundingMode_ROUND_HALF_EVEN + case paymenttypes.RoundingModeHalfUp: + return moneyv1.RoundingMode_ROUND_HALF_UP + case paymenttypes.RoundingModeDown: + return moneyv1.RoundingMode_ROUND_DOWN + default: + return moneyv1.RoundingMode_ROUNDING_MODE_UNSPECIFIED } - return nil } diff --git a/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go b/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go new file mode 100644 index 0000000..751883d --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/convert_types_test.go @@ -0,0 +1,114 @@ +package orchestrator + +import ( + "testing" + + paymenttypes "github.com/tech/sendico/pkg/payments/types" + feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" + fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" +) + +func TestMoneyConversionRoundTrip(t *testing.T) { + proto := &moneyv1.Money{Currency: "USD", Amount: "12.34"} + model := moneyFromProto(proto) + if model == nil || model.Currency != "USD" || model.Amount != "12.34" { + t.Fatalf("moneyFromProto mismatch: %#v", model) + } + back := protoMoney(model) + if back == nil || back.GetCurrency() != "USD" || back.GetAmount() != "12.34" { + t.Fatalf("protoMoney mismatch: %#v", back) + } +} + +func TestFeePolicyConversionRoundTrip(t *testing.T) { + proto := &feesv1.PolicyOverrides{InsufficientNet: feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH} + model := feePolicyFromProto(proto) + if model == nil || model.InsufficientNet != paymenttypes.InsufficientNetSweepOrgCash { + t.Fatalf("feePolicyFromProto mismatch: %#v", model) + } + back := feePolicyToProto(model) + if back == nil || back.GetInsufficientNet() != feesv1.InsufficientNetPolicy_SWEEP_ORG_CASH { + t.Fatalf("feePolicyToProto mismatch: %#v", back) + } +} + +func TestFeeLineConversionRoundTrip(t *testing.T) { + protoLine := &feesv1.DerivedPostingLine{ + LedgerAccountRef: "ledger:fees", + Money: &moneyv1.Money{Currency: "EUR", Amount: "1.00"}, + LineType: accountingv1.PostingLineType_POSTING_LINE_FEE, + Side: accountingv1.EntrySide_ENTRY_SIDE_DEBIT, + Meta: map[string]string{"k": "v"}, + } + modelLines := feeLinesFromProto([]*feesv1.DerivedPostingLine{protoLine}) + if len(modelLines) != 1 { + t.Fatalf("expected 1 model line, got %d", len(modelLines)) + } + modelLine := modelLines[0] + if modelLine.LedgerAccountRef != "ledger:fees" || modelLine.Money.GetCurrency() != "EUR" || modelLine.Money.GetAmount() != "1.00" { + t.Fatalf("model line mismatch: %#v", modelLine) + } + if modelLine.LineType != paymenttypes.PostingLineTypeFee || modelLine.Side != paymenttypes.EntrySideDebit { + t.Fatalf("model line enums mismatch: %#v", modelLine) + } + back := feeLinesToProto(modelLines) + if len(back) != 1 { + t.Fatalf("expected 1 proto line, got %d", len(back)) + } + protoBack := back[0] + if protoBack.GetLedgerAccountRef() != "ledger:fees" || protoBack.GetMoney().GetCurrency() != "EUR" || protoBack.GetMoney().GetAmount() != "1.00" { + t.Fatalf("proto line mismatch: %#v", protoBack) + } + if protoBack.GetLineType() != accountingv1.PostingLineType_POSTING_LINE_FEE || protoBack.GetSide() != accountingv1.EntrySide_ENTRY_SIDE_DEBIT { + t.Fatalf("proto line enums mismatch: %#v", protoBack) + } +} + +func TestFXQuoteConversionRoundTrip(t *testing.T) { + proto := &oraclev1.Quote{ + QuoteRef: "q1", + Pair: &fxv1.CurrencyPair{Base: "USD", Quote: "EUR"}, + Side: fxv1.Side_SELL_BASE_BUY_QUOTE, + Price: &moneyv1.Decimal{Value: "0.9"}, + BaseAmount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + QuoteAmount: &moneyv1.Money{Currency: "EUR", Amount: "90"}, + ExpiresAtUnixMs: 1700000000000, + Provider: "provider", + RateRef: "rate", + Firm: true, + } + model := fxQuoteFromProto(proto) + if model == nil || model.QuoteRef != "q1" || model.Pair.GetBase() != "USD" || model.Pair.GetQuote() != "EUR" { + t.Fatalf("fxQuoteFromProto mismatch: %#v", model) + } + if model.Side != paymenttypes.FXSideSellBaseBuyQuote || model.Price.GetValue() != "0.9" { + t.Fatalf("fxQuoteFromProto enums mismatch: %#v", model) + } + back := fxQuoteToProto(model) + if back == nil || back.GetQuoteRef() != "q1" || back.GetPair().GetBase() != "USD" || back.GetPair().GetQuote() != "EUR" { + t.Fatalf("fxQuoteToProto mismatch: %#v", back) + } + if back.GetSide() != fxv1.Side_SELL_BASE_BUY_QUOTE || back.GetPrice().GetValue() != "0.9" { + t.Fatalf("fxQuoteToProto enums mismatch: %#v", back) + } +} + +func TestAssetConversionRoundTrip(t *testing.T) { + proto := &chainv1.Asset{ + Chain: chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET, + TokenSymbol: "USDT", + ContractAddress: "0xabc", + } + model := assetFromProto(proto) + if model == nil || model.Chain != "TRON" || model.TokenSymbol != "USDT" || model.ContractAddress != "0xabc" { + t.Fatalf("assetFromProto mismatch: %#v", model) + } + back := assetToProto(model) + if back == nil || back.GetChain() != chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET || back.GetTokenSymbol() != "USDT" || back.GetContractAddress() != "0xabc" { + t.Fatalf("assetToProto mismatch: %#v", back) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go new file mode 100644 index 0000000..4ebb90f --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/discovery_gateway_registry.go @@ -0,0 +1,131 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + "time" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/discovery" + "github.com/tech/sendico/pkg/mlogger" +) + +type discoveryGatewayRegistry struct { + logger mlogger.Logger + registry *discovery.Registry +} + +func NewDiscoveryGatewayRegistry(logger mlogger.Logger, registry *discovery.Registry) GatewayRegistry { + if registry == nil { + return nil + } + if logger != nil { + logger = logger.Named("discovery_gateway_registry") + } + return &discoveryGatewayRegistry{ + logger: logger, + registry: registry, + } +} + +func (r *discoveryGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + if r == nil || r.registry == nil { + return nil, nil + } + entries := r.registry.List(time.Now(), true) + items := make([]*model.GatewayInstanceDescriptor, 0, len(entries)) + for _, entry := range entries { + if entry.Rail == "" { + continue + } + rail := railFromDiscovery(entry.Rail) + if rail == model.RailUnspecified { + continue + } + items = append(items, &model.GatewayInstanceDescriptor{ + ID: entry.ID, + Rail: rail, + Network: entry.Network, + Currencies: normalizeCurrencies(entry.Currencies), + Capabilities: capabilitiesFromOps(entry.Operations), + Limits: limitsFromDiscovery(entry.Limits), + Version: entry.Version, + IsEnabled: entry.Healthy, + }) + } + sort.Slice(items, func(i, j int) bool { + return items[i].ID < items[j].ID + }) + return items, nil +} + +func railFromDiscovery(value string) model.Rail { + switch strings.ToUpper(strings.TrimSpace(value)) { + 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 + } +} + +func capabilitiesFromOps(ops []string) model.RailCapabilities { + var cap model.RailCapabilities + for _, op := range ops { + switch strings.ToLower(strings.TrimSpace(op)) { + case "payin.crypto", "payin.card", "payin.fiat": + cap.CanPayIn = true + case "payout.crypto", "payout.card", "payout.fiat": + cap.CanPayOut = true + case "balance.read": + cap.CanReadBalance = true + case "fee.send": + cap.CanSendFee = true + case "observe.confirm", "observe.confirmation": + cap.RequiresObserveConfirm = true + } + } + return cap +} + +func limitsFromDiscovery(src *discovery.Limits) model.Limits { + if src == nil { + return model.Limits{} + } + limits := model.Limits{ + MinAmount: strings.TrimSpace(src.MinAmount), + MaxAmount: strings.TrimSpace(src.MaxAmount), + VolumeLimit: map[string]string{}, + VelocityLimit: map[string]int{}, + } + for key, value := range src.VolumeLimit { + k := strings.TrimSpace(key) + v := strings.TrimSpace(value) + if k == "" || v == "" { + continue + } + limits.VolumeLimit[k] = v + } + for key, value := range src.VelocityLimit { + k := strings.TrimSpace(key) + if k == "" { + continue + } + limits.VelocityLimit[k] = value + } + if len(limits.VolumeLimit) == 0 { + limits.VolumeLimit = nil + } + if len(limits.VelocityLimit) == 0 { + limits.VelocityLimit = nil + } + return limits +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go b/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go new file mode 100644 index 0000000..b40976e --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/execution_plan.go @@ -0,0 +1,168 @@ +package orchestrator + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +const ( + executionStepMetadataRole = "role" + executionStepMetadataStatus = "status" + + executionStepRoleSource = "source" + executionStepRoleConsumer = "consumer" + + executionStepStatusPlanned = "planned" + executionStepStatusSubmitted = "submitted" + executionStepStatusConfirmed = "confirmed" + executionStepStatusFailed = "failed" + executionStepStatusCancelled = "cancelled" + executionStepStatusSkipped = "skipped" +) + +func setExecutionStepRole(step *model.ExecutionStep, role string) { + role = strings.ToLower(strings.TrimSpace(role)) + setExecutionStepMetadata(step, executionStepMetadataRole, role) +} + +func setExecutionStepStatus(step *model.ExecutionStep, status string) { + status = strings.ToLower(strings.TrimSpace(status)) + setExecutionStepMetadata(step, executionStepMetadataStatus, status) +} + +func executionStepRole(step *model.ExecutionStep) string { + if step == nil { + return "" + } + if role := strings.TrimSpace(step.Metadata[executionStepMetadataRole]); role != "" { + return strings.ToLower(role) + } + if strings.EqualFold(step.Code, stepCodeCardPayout) { + return executionStepRoleConsumer + } + return executionStepRoleSource +} + +func executionStepStatus(step *model.ExecutionStep) string { + if step == nil { + return "" + } + status := strings.TrimSpace(step.Metadata[executionStepMetadataStatus]) + if status == "" { + return executionStepStatusPlanned + } + return strings.ToLower(status) +} + +func isSourceExecutionStep(step *model.ExecutionStep) bool { + return executionStepRole(step) == executionStepRoleSource +} + +func isConsumerExecutionStep(step *model.ExecutionStep) bool { + return executionStepRole(step) == executionStepRoleConsumer +} + +func sourceStepsConfirmed(plan *model.ExecutionPlan) bool { + if plan == nil || len(plan.Steps) == 0 { + return false + } + hasSource := false + for _, step := range plan.Steps { + if step == nil || !isSourceExecutionStep(step) { + continue + } + status := executionStepStatus(step) + if status == executionStepStatusSkipped { + continue + } + hasSource = true + if status != executionStepStatusConfirmed { + return false + } + } + return hasSource +} + +func findExecutionStepByTransferRef(plan *model.ExecutionPlan, transferRef string) *model.ExecutionStep { + if plan == nil { + return nil + } + transferRef = strings.TrimSpace(transferRef) + if transferRef == "" { + return nil + } + for _, step := range plan.Steps { + if step == nil { + continue + } + if strings.EqualFold(strings.TrimSpace(step.TransferRef), transferRef) { + return step + } + } + return nil +} + +func updateExecutionStepFromTransfer(plan *model.ExecutionPlan, event *chainv1.TransferStatusChangedEvent) *model.ExecutionStep { + if plan == nil || event == nil || event.GetTransfer() == nil { + return nil + } + transfer := event.GetTransfer() + transferRef := strings.TrimSpace(transfer.GetTransferRef()) + if transferRef == "" { + return nil + } + step := findExecutionStepByTransferRef(plan, transferRef) + if step == nil { + return nil + } + if step.TransferRef == "" { + step.TransferRef = transferRef + } + if status := executionStepStatusFromTransferStatus(transfer.GetStatus()); status != "" { + setExecutionStepStatus(step, status) + } + return step +} + +func executionStepStatusFromTransferStatus(status chainv1.TransferStatus) string { + switch status { + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + return executionStepStatusConfirmed + case chainv1.TransferStatus_TRANSFER_FAILED: + return executionStepStatusFailed + case chainv1.TransferStatus_TRANSFER_CANCELLED: + return executionStepStatusCancelled + case chainv1.TransferStatus_TRANSFER_SIGNING, + chainv1.TransferStatus_TRANSFER_PENDING, + chainv1.TransferStatus_TRANSFER_SUBMITTED: + return executionStepStatusSubmitted + default: + return "" + } +} + +func setExecutionStepMetadata(step *model.ExecutionStep, key, value string) { + if step == nil { + return + } + key = strings.TrimSpace(key) + if key == "" { + return + } + value = strings.TrimSpace(value) + if value == "" { + if step.Metadata != nil { + delete(step.Metadata, key) + if len(step.Metadata) == 0 { + step.Metadata = nil + } + } + return + } + if step.Metadata == nil { + step.Metadata = map[string]string{} + } + step.Metadata[key] = value +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go new file mode 100644 index 0000000..fbce083 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/gateway_registry.go @@ -0,0 +1,249 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + + mntxclient "github.com/tech/sendico/gateway/mntx/client" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/mlogger" + gatewayv1 "github.com/tech/sendico/pkg/proto/common/gateway/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + "go.uber.org/zap" +) + +type gatewayRegistry struct { + logger mlogger.Logger + mntx mntxclient.Client + static []*model.GatewayInstanceDescriptor +} + +// NewGatewayRegistry aggregates static and remote gateway descriptors. +func NewGatewayRegistry(logger mlogger.Logger, mntxClient mntxclient.Client, static []*model.GatewayInstanceDescriptor) GatewayRegistry { + if mntxClient == nil && len(static) == 0 { + return nil + } + if logger != nil { + logger = logger.Named("gateway_registry") + } + return &gatewayRegistry{ + logger: logger, + mntx: mntxClient, + static: cloneGatewayDescriptors(static), + } +} + +func (r *gatewayRegistry) List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) { + items := map[string]*model.GatewayInstanceDescriptor{} + for _, gw := range r.static { + if gw == nil { + continue + } + id := strings.TrimSpace(gw.ID) + if id == "" { + continue + } + items[id] = cloneGatewayDescriptor(gw) + } + + if r.mntx != nil { + resp, err := r.mntx.ListGatewayInstances(ctx, &mntxv1.ListGatewayInstancesRequest{}) + if err != nil { + if r.logger != nil { + r.logger.Warn("Failed to list Monetix gateway instances", zap.Error(err)) + } + } else { + for _, gw := range resp.GetItems() { + modelGw := modelGatewayFromProto(gw) + if modelGw == nil { + continue + } + id := strings.TrimSpace(modelGw.ID) + if id == "" { + continue + } + items[id] = modelGw + } + } + } + + result := make([]*model.GatewayInstanceDescriptor, 0, len(items)) + for _, gw := range items { + result = append(result, gw) + } + sort.Slice(result, func(i, j int) bool { + return result[i].ID < result[j].ID + }) + return result, nil +} + +func modelGatewayFromProto(src *gatewayv1.GatewayInstanceDescriptor) *model.GatewayInstanceDescriptor { + if src == nil { + return nil + } + limits := modelLimitsFromProto(src.GetLimits()) + return &model.GatewayInstanceDescriptor{ + ID: strings.TrimSpace(src.GetId()), + Rail: modelRailFromProto(src.GetRail()), + Network: strings.ToUpper(strings.TrimSpace(src.GetNetwork())), + Currencies: normalizeCurrencies(src.GetCurrencies()), + Capabilities: modelCapabilitiesFromProto(src.GetCapabilities()), + Limits: limits, + Version: strings.TrimSpace(src.GetVersion()), + IsEnabled: src.GetIsEnabled(), + } +} + +func modelRailFromProto(rail gatewayv1.Rail) model.Rail { + switch rail { + case gatewayv1.Rail_RAIL_CRYPTO: + return model.RailCrypto + case gatewayv1.Rail_RAIL_PROVIDER_SETTLEMENT: + return model.RailProviderSettlement + case gatewayv1.Rail_RAIL_LEDGER: + return model.RailLedger + case gatewayv1.Rail_RAIL_CARD_PAYOUT: + return model.RailCardPayout + case gatewayv1.Rail_RAIL_FIAT_ONRAMP: + return model.RailFiatOnRamp + default: + return model.RailUnspecified + } +} + +func modelCapabilitiesFromProto(src *gatewayv1.RailCapabilities) model.RailCapabilities { + if src == nil { + return model.RailCapabilities{} + } + return model.RailCapabilities{ + CanPayIn: src.GetCanPayIn(), + CanPayOut: src.GetCanPayOut(), + CanReadBalance: src.GetCanReadBalance(), + CanSendFee: src.GetCanSendFee(), + RequiresObserveConfirm: src.GetRequiresObserveConfirm(), + } +} + +func modelLimitsFromProto(src *gatewayv1.Limits) model.Limits { + if src == nil { + return model.Limits{} + } + limits := model.Limits{ + MinAmount: strings.TrimSpace(src.GetMinAmount()), + MaxAmount: strings.TrimSpace(src.GetMaxAmount()), + PerTxMaxFee: strings.TrimSpace(src.GetPerTxMaxFee()), + PerTxMinAmount: strings.TrimSpace(src.GetPerTxMinAmount()), + PerTxMaxAmount: strings.TrimSpace(src.GetPerTxMaxAmount()), + } + + if len(src.GetVolumeLimit()) > 0 { + limits.VolumeLimit = map[string]string{} + for key, value := range src.GetVolumeLimit() { + bucket := strings.TrimSpace(key) + amount := strings.TrimSpace(value) + if bucket == "" || amount == "" { + continue + } + limits.VolumeLimit[bucket] = amount + } + } + + if len(src.GetVelocityLimit()) > 0 { + limits.VelocityLimit = map[string]int{} + for key, value := range src.GetVelocityLimit() { + bucket := strings.TrimSpace(key) + if bucket == "" { + continue + } + limits.VelocityLimit[bucket] = int(value) + } + } + + if len(src.GetCurrencyLimits()) > 0 { + limits.CurrencyLimits = map[string]model.LimitsOverride{} + for key, override := range src.GetCurrencyLimits() { + currency := strings.ToUpper(strings.TrimSpace(key)) + if currency == "" || override == nil { + continue + } + limits.CurrencyLimits[currency] = model.LimitsOverride{ + MaxVolume: strings.TrimSpace(override.GetMaxVolume()), + MinAmount: strings.TrimSpace(override.GetMinAmount()), + MaxAmount: strings.TrimSpace(override.GetMaxAmount()), + MaxFee: strings.TrimSpace(override.GetMaxFee()), + MaxOps: int(override.GetMaxOps()), + } + } + } + + return limits +} + +func normalizeCurrencies(values []string) []string { + if len(values) == 0 { + return nil + } + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.ToUpper(strings.TrimSpace(value)) + if clean == "" || seen[clean] { + continue + } + seen[clean] = true + result = append(result, clean) + } + return result +} + +func cloneGatewayDescriptors(src []*model.GatewayInstanceDescriptor) []*model.GatewayInstanceDescriptor { + if len(src) == 0 { + return nil + } + result := make([]*model.GatewayInstanceDescriptor, 0, len(src)) + for _, item := range src { + if item == nil { + continue + } + if cloned := cloneGatewayDescriptor(item); cloned != nil { + result = append(result, cloned) + } + } + return result +} + +func cloneGatewayDescriptor(src *model.GatewayInstanceDescriptor) *model.GatewayInstanceDescriptor { + if src == nil { + return nil + } + dst := *src + if src.Currencies != nil { + dst.Currencies = append([]string(nil), src.Currencies...) + } + dst.Limits = cloneLimits(src.Limits) + return &dst +} + +func cloneLimits(src model.Limits) model.Limits { + dst := src + if src.VolumeLimit != nil { + dst.VolumeLimit = map[string]string{} + for key, value := range src.VolumeLimit { + dst.VolumeLimit[key] = value + } + } + if src.VelocityLimit != nil { + dst.VelocityLimit = map[string]int{} + for key, value := range src.VelocityLimit { + dst.VelocityLimit[key] = value + } + } + if src.CurrencyLimits != nil { + dst.CurrencyLimits = map[string]model.LimitsOverride{} + for key, value := range src.CurrencyLimits { + dst.CurrencyLimits[key] = value + } + } + return dst +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go index 98b8c19..1b51a02 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_events.go @@ -10,21 +10,26 @@ import ( "github.com/tech/sendico/pkg/merrors" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "go.uber.org/zap" ) type paymentEventHandler struct { - repo storage.Repository - ensureRepo func(ctx context.Context) error - logger mlogger.Logger + repo storage.Repository + ensureRepo func(ctx context.Context) error + logger mlogger.Logger + submitCardPayout func(ctx context.Context, payment *model.Payment) error + resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error } -func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger) *paymentEventHandler { +func newPaymentEventHandler(repo storage.Repository, ensure func(ctx context.Context) error, logger mlogger.Logger, submitCardPayout func(ctx context.Context, payment *model.Payment) error, resumePlan func(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error) *paymentEventHandler { return &paymentEventHandler{ - repo: repo, - ensureRepo: ensure, - logger: logger, + repo: repo, + ensureRepo: ensure, + logger: logger, + submitCardPayout: submitCardPayout, + resumePlan: resumePlan, } } @@ -48,6 +53,128 @@ func (h *paymentEventHandler) processTransferUpdate(ctx context.Context, req *or if err != nil { return paymentNotFoundResponder[orchestratorv1.ProcessTransferUpdateResponse](mservice.PaymentOrchestrator, h.logger, err) } + if payment.PaymentPlan != nil && len(payment.PaymentPlan.Steps) > 0 { + if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != len(payment.PaymentPlan.Steps) { + intent := payment.Intent + splitIdx := len(payment.PaymentPlan.Steps) + sourceRail, _, srcErr := railFromEndpoint(intent.Source, intent.Attributes, true) + destRail, _, dstErr := railFromEndpoint(intent.Destination, intent.Attributes, false) + if srcErr == nil && dstErr == nil { + splitIdx = planSplitIndex(payment.PaymentPlan, sourceRail, destRail) + } + ensureExecutionPlanForPlan(payment, payment.PaymentPlan, splitIdx) + } + updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent()) + if payment.Execution == nil { + payment.Execution = &model.ExecutionRefs{} + } + if payment.Execution.ChainTransferRef == "" { + payment.Execution.ChainTransferRef = transferRef + } + reason := transferFailureReason(req.GetEvent()) + switch transfer.GetStatus() { + case chainv1.TransferStatus_TRANSFER_FAILED: + payment.State = model.PaymentStateFailed + payment.FailureCode = model.PaymentFailureCodeChain + payment.FailureReason = reason + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) + case chainv1.TransferStatus_TRANSFER_CANCELLED: + payment.State = model.PaymentStateCancelled + payment.FailureCode = model.PaymentFailureCodePolicy + payment.FailureReason = reason + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + if h.resumePlan != nil { + if err := h.resumePlan(ctx, store, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + } else { + payment.State = model.PaymentStateSubmitted + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + } + return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) + case chainv1.TransferStatus_TRANSFER_SIGNING, + chainv1.TransferStatus_TRANSFER_PENDING, + chainv1.TransferStatus_TRANSFER_SUBMITTED: + if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { + payment.State = model.PaymentStateSubmitted + } + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) + default: + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) + } + } + + updateExecutionStepFromTransfer(payment.ExecutionPlan, req.GetEvent()) + if payment.Intent.Destination.Type == model.EndpointTypeCard { + if payment.Execution == nil { + payment.Execution = &model.ExecutionRefs{} + } + if payment.Execution.ChainTransferRef == "" { + payment.Execution.ChainTransferRef = transferRef + } + reason := transferFailureReason(req.GetEvent()) + switch transfer.GetStatus() { + case chainv1.TransferStatus_TRANSFER_FAILED: + payment.State = model.PaymentStateFailed + payment.FailureCode = model.PaymentFailureCodeChain + payment.FailureReason = reason + case chainv1.TransferStatus_TRANSFER_CANCELLED: + payment.State = model.PaymentStateCancelled + payment.FailureCode = model.PaymentFailureCodePolicy + payment.FailureReason = reason + case chainv1.TransferStatus_TRANSFER_CONFIRMED: + if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { + if sourceStepsConfirmed(payment.ExecutionPlan) { + if payment.Execution.CardPayoutRef != "" { + payment.State = model.PaymentStateSubmitted + } else { + payment.State = model.PaymentStateFundsReserved + if h.submitCardPayout == nil { + h.logger.Warn("card payout execution skipped", zap.String("payment_ref", payment.PaymentRef)) + } else if err := h.submitCardPayout(ctx, payment); err != nil { + payment.State = model.PaymentStateFailed + payment.FailureCode = model.PaymentFailureCodePolicy + payment.FailureReason = strings.TrimSpace(err.Error()) + h.logger.Warn("card payout execution failed", zap.Error(err), zap.String("payment_ref", payment.PaymentRef)) + } else { + payment.State = model.PaymentStateSubmitted + } + } + } else { + payment.State = model.PaymentStateSubmitted + } + } + case chainv1.TransferStatus_TRANSFER_SIGNING, + chainv1.TransferStatus_TRANSFER_PENDING, + chainv1.TransferStatus_TRANSFER_SUBMITTED: + if payment.State != model.PaymentStateFailed && payment.State != model.PaymentStateCancelled && payment.State != model.PaymentStateSettled { + payment.State = model.PaymentStateSubmitted + } + default: + // keep current state + } + if err := store.Update(ctx, payment); err != nil { + return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) + } + h.logger.Info("transfer update applied", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", transferRef), zap.Any("state", payment.State)) + return gsresponse.Success(&orchestratorv1.ProcessTransferUpdateResponse{Payment: toProtoPayment(payment)}) + } + applyTransferStatus(req.GetEvent(), payment) if err := store.Update(ctx, payment); err != nil { return gsresponse.Auto[orchestratorv1.ProcessTransferUpdateResponse](h.logger, mservice.PaymentOrchestrator, err) @@ -137,3 +264,14 @@ func (h *paymentEventHandler) processCardPayoutUpdate(ctx context.Context, req * Payment: toProtoPayment(payment), }) } + +func transferFailureReason(event *chainv1.TransferStatusChangedEvent) string { + if event == nil || event.GetTransfer() == nil { + return "" + } + reason := strings.TrimSpace(event.GetReason()) + if reason != "" { + return reason + } + return strings.TrimSpace(event.GetTransfer().GetFailureReason()) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers.go b/api/payments/orchestrator/internal/service/orchestrator/helpers.go index 55962cf..df242d6 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers.go +++ b/api/payments/orchestrator/internal/service/orchestrator/helpers.go @@ -7,6 +7,8 @@ import ( "github.com/shopspring/decimal" oracleclient "github.com/tech/sendico/fx/oracle/client" "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/payments/rail" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" @@ -16,10 +18,14 @@ import ( accountingv1 "github.com/tech/sendico/pkg/proto/common/accounting/v1" fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ) -func cloneMoney(input *moneyv1.Money) *moneyv1.Money { +type moneyGetter interface { + GetAmount() string + GetCurrency() string +} + +func cloneProtoMoney(input *moneyv1.Money) *moneyv1.Money { if input == nil { return nil } @@ -112,7 +118,7 @@ func extractFeeTotal(lines []*feesv1.DerivedPostingLine, currency string) *money func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, side fxv1.Side) (*moneyv1.Money, *moneyv1.Money) { if fxQuote == nil { - return cloneMoney(intentAmount), cloneMoney(intentAmount) + return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount) } qSide := fxQuote.GetSide() if qSide == fxv1.Side_SIDE_UNSPECIFIED { @@ -121,27 +127,27 @@ func resolveTradeAmounts(intentAmount *moneyv1.Money, fxQuote *oraclev1.Quote, s switch qSide { case fxv1.Side_BUY_BASE_SELL_QUOTE: - pay := cloneMoney(fxQuote.GetQuoteAmount()) - settle := cloneMoney(fxQuote.GetBaseAmount()) + pay := cloneProtoMoney(fxQuote.GetQuoteAmount()) + settle := cloneProtoMoney(fxQuote.GetBaseAmount()) if pay == nil { - pay = cloneMoney(intentAmount) + pay = cloneProtoMoney(intentAmount) } if settle == nil { - settle = cloneMoney(intentAmount) + settle = cloneProtoMoney(intentAmount) } return pay, settle case fxv1.Side_SELL_BASE_BUY_QUOTE: - pay := cloneMoney(fxQuote.GetBaseAmount()) - settle := cloneMoney(fxQuote.GetQuoteAmount()) + pay := cloneProtoMoney(fxQuote.GetBaseAmount()) + settle := cloneProtoMoney(fxQuote.GetQuoteAmount()) if pay == nil { - pay = cloneMoney(intentAmount) + pay = cloneProtoMoney(intentAmount) } if settle == nil { - settle = cloneMoney(intentAmount) + settle = cloneProtoMoney(intentAmount) } return pay, settle default: - return cloneMoney(intentAmount), cloneMoney(intentAmount) + return cloneProtoMoney(intentAmount), cloneProtoMoney(intentAmount) } } @@ -151,7 +157,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est } debitDecimal, err := decimalFromMoney(pay) if err != nil { - return cloneMoney(pay), cloneMoney(settlement) + return cloneProtoMoney(pay), cloneProtoMoney(settlement) } settlementCurrency := pay.GetCurrency() @@ -187,7 +193,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est } switch mode { - case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED: + case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: // Sender pays the fee: keep settlement fixed, increase debit. applyChargeToDebit(fee) default: @@ -197,7 +203,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est if network != nil && network.GetNetworkFee() != nil { switch mode { - case orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED: + case orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED: applyChargeToDebit(network.GetNetworkFee()) default: applyChargeToSettlement(network.GetNetworkFee()) @@ -207,7 +213,7 @@ func computeAggregates(pay, settlement, fee *moneyv1.Money, network *chainv1.Est return makeMoney(pay.GetCurrency(), debitDecimal), makeMoney(settlementCurrency, settlementDecimal) } -func decimalFromMoney(m *moneyv1.Money) (decimal.Decimal, error) { +func decimalFromMoney(m moneyGetter) (decimal.Decimal, error) { if m == nil { return decimal.Zero, nil } @@ -226,7 +232,7 @@ func ensureCurrency(m *moneyv1.Money, targetCurrency string, quote *oraclev1.Quo return nil, nil } if strings.EqualFold(m.GetCurrency(), targetCurrency) { - return cloneMoney(m), nil + return cloneProtoMoney(m), nil } return convertWithQuote(m, quote, targetCurrency) } @@ -270,8 +276,8 @@ func quoteToProto(src *oracleclient.Quote) *oraclev1.Quote { Pair: src.Pair, Side: src.Side, Price: &moneyv1.Decimal{Value: src.Price}, - BaseAmount: cloneMoney(src.BaseAmount), - QuoteAmount: cloneMoney(src.QuoteAmount), + BaseAmount: cloneProtoMoney(src.BaseAmount), + QuoteAmount: cloneProtoMoney(src.QuoteAmount), ExpiresAtUnixMs: src.ExpiresAt.UnixMilli(), Provider: src.Provider, RateRef: src.RateRef, @@ -288,7 +294,7 @@ func ledgerChargesFromFeeLines(lines []*feesv1.DerivedPostingLine) []*ledgerv1.P if line == nil || strings.TrimSpace(line.GetLedgerAccountRef()) == "" { continue } - money := cloneMoney(line.GetMoney()) + money := cloneProtoMoney(line.GetMoney()) if money == nil { continue } @@ -335,17 +341,17 @@ func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote return expiry } -func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown { +func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []rail.FeeBreakdown { if quote == nil { return nil } lines := quote.GetFeeLines() - breakdown := make([]*chainv1.ServiceFeeBreakdown, 0, len(lines)+1) + breakdown := make([]rail.FeeBreakdown, 0, len(lines)+1) for _, line := range lines { if line == nil { continue } - amount := cloneMoney(line.GetMoney()) + amount := moneyFromProto(line.GetMoney()) if amount == nil { continue } @@ -357,16 +363,16 @@ func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.Servic code = line.GetLineType().String() } desc := strings.TrimSpace(line.GetMeta()["description"]) - breakdown = append(breakdown, &chainv1.ServiceFeeBreakdown{ + breakdown = append(breakdown, rail.FeeBreakdown{ FeeCode: code, Amount: amount, Description: desc, }) } if quote.GetNetworkFee() != nil && quote.GetNetworkFee().GetNetworkFee() != nil { - networkAmount := cloneMoney(quote.GetNetworkFee().GetNetworkFee()) + networkAmount := moneyFromProto(quote.GetNetworkFee().GetNetworkFee()) if networkAmount != nil { - breakdown = append(breakdown, &chainv1.ServiceFeeBreakdown{ + breakdown = append(breakdown, rail.FeeBreakdown{ FeeCode: "network_fee", Amount: networkAmount, Description: strings.TrimSpace(quote.GetNetworkFee().GetEstimationContext()), @@ -395,7 +401,7 @@ func assignLedgerAccounts(lines []*feesv1.DerivedPostingLine, account string) [] return lines } -func moneyEquals(a, b *moneyv1.Money) bool { +func moneyEquals(a, b moneyGetter) bool { if a == nil || b == nil { return false } diff --git a/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go index 768ff0a..8b10f79 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/helpers_test.go @@ -48,7 +48,7 @@ func TestComputeAggregatesConvertsCurrencies(t *testing.T) { }, } - debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED) + debit, settlement := computeAggregates(pay, settle, fee, network, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED) if debit.GetCurrency() != "USD" || debit.GetAmount() != "115" { t.Fatalf("expected debit 115 USD, got %s %s", debit.GetCurrency(), debit.GetAmount()) } @@ -69,7 +69,7 @@ func TestComputeAggregatesRecipientPaysFee(t *testing.T) { }, } - debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE) + debit, settlement := computeAggregates(pay, settle, fee, nil, fxQuote, orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE) if debit.GetCurrency() != "USDT" || debit.GetAmount() != "100" { t.Fatalf("expected debit 100 USDT, got %s %s", debit.GetCurrency(), debit.GetAmount()) } diff --git a/api/payments/orchestrator/internal/service/orchestrator/model_money.go b/api/payments/orchestrator/internal/service/orchestrator/model_money.go new file mode 100644 index 0000000..3a8184d --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/model_money.go @@ -0,0 +1,13 @@ +package orchestrator + +import paymenttypes "github.com/tech/sendico/pkg/payments/types" + +func cloneMoney(input *paymenttypes.Money) *paymenttypes.Money { + if input == nil { + return nil + } + return &paymenttypes.Money{ + Currency: input.GetCurrency(), + Amount: input.GetAmount(), + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/options.go b/api/payments/orchestrator/internal/service/orchestrator/options.go index 708f6e8..fb46a1b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/options.go +++ b/api/payments/orchestrator/internal/service/orchestrator/options.go @@ -1,6 +1,8 @@ package orchestrator import ( + "context" + "sort" "strings" "time" @@ -8,7 +10,10 @@ import ( chainclient "github.com/tech/sendico/gateway/chain/client" mntxclient "github.com/tech/sendico/gateway/mntx/client" ledgerclient "github.com/tech/sendico/ledger/client" + "github.com/tech/sendico/payments/orchestrator/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/payments/rail" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" ) @@ -25,7 +30,8 @@ func (f feesDependency) available() bool { } type ledgerDependency struct { - client ledgerclient.Client + client ledgerclient.Client + internal rail.InternalLedger } func (l ledgerDependency) available() bool { @@ -40,6 +46,72 @@ func (g gatewayDependency) available() bool { return g.client != nil } +type railGatewayDependency struct { + byID map[string]rail.RailGateway + byRail map[model.Rail][]rail.RailGateway + registry GatewayRegistry + chainClient chainclient.Client +} + +func (g railGatewayDependency) available() bool { + return len(g.byID) > 0 || len(g.byRail) > 0 || (g.registry != nil && g.chainClient != nil) +} + +func (g railGatewayDependency) resolve(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) { + if step == nil { + return nil, merrors.InvalidArgument("rail gateway: step is required") + } + if id := strings.TrimSpace(step.GatewayID); id != "" { + gw, ok := g.byID[id] + if !ok { + return nil, merrors.InvalidArgument("rail gateway: unknown gateway id") + } + return gw, nil + } + if len(g.byRail) == 0 { + return g.resolveDynamic(ctx, step) + } + list := g.byRail[step.Rail] + if len(list) == 0 { + return g.resolveDynamic(ctx, step) + } + return list[0], nil +} + +func (g railGatewayDependency) resolveDynamic(ctx context.Context, step *model.PaymentStep) (rail.RailGateway, error) { + if g.registry == nil || g.chainClient == nil { + return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail") + } + items, err := g.registry.List(ctx) + if err != nil { + return nil, err + } + for _, entry := range items { + if entry == nil || !entry.IsEnabled { + continue + } + if entry.Rail != step.Rail { + continue + } + if step.GatewayID != "" && entry.ID != step.GatewayID { + continue + } + cfg := chainclient.RailGatewayConfig{ + Rail: string(entry.Rail), + Network: entry.Network, + Capabilities: rail.RailCapabilities{ + CanPayIn: entry.Capabilities.CanPayIn, + CanPayOut: entry.Capabilities.CanPayOut, + CanReadBalance: entry.Capabilities.CanReadBalance, + CanSendFee: entry.Capabilities.CanSendFee, + RequiresObserveConfirm: entry.Capabilities.RequiresObserveConfirm, + }, + } + return chainclient.NewRailGateway(g.chainClient, cfg), nil + } + return nil, merrors.InvalidArgument("rail gateway: missing gateway for rail") +} + type oracleDependency struct { client oracleclient.Client } @@ -56,6 +128,14 @@ func (m mntxDependency) available() bool { return m.client != nil } +type gatewayRegistryDependency struct { + registry GatewayRegistry +} + +func (g gatewayRegistryDependency) available() bool { + return g.registry != nil +} + // CardGatewayRoute maps a gateway to its funding and fee destinations. type CardGatewayRoute struct { FundingAddress string @@ -76,7 +156,10 @@ func WithFeeEngine(client feesv1.FeeEngineClient, timeout time.Duration) Option // WithLedgerClient wires the ledger client. func WithLedgerClient(client ledgerclient.Client) Option { return func(s *Service) { - s.deps.ledger = ledgerDependency{client: client} + s.deps.ledger = ledgerDependency{ + client: client, + internal: client, + } } } @@ -87,6 +170,16 @@ func WithChainGatewayClient(client chainclient.Client) Option { } } +// WithRailGateways wires rail gateway adapters by instance ID. +func WithRailGateways(gateways map[string]rail.RailGateway) Option { + return func(s *Service) { + if len(gateways) == 0 { + return + } + s.deps.railGateways = buildRailGatewayDependency(gateways, s.deps.gatewayRegistry, s.deps.gateway.client) + } +} + // WithOracleClient wires the FX oracle client. func WithOracleClient(client oracleclient.Client) Option { return func(s *Service) { @@ -132,6 +225,29 @@ func WithFeeLedgerAccounts(routes map[string]string) Option { } } +// WithPlanBuilder wires a payment plan builder implementation. +func WithPlanBuilder(builder PlanBuilder) Option { + return func(s *Service) { + if builder != nil { + s.deps.planBuilder = builder + } + } +} + +// WithGatewayRegistry wires a registry of gateway instances for routing. +func WithGatewayRegistry(registry GatewayRegistry) Option { + return func(s *Service) { + if registry != nil { + s.deps.gatewayRegistry = registry + s.deps.railGateways.registry = registry + s.deps.railGateways.chainClient = s.deps.gateway.client + if s.deps.planBuilder == nil { + s.deps.planBuilder = &defaultPlanBuilder{} + } + } + } +} + // WithClock overrides the default clock. func WithClock(clock clockpkg.Clock) Option { return func(s *Service) { @@ -140,3 +256,45 @@ func WithClock(clock clockpkg.Clock) Option { } } } + +func buildRailGatewayDependency(gateways map[string]rail.RailGateway, registry GatewayRegistry, chainClient chainclient.Client) railGatewayDependency { + result := railGatewayDependency{ + byID: map[string]rail.RailGateway{}, + byRail: map[model.Rail][]rail.RailGateway{}, + registry: registry, + chainClient: chainClient, + } + if len(gateways) == 0 { + return result + } + + type item struct { + id string + gw rail.RailGateway + } + itemsByRail := map[model.Rail][]item{} + + for id, gw := range gateways { + cleanID := strings.TrimSpace(id) + if cleanID == "" || gw == nil { + continue + } + result.byID[cleanID] = gw + railID := parseRailValue(gw.Rail()) + if railID == model.RailUnspecified { + continue + } + itemsByRail[railID] = append(itemsByRail[railID], item{id: cleanID, gw: gw}) + } + + for railID, items := range itemsByRail { + sort.Slice(items, func(i, j int) bool { + return items[i].id < items[j].id + }) + for _, entry := range items { + result.byRail[railID] = append(result.byRail[railID], entry.gw) + } + } + + return result +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go index 382b5ae..104e96b 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_executor.go @@ -12,7 +12,6 @@ import ( chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" - "go.mongodb.org/mongo-driver/bson/primitive" "go.uber.org/zap" ) @@ -30,132 +29,30 @@ func (p *paymentExecutor) executePayment(ctx context.Context, store storage.Paym if store == nil { return errStorageUnavailable } - - charges := ledgerChargesFromFeeLines(quote.GetFeeLines()) - ledgerNeeded := requiresLedger(payment) - chainNeeded := requiresChain(payment) - cardNeeded := payment.Intent.Destination.Type == model.EndpointTypeCard - - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} + if p.svc == nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "service_unavailable", errStorageUnavailable) } - - if ledgerNeeded { - if !p.deps.ledger.available() { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, "ledger_client_unavailable", merrors.Internal("ledger_client_unavailable")) - } - if err := p.performLedgerOperation(ctx, payment, quote, charges); err != nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodeLedger, strings.TrimSpace(err.Error()), err) - } - payment.State = model.PaymentStateFundsReserved - if err := p.persistPayment(ctx, store, payment); err != nil { - return err - } - p.logger.Info("ledger reservation completed", zap.String("payment_ref", payment.PaymentRef)) + if p.svc.storage == nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable) } - - if chainNeeded { - if !p.deps.gateway.available() { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, "chain_client_unavailable", merrors.Internal("chain_client_unavailable")) - } - resp, err := p.submitChainTransfer(ctx, payment, quote) - if err != nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err) - } - exec = payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - if resp != nil && resp.GetTransfer() != nil { - exec.ChainTransferRef = strings.TrimSpace(resp.GetTransfer().GetTransferRef()) - } - payment.Execution = exec - payment.State = model.PaymentStateSubmitted - if err := p.persistPayment(ctx, store, payment); err != nil { - return err - } - p.logger.Info("chain transfer submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("transfer_ref", exec.ChainTransferRef)) - if !cardNeeded { - return nil - } + routeStore := p.svc.storage.Routes() + if routeStore == nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "routes_store_unavailable", errStorageUnavailable) } - - if cardNeeded { - if !p.deps.mntx.available() { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "card_gateway_unavailable", merrors.Internal("card_gateway_unavailable")) - } - if err := p.svc.submitCardFundingTransfers(ctx, payment, quote); err != nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodeChain, strings.TrimSpace(err.Error()), err) - } - if err := p.svc.submitCardPayout(ctx, payment); err != nil { - return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) - } - payment.State = model.PaymentStateSubmitted - if err := p.persistPayment(ctx, store, payment); err != nil { - return err - } - p.logger.Info("card payout submitted", zap.String("payment_ref", payment.PaymentRef), zap.String("card_payout_ref", payment.Execution.CardPayoutRef)) - return nil + builder := p.svc.deps.planBuilder + if builder == nil { + builder = &defaultPlanBuilder{} } - - payment.State = model.PaymentStateSettled - if err := p.persistPayment(ctx, store, payment); err != nil { - return err + plan, err := builder.Build(ctx, payment, quote, routeStore, p.svc.deps.gatewayRegistry) + if err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) } - p.logger.Info("payment settled without chain", zap.String("payment_ref", payment.PaymentRef)) - return nil -} - -func (p *paymentExecutor) performLedgerOperation(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine) error { - intent := payment.Intent - if payment.OrganizationRef == primitive.NilObjectID { - return merrors.InvalidArgument("ledger: organization_ref is required") + if plan == nil || len(plan.Steps) == 0 { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment_plan_empty", merrors.InvalidArgument("payment plan is required")) } + payment.PaymentPlan = plan - amount := cloneMoney(intent.Amount) - if amount == nil { - return merrors.InvalidArgument("ledger: amount is required") - } - - description := paymentDescription(payment) - metadata := cloneMetadata(payment.Metadata) - exec := payment.Execution - if exec == nil { - exec = &model.ExecutionRefs{} - } - - switch intent.Kind { - case model.PaymentKindFXConversion: - if err := p.applyFX(ctx, payment, quote, charges, description, metadata, exec); err != nil { - return err - } - case model.PaymentKindInternalTransfer, model.PaymentKindPayout, model.PaymentKindUnspecified: - from, to, err := resolveLedgerAccounts(intent) - if err != nil { - return err - } - req := &ledgerv1.TransferRequest{ - IdempotencyKey: payment.IdempotencyKey, - OrganizationRef: payment.OrganizationRef.Hex(), - FromLedgerAccountRef: from, - ToLedgerAccountRef: to, - Money: amount, - Description: description, - Charges: charges, - Metadata: metadata, - } - resp, err := p.deps.ledger.client.TransferInternal(ctx, req) - if err != nil { - return err - } - exec.DebitEntryRef = strings.TrimSpace(resp.GetJournalEntryRef()) - payment.Execution = exec - default: - return merrors.InvalidArgument("ledger: unsupported payment kind") - } - - return nil + return p.executePaymentPlan(ctx, store, payment, quote) } func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, description string, metadata map[string]string, exec *model.ExecutionRefs) error { @@ -171,14 +68,14 @@ func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, q } fxSide := fxv1.Side_SIDE_UNSPECIFIED if intent.FX != nil { - fxSide = intent.FX.Side + fxSide = fxSideToProto(intent.FX.Side) } - fromMoney, toMoney := resolveTradeAmounts(intent.Amount, fq, fxSide) + fromMoney, toMoney := resolveTradeAmounts(protoMoney(intent.Amount), fq, fxSide) if fromMoney == nil { - fromMoney = cloneMoney(intent.Amount) + fromMoney = protoMoney(intent.Amount) } if toMoney == nil { - toMoney = cloneMoney(quote.GetExpectedSettlementAmount()) + toMoney = cloneProtoMoney(quote.GetExpectedSettlementAmount()) } rate := "" if fq.GetPrice() != nil { @@ -205,35 +102,6 @@ func (p *paymentExecutor) applyFX(ctx context.Context, payment *model.Payment, q return nil } -func (p *paymentExecutor) submitChainTransfer(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote) (*chainv1.SubmitTransferResponse, error) { - intent := payment.Intent - source := intent.Source.ManagedWallet - destination := intent.Destination - if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { - return nil, merrors.InvalidArgument("chain: source managed wallet is required") - } - dest, err := toGatewayDestination(destination) - if err != nil { - return nil, err - } - amount := cloneMoney(intent.Amount) - if amount == nil { - return nil, merrors.InvalidArgument("chain: amount is required") - } - fees := feeBreakdownFromQuote(quote) - req := &chainv1.SubmitTransferRequest{ - IdempotencyKey: payment.IdempotencyKey, - OrganizationRef: payment.OrganizationRef.Hex(), - SourceWalletRef: strings.TrimSpace(source.ManagedWalletRef), - Destination: dest, - Amount: amount, - Fees: fees, - Metadata: cloneMetadata(payment.Metadata), - ClientReference: payment.PaymentRef, - } - return p.deps.gateway.client.SubmitTransfer(ctx, req) -} - func (p *paymentExecutor) persistPayment(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { if store == nil { return errStorageUnavailable @@ -271,79 +139,6 @@ func paymentDescription(payment *model.Payment) string { return payment.PaymentRef } -func resolveLedgerAccounts(intent model.PaymentIntent) (string, string, error) { - source := intent.Source.Ledger - destination := intent.Destination.Ledger - if source == nil || strings.TrimSpace(source.LedgerAccountRef) == "" { - return "", "", merrors.InvalidArgument("ledger: source account is required") - } - to := "" - if destination != nil && strings.TrimSpace(destination.LedgerAccountRef) != "" { - to = strings.TrimSpace(destination.LedgerAccountRef) - } else if strings.TrimSpace(source.ContraLedgerAccountRef) != "" { - to = strings.TrimSpace(source.ContraLedgerAccountRef) - } - if to == "" { - return "", "", merrors.InvalidArgument("ledger: destination account is required") - } - return strings.TrimSpace(source.LedgerAccountRef), to, nil -} - -func requiresLedger(payment *model.Payment) bool { - if payment == nil { - return false - } - if payment.Intent.Kind == model.PaymentKindFXConversion { - return true - } - return hasLedgerEndpoint(payment.Intent.Source) || hasLedgerEndpoint(payment.Intent.Destination) -} - -func requiresChain(payment *model.Payment) bool { - if payment == nil { - return false - } - if !hasManagedWallet(payment.Intent.Source) { - return false - } - switch payment.Intent.Destination.Type { - case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain: - return true - default: - return false - } -} - -func hasLedgerEndpoint(endpoint model.PaymentEndpoint) bool { - return endpoint.Type == model.EndpointTypeLedger && endpoint.Ledger != nil && strings.TrimSpace(endpoint.Ledger.LedgerAccountRef) != "" -} - -func hasManagedWallet(endpoint model.PaymentEndpoint) bool { - return endpoint.Type == model.EndpointTypeManagedWallet && endpoint.ManagedWallet != nil && strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef) != "" -} - -func toGatewayDestination(endpoint model.PaymentEndpoint) (*chainv1.TransferDestination, error) { - switch endpoint.Type { - case model.EndpointTypeManagedWallet: - if endpoint.ManagedWallet == nil || strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef) == "" { - return nil, merrors.InvalidArgument("chain: destination managed wallet is required") - } - return &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ManagedWalletRef{ManagedWalletRef: strings.TrimSpace(endpoint.ManagedWallet.ManagedWalletRef)}, - }, nil - case model.EndpointTypeExternalChain: - if endpoint.ExternalChain == nil || strings.TrimSpace(endpoint.ExternalChain.Address) == "" { - return nil, merrors.InvalidArgument("chain: external address is required") - } - return &chainv1.TransferDestination{ - Destination: &chainv1.TransferDestination_ExternalAddress{ExternalAddress: strings.TrimSpace(endpoint.ExternalChain.Address)}, - Memo: strings.TrimSpace(endpoint.ExternalChain.Memo), - }, nil - default: - return nil, merrors.InvalidArgument("chain: unsupported destination type") - } -} - func applyTransferStatus(event *chainv1.TransferStatusChangedEvent, payment *model.Payment) { if payment.Execution == nil { payment.Execution = &model.ExecutionRefs{} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go new file mode 100644 index 0000000..590da4a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_card.go @@ -0,0 +1,170 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" +) + +func (p *paymentExecutor) submitCardPayoutPlan(ctx context.Context, payment *model.Payment, amount *moneyv1.Money) (string, error) { + if payment == nil { + return "", merrors.InvalidArgument("payment is required") + } + if !p.deps.mntx.available() { + return "", merrors.Internal("card_gateway_unavailable") + } + intent := payment.Intent + card := intent.Destination.Card + if card == nil { + return "", merrors.InvalidArgument("card payout: card endpoint is required") + } + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return "", merrors.InvalidArgument("card payout: amount is required") + } + + amtDec, err := decimalFromMoney(amount) + if err != nil { + return "", err + } + minor := amtDec.Mul(decimal.NewFromInt(100)).IntPart() + + payoutID := payment.PaymentRef + currency := strings.TrimSpace(amount.GetCurrency()) + holder := strings.TrimSpace(card.Cardholder) + meta := cloneMetadata(payment.Metadata) + customer := intent.Customer + customerID := "" + customerFirstName := "" + customerMiddleName := "" + customerLastName := "" + customerIP := "" + customerZip := "" + customerCountry := "" + customerState := "" + customerCity := "" + customerAddress := "" + if customer != nil { + customerID = strings.TrimSpace(customer.ID) + customerFirstName = strings.TrimSpace(customer.FirstName) + customerMiddleName = strings.TrimSpace(customer.MiddleName) + customerLastName = strings.TrimSpace(customer.LastName) + customerIP = strings.TrimSpace(customer.IP) + customerZip = strings.TrimSpace(customer.Zip) + customerCountry = strings.TrimSpace(customer.Country) + customerState = strings.TrimSpace(customer.State) + customerCity = strings.TrimSpace(customer.City) + customerAddress = strings.TrimSpace(customer.Address) + } + if customerFirstName == "" { + customerFirstName = strings.TrimSpace(card.Cardholder) + } + if customerLastName == "" { + customerLastName = strings.TrimSpace(card.CardholderSurname) + } + if customerID == "" { + return "", merrors.InvalidArgument("card payout: customer id is required") + } + if customerFirstName == "" { + return "", merrors.InvalidArgument("card payout: customer first name is required") + } + if customerLastName == "" { + return "", merrors.InvalidArgument("card payout: customer last name is required") + } + if customerIP == "" { + return "", merrors.InvalidArgument("card payout: customer ip is required") + } + + var state *mntxv1.CardPayoutState + if token := strings.TrimSpace(card.Token); token != "" { + req := &mntxv1.CardTokenPayoutRequest{ + PayoutId: payoutID, + CustomerId: customerID, + CustomerFirstName: customerFirstName, + CustomerMiddleName: customerMiddleName, + CustomerLastName: customerLastName, + CustomerIp: customerIP, + CustomerZip: customerZip, + CustomerCountry: customerCountry, + CustomerState: customerState, + CustomerCity: customerCity, + CustomerAddress: customerAddress, + AmountMinor: minor, + Currency: currency, + CardToken: token, + CardHolder: holder, + MaskedPan: strings.TrimSpace(card.MaskedPan), + Metadata: meta, + } + resp, err := p.deps.mntx.client.CreateCardTokenPayout(ctx, req) + if err != nil { + return "", err + } + state = resp.GetPayout() + } else if pan := strings.TrimSpace(card.Pan); pan != "" { + req := &mntxv1.CardPayoutRequest{ + PayoutId: payoutID, + CustomerId: customerID, + CustomerFirstName: customerFirstName, + CustomerMiddleName: customerMiddleName, + CustomerLastName: customerLastName, + CustomerIp: customerIP, + CustomerZip: customerZip, + CustomerCountry: customerCountry, + CustomerState: customerState, + CustomerCity: customerCity, + CustomerAddress: customerAddress, + AmountMinor: minor, + Currency: currency, + CardPan: pan, + CardExpYear: card.ExpYear, + CardExpMonth: card.ExpMonth, + CardHolder: holder, + Metadata: meta, + } + resp, err := p.deps.mntx.client.CreateCardPayout(ctx, req) + if err != nil { + return "", err + } + state = resp.GetPayout() + } else { + return "", merrors.InvalidArgument("card payout: either token or pan must be provided") + } + + if state == nil { + return "", merrors.Internal("card payout: missing payout state") + } + recordCardPayoutState(payment, state) + exec := ensureExecutionRefs(payment) + if exec.CardPayoutRef == "" { + exec.CardPayoutRef = strings.TrimSpace(state.GetPayoutId()) + } + return exec.CardPayoutRef, nil +} + +func (p *paymentExecutor) resolveCardRoute(intent model.PaymentIntent) (CardGatewayRoute, error) { + if p.svc != nil { + return p.svc.cardRoute(p.gatewayKeyFromIntent(intent)) + } + key := p.gatewayKeyFromIntent(intent) + route, ok := p.deps.cardRoutes[key] + if !ok { + return CardGatewayRoute{}, merrors.InvalidArgument("card routing missing for gateway " + key) + } + if strings.TrimSpace(route.FundingAddress) == "" { + return CardGatewayRoute{}, merrors.InvalidArgument("card funding address is required for gateway " + key) + } + return route, nil +} + +func (p *paymentExecutor) gatewayKeyFromIntent(intent model.PaymentIntent) string { + key := strings.TrimSpace(intent.Attributes["gateway"]) + if key == "" && intent.Destination.Card != nil { + key = defaultCardGateway + } + return strings.ToLower(key) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go new file mode 100644 index 0000000..13940f2 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_chain.go @@ -0,0 +1,106 @@ +package orchestrator + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/payments/rail" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func (p *paymentExecutor) buildCryptoTransferRequest(payment *model.Payment, amount *paymenttypes.Money, action model.RailOperation, idempotencyKey string, quote *orchestratorv1.PaymentQuote) (rail.TransferRequest, error) { + if payment == nil { + return rail.TransferRequest{}, merrors.InvalidArgument("chain: payment is required") + } + if amount == nil { + return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required") + } + source := payment.Intent.Source.ManagedWallet + if source == nil || strings.TrimSpace(source.ManagedWalletRef) == "" { + return rail.TransferRequest{}, merrors.InvalidArgument("chain: source managed wallet is required") + } + destRef, memo, err := p.resolveCryptoDestination(payment, action) + if err != nil { + return rail.TransferRequest{}, err + } + req := rail.TransferRequest{ + OrganizationRef: payment.OrganizationRef.Hex(), + FromAccountID: strings.TrimSpace(source.ManagedWalletRef), + ToAccountID: strings.TrimSpace(destRef), + Currency: strings.TrimSpace(amount.GetCurrency()), + Network: strings.TrimSpace(cryptoNetworkForPayment(payment)), + Amount: strings.TrimSpace(amount.GetAmount()), + IdempotencyKey: strings.TrimSpace(idempotencyKey), + Metadata: cloneMetadata(payment.Metadata), + ClientReference: payment.PaymentRef, + DestinationMemo: memo, + } + if req.Currency == "" || req.Amount == "" { + return rail.TransferRequest{}, merrors.InvalidArgument("chain: amount is required") + } + if req.IdempotencyKey == "" { + return rail.TransferRequest{}, merrors.InvalidArgument("chain: idempotency_key is required") + } + if action == model.RailOperationSend && quote != nil { + req.Fees = feeBreakdownFromQuote(quote) + } + return req, nil +} + +func (p *paymentExecutor) resolveCryptoDestination(payment *model.Payment, action model.RailOperation) (string, string, error) { + if payment == nil { + return "", "", merrors.InvalidArgument("chain: payment is required") + } + intent := payment.Intent + switch intent.Destination.Type { + case model.EndpointTypeManagedWallet: + if action == model.RailOperationSend { + if intent.Destination.ManagedWallet == nil || strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef) == "" { + return "", "", merrors.InvalidArgument("chain: destination managed wallet is required") + } + return strings.TrimSpace(intent.Destination.ManagedWallet.ManagedWalletRef), "", nil + } + case model.EndpointTypeExternalChain: + if action == model.RailOperationSend { + if intent.Destination.ExternalChain == nil || strings.TrimSpace(intent.Destination.ExternalChain.Address) == "" { + return "", "", merrors.InvalidArgument("chain: external address is required") + } + return strings.TrimSpace(intent.Destination.ExternalChain.Address), strings.TrimSpace(intent.Destination.ExternalChain.Memo), nil + } + } + route, err := p.resolveCardRoute(intent) + if err != nil { + return "", "", err + } + switch action { + case model.RailOperationSend: + address := strings.TrimSpace(route.FundingAddress) + if address == "" { + return "", "", merrors.InvalidArgument("chain: funding address is required") + } + return address, "", nil + case model.RailOperationFee: + if walletRef := strings.TrimSpace(route.FeeWalletRef); walletRef != "" { + return walletRef, "", nil + } + if address := strings.TrimSpace(route.FeeAddress); address != "" { + return address, "", nil + } + return "", "", merrors.InvalidArgument("chain: fee destination is required") + default: + return "", "", merrors.InvalidArgument("chain: unsupported action") + } +} + +func cryptoNetworkForPayment(payment *model.Payment) string { + if payment == nil { + return "" + } + network := networkFromEndpoint(payment.Intent.Source) + if network != "" { + return network + } + return networkFromEndpoint(payment.Intent.Destination) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go new file mode 100644 index 0000000..8fe66d6 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor.go @@ -0,0 +1,94 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func (p *paymentExecutor) executePaymentPlan(ctx context.Context, store storage.PaymentsStore, payment *model.Payment, quote *orchestratorv1.PaymentQuote) error { + if store == nil { + return errStorageUnavailable + } + if payment == nil { + return merrors.InvalidArgument("payment plan: payment is required") + } + plan := payment.PaymentPlan + if plan == nil || len(plan.Steps) == 0 { + return merrors.InvalidArgument("payment plan: steps are required") + } + + intent := payment.Intent + sourceRail, _, err := railFromEndpoint(intent.Source, intent.Attributes, true) + if err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) + } + destRail, _, err := railFromEndpoint(intent.Destination, intent.Attributes, false) + if err != nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, strings.TrimSpace(err.Error()), err) + } + + execQuote := executionQuote(payment, quote) + charges := ledgerChargesFromFeeLines(execQuote.GetFeeLines()) + + splitIdx := planSplitIndex(plan, sourceRail, destRail) + execPlan := ensureExecutionPlanForPlan(payment, plan, splitIdx) + asyncSubmitted := false + + for idx, step := range plan.Steps { + if step == nil { + return p.failPayment(ctx, store, payment, model.PaymentFailureCodePolicy, "payment plan: step is required", merrors.InvalidArgument("payment plan: step is required")) + } + execStep := execPlan.Steps[idx] + status := executionStepStatus(execStep) + switch status { + case executionStepStatusConfirmed, executionStepStatusSkipped: + continue + case executionStepStatusFailed: + payment.State = model.PaymentStateFailed + payment.FailureCode = failureCodeForStep(step) + return p.persistPayment(ctx, store, payment) + case executionStepStatusCancelled: + payment.State = model.PaymentStateCancelled + payment.FailureCode = model.PaymentFailureCodePolicy + return p.persistPayment(ctx, store, payment) + case executionStepStatusSubmitted: + asyncSubmitted = true + if isConsumerExecutionStep(execStep) || step.Action == model.RailOperationObserveConfirm { + payment.State = model.PaymentStateSubmitted + return p.persistPayment(ctx, store, payment) + } + continue + } + + if isConsumerExecutionStep(execStep) && !sourceStepsConfirmed(execPlan) { + payment.State = model.PaymentStateSubmitted + return p.persistPayment(ctx, store, payment) + } + + async, err := p.executePlanStep(ctx, payment, step, execStep, execQuote, charges, idx) + if err != nil { + return p.failPayment(ctx, store, payment, failureCodeForStep(step), strings.TrimSpace(err.Error()), err) + } + if async { + asyncSubmitted = true + if isConsumerExecutionStep(execStep) || step.Action == model.RailOperationObserveConfirm { + payment.State = model.PaymentStateSubmitted + return p.persistPayment(ctx, store, payment) + } + } + } + + if asyncSubmitted && !executionPlanComplete(execPlan) { + payment.State = model.PaymentStateSubmitted + return p.persistPayment(ctx, store, payment) + } + payment.State = model.PaymentStateSettled + payment.FailureCode = model.PaymentFailureCodeUnspecified + payment.FailureReason = "" + return p.persistPayment(ctx, store, payment) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go new file mode 100644 index 0000000..1b732d8 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_executor_test.go @@ -0,0 +1,184 @@ +package orchestrator + +import ( + "context" + "strings" + "testing" + + mntxclient "github.com/tech/sendico/gateway/mntx/client" + ledgerclient "github.com/tech/sendico/ledger/client" + "github.com/tech/sendico/payments/orchestrator/storage/model" + mo "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/payments/rail" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + mntxv1 "github.com/tech/sendico/pkg/proto/gateway/mntx/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +func TestExecutePaymentPlan_SourceBeforeDestination(t *testing.T) { + ctx := context.Background() + + store := newStubPaymentsStore() + repo := &stubRepository{store: store} + + transferRefs := []string{"send-1", "fee-1"} + sendCalls := 0 + railGateway := &fakeRailGateway{ + rail: "CRYPTO", + sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { + ref := transferRefs[sendCalls] + sendCalls++ + return rail.RailResult{ReferenceID: ref, Status: rail.TransferStatusPending}, nil + }, + } + + debitCalls := 0 + creditCalls := 0 + ledgerFake := &ledgerclient.Fake{ + CreateTransactionFn: func(ctx context.Context, tx rail.LedgerTx) (string, error) { + if strings.EqualFold(tx.FromRail, "LEDGER") { + debitCalls++ + return "debit-1", nil + } + if strings.EqualFold(tx.ToRail, "LEDGER") { + creditCalls++ + return "credit-1", nil + } + return "", nil + }, + } + + payoutCalls := 0 + mntxFake := &mntxclient.Fake{ + CreateCardPayoutFn: func(ctx context.Context, req *mntxv1.CardPayoutRequest) (*mntxv1.CardPayoutResponse, error) { + payoutCalls++ + return &mntxv1.CardPayoutResponse{Payout: &mntxv1.CardPayoutState{PayoutId: "payout-1"}}, nil + }, + } + + svc := &Service{ + logger: zap.NewNop(), + storage: repo, + deps: serviceDependencies{ + railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{ + "crypto-default": railGateway, + }, nil, nil), + ledger: ledgerDependency{ + client: ledgerFake, + internal: ledgerFake, + }, + mntx: mntxDependency{client: mntxFake}, + cardRoutes: map[string]CardGatewayRoute{ + defaultCardGateway: { + FundingAddress: "funding-address", + FeeWalletRef: "fee-wallet", + }, + }, + }, + } + + executor := newPaymentExecutor(&svc.deps, svc.logger, svc) + + payment := &model.Payment{ + PaymentRef: "pay-plan-1", + IdempotencyKey: "pay-plan-1", + OrganizationBoundBase: mo.OrganizationBoundBase{ + OrganizationRef: primitive.NewObjectID(), + }, + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-src", + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{ + Pan: "4111111111111111", + Cardholder: "Ada", + CardholderSurname: "Lovelace", + ExpMonth: 1, + ExpYear: 2030, + MaskedPan: "4111", + }, + }, + Attributes: map[string]string{ + "ledger_credit_account_ref": "ledger:credit", + "ledger_debit_account_ref": "ledger:debit", + }, + Customer: &model.Customer{ + ID: "cust-1", + FirstName: "Ada", + LastName: "Lovelace", + IP: "1.2.3.4", + }, + }, + PaymentPlan: &model.PaymentPlan{ + ID: "pay-plan-1", + IdempotencyKey: "pay-plan-1", + Steps: []*model.PaymentStep{ + {Rail: model.RailCrypto, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}}, + {Rail: model.RailCrypto, Action: model.RailOperationFee, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "5"}}, + {Rail: model.RailProviderSettlement, Action: model.RailOperationObserveConfirm}, + {Rail: model.RailLedger, Action: model.RailOperationCredit, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, + {Rail: model.RailLedger, Action: model.RailOperationDebit, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, + {Rail: model.RailCardPayout, Action: model.RailOperationSend, Amount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}}, + }, + }, + } + + store.payments[payment.PaymentRef] = payment + + if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil { + t.Fatalf("executePaymentPlan error: %v", err) + } + + if sendCalls != 2 { + t.Fatalf("expected 2 rail sends, got %d", sendCalls) + } + if debitCalls != 0 || creditCalls != 0 { + t.Fatalf("unexpected ledger calls: debit=%d credit=%d", debitCalls, creditCalls) + } + if payoutCalls != 0 { + t.Fatalf("expected no payout before source confirmation, got %d", payoutCalls) + } + if payment.State != model.PaymentStateSubmitted { + t.Fatalf("expected submitted state, got %s", payment.State) + } + if payment.Execution == nil || payment.Execution.ChainTransferRef == "" || payment.Execution.FeeTransferRef == "" { + t.Fatalf("expected chain and fee transfer refs set") + } + if payment.ExecutionPlan == nil || len(payment.ExecutionPlan.Steps) != 6 { + t.Fatalf("expected execution plan with 6 steps") + } + if executionStepStatus(payment.ExecutionPlan.Steps[0]) != executionStepStatusSubmitted { + t.Fatalf("expected send step submitted") + } + if executionStepStatus(payment.ExecutionPlan.Steps[1]) != executionStepStatusSubmitted { + t.Fatalf("expected fee step submitted") + } + if executionStepStatus(payment.ExecutionPlan.Steps[2]) != executionStepStatusSubmitted { + t.Fatalf("expected observe step submitted") + } + + setExecutionStepStatus(payment.ExecutionPlan.Steps[0], executionStepStatusConfirmed) + setExecutionStepStatus(payment.ExecutionPlan.Steps[1], executionStepStatusConfirmed) + setExecutionStepStatus(payment.ExecutionPlan.Steps[2], executionStepStatusConfirmed) + + if err := executor.executePaymentPlan(ctx, store, payment, &orchestratorv1.PaymentQuote{}); err != nil { + t.Fatalf("executePaymentPlan resume error: %v", err) + } + if debitCalls != 1 || creditCalls != 1 { + t.Fatalf("expected ledger calls after source confirmation, debit=%d credit=%d", debitCalls, creditCalls) + } + if payoutCalls != 1 { + t.Fatalf("expected card payout submitted, got %d", payoutCalls) + } + if payment.Execution == nil || payment.Execution.CardPayoutRef == "" { + t.Fatalf("expected card payout ref set") + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go new file mode 100644 index 0000000..3e5c1d3 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_helpers.go @@ -0,0 +1,165 @@ +package orchestrator + +import ( + "fmt" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func ensureExecutionRefs(payment *model.Payment) *model.ExecutionRefs { + if payment.Execution == nil { + payment.Execution = &model.ExecutionRefs{} + } + return payment.Execution +} + +func executionQuote(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *orchestratorv1.PaymentQuote { + if quote != nil { + return quote + } + if payment != nil && payment.LastQuote != nil { + return modelQuoteToProto(payment.LastQuote) + } + return &orchestratorv1.PaymentQuote{} +} + +func planSplitIndex(plan *model.PaymentPlan, sourceRail, destRail model.Rail) int { + if plan == nil { + return 0 + } + if sourceRail == model.RailLedger { + for idx, step := range plan.Steps { + if step == nil { + continue + } + if step.Rail != model.RailLedger { + return idx + } + } + return len(plan.Steps) + } + for idx, step := range plan.Steps { + if step == nil { + continue + } + if step.Rail == model.RailLedger && step.Action == model.RailOperationCredit { + return idx + } + } + for idx, step := range plan.Steps { + if step == nil { + continue + } + if step.Rail == destRail && step.Action == model.RailOperationSend { + return idx + } + } + return len(plan.Steps) +} + +func ensureExecutionPlanForPlan(payment *model.Payment, plan *model.PaymentPlan, splitIdx int) *model.ExecutionPlan { + if payment == nil || plan == nil { + return nil + } + execPlan := payment.ExecutionPlan + if execPlan == nil { + execPlan = &model.ExecutionPlan{} + payment.ExecutionPlan = execPlan + } + existing := map[string]*model.ExecutionStep{} + for _, step := range execPlan.Steps { + if step == nil || strings.TrimSpace(step.Code) == "" { + continue + } + existing[strings.TrimSpace(step.Code)] = step + } + steps := make([]*model.ExecutionStep, len(plan.Steps)) + for idx, planStep := range plan.Steps { + code := planStepCode(idx) + step := existing[code] + if step == nil { + step = &model.ExecutionStep{Code: code} + } + if step.Description == "" { + step.Description = describePlanStep(planStep) + } + step.Amount = cloneMoney(planStep.Amount) + if idx < splitIdx { + setExecutionStepRole(step, executionStepRoleSource) + } else { + setExecutionStepRole(step, executionStepRoleConsumer) + } + if step.Metadata == nil || strings.TrimSpace(step.Metadata[executionStepMetadataStatus]) == "" { + setExecutionStepStatus(step, executionStepStatusPlanned) + } + steps[idx] = step + } + execPlan.Steps = steps + return execPlan +} + +func executionPlanComplete(plan *model.ExecutionPlan) bool { + if plan == nil || len(plan.Steps) == 0 { + return false + } + for _, step := range plan.Steps { + if step == nil { + continue + } + status := executionStepStatus(step) + if status == executionStepStatusSkipped { + continue + } + if status != executionStepStatusConfirmed { + return false + } + } + return true +} + +func planStepCode(idx int) string { + return fmt.Sprintf("plan_step_%d", idx) +} + +func describePlanStep(step *model.PaymentStep) string { + if step == nil { + return "" + } + return strings.TrimSpace(fmt.Sprintf("%s %s", step.Rail, step.Action)) +} + +func planStepIdempotencyKey(payment *model.Payment, idx int, step *model.PaymentStep) string { + base := "" + if payment != nil { + base = strings.TrimSpace(payment.IdempotencyKey) + if base == "" { + base = strings.TrimSpace(payment.PaymentRef) + } + } + if base == "" { + base = "payment" + } + if step == nil { + return fmt.Sprintf("%s:plan:%d", base, idx) + } + return fmt.Sprintf("%s:plan:%d:%s:%s", base, idx, strings.ToLower(string(step.Rail)), strings.ToLower(string(step.Action))) +} + +func failureCodeForStep(step *model.PaymentStep) model.PaymentFailureCode { + if step == nil { + return model.PaymentFailureCodePolicy + } + switch step.Rail { + case model.RailLedger: + if step.Action == model.RailOperationFXConvert { + return model.PaymentFailureCodeFX + } + return model.PaymentFailureCodeLedger + case model.RailCrypto: + return model.PaymentFailureCodeChain + default: + return model.PaymentFailureCodePolicy + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go new file mode 100644 index 0000000..c32a86a --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_ledger.go @@ -0,0 +1,220 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/payments/rail" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "go.mongodb.org/mongo-driver/bson/primitive" +) + +func (p *paymentExecutor) postLedgerDebit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, quote *orchestratorv1.PaymentQuote) (string, error) { + if p.deps.ledger.internal == nil { + return "", merrors.Internal("ledger_client_unavailable") + } + tx, err := p.ledgerTxForAction(payment, amount, charges, idempotencyKey, idx, model.RailOperationDebit, quote) + if err != nil { + return "", err + } + return p.deps.ledger.internal.CreateTransaction(ctx, tx) +} + +func (p *paymentExecutor) postLedgerCredit(ctx context.Context, payment *model.Payment, amount *moneyv1.Money, idempotencyKey string, idx int, quote *orchestratorv1.PaymentQuote) (string, error) { + if p.deps.ledger.internal == nil { + return "", merrors.Internal("ledger_client_unavailable") + } + tx, err := p.ledgerTxForAction(payment, amount, nil, idempotencyKey, idx, model.RailOperationCredit, quote) + if err != nil { + return "", err + } + return p.deps.ledger.internal.CreateTransaction(ctx, tx) +} + +func (p *paymentExecutor) ledgerTxForAction(payment *model.Payment, amount *moneyv1.Money, charges []*ledgerv1.PostingLine, idempotencyKey string, idx int, action model.RailOperation, quote *orchestratorv1.PaymentQuote) (rail.LedgerTx, error) { + if payment == nil { + return rail.LedgerTx{}, merrors.InvalidArgument("ledger: payment is required") + } + if payment.OrganizationRef == primitive.NilObjectID { + return rail.LedgerTx{}, merrors.InvalidArgument("ledger: organization_ref is required") + } + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return rail.LedgerTx{}, merrors.InvalidArgument("ledger: amount is required") + } + + sourceRail, _, err := railFromEndpoint(payment.Intent.Source, payment.Intent.Attributes, true) + if err != nil { + sourceRail = model.RailUnspecified + } + destRail, _, err := railFromEndpoint(payment.Intent.Destination, payment.Intent.Attributes, false) + if err != nil { + destRail = model.RailUnspecified + } + + fromRail := model.RailUnspecified + toRail := model.RailUnspecified + accountRef := "" + contraRef := "" + externalRef := "" + + switch action { + case model.RailOperationDebit: + fromRail = model.RailLedger + toRail = ledgerStepToRail(payment.PaymentPlan, idx, destRail) + accountRef, contraRef, err = ledgerDebitAccount(payment) + case model.RailOperationCredit: + fromRail = ledgerStepFromRail(payment.PaymentPlan, idx, sourceRail) + toRail = model.RailLedger + accountRef, contraRef, err = ledgerCreditAccount(payment) + externalRef = ledgerExternalReference(payment.ExecutionPlan, idx) + default: + return rail.LedgerTx{}, merrors.InvalidArgument("ledger: unsupported action") + } + if err != nil { + return rail.LedgerTx{}, err + } + if action == model.RailOperationDebit && toRail == model.RailLedger { + toRail = model.RailUnspecified + } + if action == model.RailOperationCredit && fromRail == model.RailLedger { + fromRail = model.RailUnspecified + } + + planID := payment.PaymentRef + if payment.PaymentPlan != nil && strings.TrimSpace(payment.PaymentPlan.ID) != "" { + planID = strings.TrimSpace(payment.PaymentPlan.ID) + } + + feeAmount := "" + if action == model.RailOperationDebit { + if feeMoney := resolveFeeAmount(payment, quote); feeMoney != nil { + feeAmount = strings.TrimSpace(feeMoney.GetAmount()) + } + } + + fxRate := "" + if quote != nil && quote.GetFxQuote() != nil && quote.GetFxQuote().GetPrice() != nil { + fxRate = strings.TrimSpace(quote.GetFxQuote().GetPrice().GetValue()) + } + + return rail.LedgerTx{ + PaymentPlanID: planID, + Currency: strings.TrimSpace(amount.GetCurrency()), + Amount: strings.TrimSpace(amount.GetAmount()), + FeeAmount: feeAmount, + FromRail: ledgerRailValue(fromRail), + ToRail: ledgerRailValue(toRail), + ExternalReferenceID: externalRef, + FXRateUsed: fxRate, + IdempotencyKey: strings.TrimSpace(idempotencyKey), + CreatedAt: planTimestamp(payment), + OrganizationRef: payment.OrganizationRef.Hex(), + LedgerAccountRef: strings.TrimSpace(accountRef), + ContraLedgerAccountRef: strings.TrimSpace(contraRef), + Description: paymentDescription(payment), + Charges: charges, + Metadata: cloneMetadata(payment.Metadata), + }, nil +} + +func ledgerRailValue(railValue model.Rail) string { + if railValue == model.RailUnspecified || strings.TrimSpace(string(railValue)) == "" { + return "" + } + return string(railValue) +} + +func ledgerStepFromRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail { + if plan == nil || idx <= 0 { + return fallback + } + for i := idx - 1; i >= 0; i-- { + step := plan.Steps[i] + if step == nil { + continue + } + if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified { + return step.Rail + } + } + return fallback +} + +func ledgerStepToRail(plan *model.PaymentPlan, idx int, fallback model.Rail) model.Rail { + if plan == nil || idx < 0 { + return fallback + } + for i := idx + 1; i < len(plan.Steps); i++ { + step := plan.Steps[i] + if step == nil { + continue + } + if step.Rail != model.RailLedger && step.Rail != model.RailUnspecified { + return step.Rail + } + } + return fallback +} + +func ledgerExternalReference(plan *model.ExecutionPlan, idx int) string { + if plan == nil || idx <= 0 { + return "" + } + for i := idx - 1; i >= 0; i-- { + step := plan.Steps[i] + if step == nil { + continue + } + if ref := strings.TrimSpace(step.TransferRef); ref != "" { + return ref + } + } + return "" +} + +func ledgerDebitAccount(payment *model.Payment) (string, string, error) { + if payment == nil { + return "", "", merrors.InvalidArgument("ledger: payment is required") + } + intent := payment.Intent + if intent.Source.Ledger != nil && strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef) != "" { + return strings.TrimSpace(intent.Source.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Source.Ledger.ContraLedgerAccountRef), nil + } + if ref := attributeLookup(intent.Attributes, "ledger_debit_account_ref", "ledgerDebitAccountRef"); ref != "" { + return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_debit_contra_account_ref", "ledgerDebitContraAccountRef")), nil + } + return "", "", merrors.InvalidArgument("ledger: source account is required") +} + +func ledgerCreditAccount(payment *model.Payment) (string, string, error) { + if payment == nil { + return "", "", merrors.InvalidArgument("ledger: payment is required") + } + intent := payment.Intent + if intent.Destination.Ledger != nil && strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef) != "" { + return strings.TrimSpace(intent.Destination.Ledger.LedgerAccountRef), strings.TrimSpace(intent.Destination.Ledger.ContraLedgerAccountRef), nil + } + if ref := attributeLookup(intent.Attributes, "ledger_credit_account_ref", "ledgerCreditAccountRef"); ref != "" { + return ref, strings.TrimSpace(attributeLookup(intent.Attributes, "ledger_credit_contra_account_ref", "ledgerCreditContraAccountRef")), nil + } + return "", "", merrors.InvalidArgument("ledger: destination account is required") +} + +func attributeLookup(attrs map[string]string, keys ...string) string { + if len(keys) == 0 { + return "" + } + for _, key := range keys { + if key == "" || attrs == nil { + continue + } + if val := strings.TrimSpace(attrs[key]); val != "" { + return val + } + } + return "" +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go new file mode 100644 index 0000000..8da432e --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/payment_plan_steps.go @@ -0,0 +1,141 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func (p *paymentExecutor) executePlanStep(ctx context.Context, payment *model.Payment, step *model.PaymentStep, execStep *model.ExecutionStep, quote *orchestratorv1.PaymentQuote, charges []*ledgerv1.PostingLine, idx int) (bool, error) { + if payment == nil || step == nil || execStep == nil { + return false, merrors.InvalidArgument("payment plan: step is required") + } + + switch step.Action { + case model.RailOperationDebit: + amount, err := requireMoney(cloneMoney(step.Amount), "ledger debit amount") + if err != nil { + return false, err + } + ref, err := p.postLedgerDebit(ctx, payment, protoMoney(amount), charges, planStepIdempotencyKey(payment, idx, step), idx, quote) + if err != nil { + return false, err + } + ensureExecutionRefs(payment).DebitEntryRef = ref + setExecutionStepStatus(execStep, executionStepStatusConfirmed) + return false, nil + case model.RailOperationCredit: + amount, err := requireMoney(cloneMoney(step.Amount), "ledger credit amount") + if err != nil { + return false, err + } + ref, err := p.postLedgerCredit(ctx, payment, protoMoney(amount), planStepIdempotencyKey(payment, idx, step), idx, quote) + if err != nil { + return false, err + } + ensureExecutionRefs(payment).CreditEntryRef = ref + setExecutionStepStatus(execStep, executionStepStatusConfirmed) + return false, nil + case model.RailOperationFXConvert: + if err := p.applyFX(ctx, payment, quote, charges, paymentDescription(payment), cloneMetadata(payment.Metadata), ensureExecutionRefs(payment)); err != nil { + return false, err + } + setExecutionStepStatus(execStep, executionStepStatusConfirmed) + return false, nil + case model.RailOperationObserveConfirm: + setExecutionStepStatus(execStep, executionStepStatusSubmitted) + return true, nil + case model.RailOperationSend: + return p.executeSendStep(ctx, payment, step, execStep, quote, idx) + case model.RailOperationFee: + return p.executeFeeStep(ctx, payment, step, execStep, idx) + default: + return false, merrors.InvalidArgument("payment plan: unsupported action") + } +} + +func (p *paymentExecutor) executeSendStep(ctx context.Context, payment *model.Payment, step *model.PaymentStep, execStep *model.ExecutionStep, quote *orchestratorv1.PaymentQuote, idx int) (bool, error) { + switch step.Rail { + case model.RailCrypto: + amount, err := requireMoney(cloneMoney(step.Amount), "crypto send amount") + if err != nil { + return false, err + } + if !p.deps.railGateways.available() { + return false, merrors.Internal("rail gateway unavailable") + } + req, err := p.buildCryptoTransferRequest(payment, amount, model.RailOperationSend, planStepIdempotencyKey(payment, idx, step), quote) + if err != nil { + return false, err + } + gw, err := p.deps.railGateways.resolve(ctx, step) + if err != nil { + return false, err + } + result, err := gw.Send(ctx, req) + if err != nil { + return false, err + } + execStep.TransferRef = strings.TrimSpace(result.ReferenceID) + exec := ensureExecutionRefs(payment) + if exec.ChainTransferRef == "" && execStep.TransferRef != "" { + exec.ChainTransferRef = execStep.TransferRef + } + setExecutionStepStatus(execStep, executionStepStatusSubmitted) + return true, nil + case model.RailCardPayout: + amount, err := requireMoney(cloneMoney(step.Amount), "card payout amount") + if err != nil { + return false, err + } + ref, err := p.submitCardPayoutPlan(ctx, payment, protoMoney(amount)) + if err != nil { + return false, err + } + execStep.TransferRef = ref + ensureExecutionRefs(payment).CardPayoutRef = ref + setExecutionStepStatus(execStep, executionStepStatusSubmitted) + return true, nil + case model.RailFiatOnRamp: + return false, merrors.InvalidArgument("payment plan: fiat on-ramp execution not implemented") + default: + return false, merrors.InvalidArgument("payment plan: unsupported send rail") + } +} + +func (p *paymentExecutor) executeFeeStep(ctx context.Context, payment *model.Payment, step *model.PaymentStep, execStep *model.ExecutionStep, idx int) (bool, error) { + switch step.Rail { + case model.RailCrypto: + amount, err := requireMoney(cloneMoney(step.Amount), "crypto fee amount") + if err != nil { + return false, err + } + if !p.deps.railGateways.available() { + return false, merrors.Internal("rail gateway unavailable") + } + req, err := p.buildCryptoTransferRequest(payment, amount, model.RailOperationFee, planStepIdempotencyKey(payment, idx, step), nil) + if err != nil { + return false, err + } + gw, err := p.deps.railGateways.resolve(ctx, step) + if err != nil { + return false, err + } + result, err := gw.Send(ctx, req) + if err != nil { + return false, err + } + execStep.TransferRef = strings.TrimSpace(result.ReferenceID) + if execStep.TransferRef != "" { + ensureExecutionRefs(payment).FeeTransferRef = execStep.TransferRef + } + setExecutionStepStatus(execStep, executionStepStatusSubmitted) + return true, nil + default: + return false, merrors.InvalidArgument("payment plan: unsupported fee rail") + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go new file mode 100644 index 0000000..d256389 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder.go @@ -0,0 +1,23 @@ +package orchestrator + +import ( + "context" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +// RouteStore exposes routing definitions for plan construction. +type RouteStore interface { + List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) +} + +// GatewayRegistry exposes gateway instances for capability-based selection. +type GatewayRegistry interface { + List(ctx context.Context) ([]*model.GatewayInstanceDescriptor, error) +} + +// PlanBuilder constructs ordered payment plans from intents, quotes, and routing policy. +type PlanBuilder interface { + Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, gateways GatewayRegistry) (*model.PaymentPlan, error) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go new file mode 100644 index 0000000..2fe9a56 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default.go @@ -0,0 +1,51 @@ +package orchestrator + +import ( + "context" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +type defaultPlanBuilder struct{} + +func (b *defaultPlanBuilder) Build(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, routes RouteStore, gateways GatewayRegistry) (*model.PaymentPlan, error) { + if payment == nil { + return nil, merrors.InvalidArgument("plan builder: payment is required") + } + if routes == nil { + return nil, merrors.InvalidArgument("plan builder: routes store is required") + } + + intent := payment.Intent + if intent.Kind == model.PaymentKindFXConversion { + return buildFXConversionPlan(payment) + } + + sourceRail, sourceNetwork, err := railFromEndpoint(intent.Source, intent.Attributes, true) + if err != nil { + return nil, err + } + destRail, destNetwork, err := railFromEndpoint(intent.Destination, intent.Attributes, false) + if err != nil { + return nil, err + } + + if sourceRail == model.RailUnspecified || destRail == model.RailUnspecified { + return nil, merrors.InvalidArgument("plan builder: source and destination rails are required") + } + if sourceRail == destRail { + if sourceRail == model.RailLedger { + return buildLedgerTransferPlan(payment) + } + return nil, merrors.InvalidArgument("plan builder: unsupported same-rail payment") + } + + path, err := buildRoutePath(ctx, routes, sourceRail, destRail, sourceNetwork, destNetwork) + if err != nil { + return nil, err + } + + return b.buildPlanFromRoutePath(ctx, payment, quote, path, sourceRail, destRail, sourceNetwork, destNetwork, gateways) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go new file mode 100644 index 0000000..44f6bcb --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_default_test.go @@ -0,0 +1,202 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func TestDefaultPlanBuilder_BuildsPlanFromRoutes_CryptoToCard(t *testing.T) { + ctx := context.Background() + builder := &defaultPlanBuilder{} + + payment := &model.Payment{ + PaymentRef: "pay-1", + IdempotencyKey: "idem-1", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-1", + Asset: &paymenttypes.Asset{ + Chain: "TRON", + TokenSymbol: "USDT", + }, + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{MaskedPan: "4111"}, + }, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "100"}, + }, + LastQuote: &model.PaymentQuoteSnapshot{ + ExpectedSettlementAmount: &paymenttypes.Money{Currency: "USDT", Amount: "95"}, + ExpectedFeeTotal: &paymenttypes.Money{Currency: "USDT", Amount: "5"}, + }, + } + + quote := &orchestratorv1.PaymentQuote{ + ExpectedSettlementAmount: &moneyv1.Money{Currency: "USDT", Amount: "95"}, + ExpectedFeeTotal: &moneyv1.Money{Currency: "USDT", Amount: "5"}, + } + + routes := &stubRouteStore{ + routes: []*model.PaymentRoute{ + {FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON", RequiresObserve: true, IsEnabled: true}, + {FromRail: model.RailProviderSettlement, ToRail: model.RailLedger, IsEnabled: true}, + {FromRail: model.RailLedger, ToRail: model.RailCardPayout, IsEnabled: true}, + }, + } + + registry := &stubGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto-tron", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + CanSendFee: true, + }, + Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, + IsEnabled: true, + }, + { + ID: "settlement", + Rail: model.RailProviderSettlement, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + RequiresObserveConfirm: true, + }, + Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, + IsEnabled: true, + }, + { + ID: "card", + Rail: model.RailCardPayout, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, + IsEnabled: true, + }, + }, + } + + plan, err := builder.Build(ctx, payment, quote, routes, registry) + if err != nil { + t.Fatalf("expected plan, got error: %v", err) + } + if plan == nil { + t.Fatal("expected plan") + } + if len(plan.Steps) != 6 { + t.Fatalf("expected 6 steps, got %d", len(plan.Steps)) + } + + assertPlanStep(t, plan.Steps[0], model.RailCrypto, model.RailOperationSend, "crypto-tron", "USDT", "100") + assertPlanStep(t, plan.Steps[1], model.RailCrypto, model.RailOperationFee, "crypto-tron", "USDT", "5") + assertPlanStep(t, plan.Steps[2], model.RailProviderSettlement, model.RailOperationObserveConfirm, "settlement", "", "") + assertPlanStep(t, plan.Steps[3], model.RailLedger, model.RailOperationCredit, "", "USDT", "95") + assertPlanStep(t, plan.Steps[4], model.RailLedger, model.RailOperationDebit, "", "USDT", "95") + assertPlanStep(t, plan.Steps[5], model.RailCardPayout, model.RailOperationSend, "card", "USDT", "95") +} + +func TestDefaultPlanBuilder_ErrorsWhenRouteMissing(t *testing.T) { + ctx := context.Background() + builder := &defaultPlanBuilder{} + + payment := &model.Payment{ + PaymentRef: "pay-1", + IdempotencyKey: "idem-1", + Intent: model.PaymentIntent{ + Kind: model.PaymentKindPayout, + Source: model.PaymentEndpoint{ + Type: model.EndpointTypeManagedWallet, + ManagedWallet: &model.ManagedWalletEndpoint{ + ManagedWalletRef: "wallet-1", + Asset: &paymenttypes.Asset{Chain: "TRON"}, + }, + }, + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{MaskedPan: "4111"}, + }, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "10"}, + }, + } + + routes := &stubRouteStore{} + registry := &stubGatewayRegistry{} + + plan, err := builder.Build(ctx, payment, &orchestratorv1.PaymentQuote{}, routes, registry) + if err == nil { + t.Fatalf("expected error, got plan: %#v", plan) + } +} + +// --- test doubles --- + +type stubRouteStore struct { + routes []*model.PaymentRoute +} + +func (s *stubRouteStore) List(_ context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) { + items := make([]*model.PaymentRoute, 0, len(s.routes)) + for _, route := range s.routes { + if route == nil { + continue + } + if filter != nil && filter.IsEnabled != nil { + if route.IsEnabled != *filter.IsEnabled { + continue + } + } + items = append(items, route) + } + return &model.PaymentRouteList{Items: items}, nil +} + +type stubGatewayRegistry struct { + items []*model.GatewayInstanceDescriptor +} + +func (s *stubGatewayRegistry) List(_ context.Context) ([]*model.GatewayInstanceDescriptor, error) { + return s.items, nil +} + +func assertPlanStep(t *testing.T, step *model.PaymentStep, rail model.Rail, action model.RailOperation, gatewayID, currency, amount string) { + t.Helper() + if step == nil { + t.Fatal("expected step") + } + if step.Rail != rail { + t.Fatalf("expected rail %s, got %s", rail, step.Rail) + } + if step.Action != action { + t.Fatalf("expected action %s, got %s", action, step.Action) + } + if step.GatewayID != gatewayID { + t.Fatalf("expected gateway %q, got %q", gatewayID, step.GatewayID) + } + if currency == "" && amount == "" { + if step.Amount != nil && step.Amount.Amount != "" { + t.Fatalf("expected empty amount, got %v", step.Amount) + } + return + } + if step.Amount == nil { + t.Fatalf("expected amount %s %s, got nil", currency, amount) + } + if step.Amount.GetCurrency() != currency || step.Amount.GetAmount() != amount { + t.Fatalf("expected amount %s %s, got %s %s", currency, amount, step.Amount.GetCurrency(), step.Amount.GetAmount()) + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go new file mode 100644 index 0000000..10ab26c --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_endpoints.go @@ -0,0 +1,135 @@ +package orchestrator + +import ( + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" +) + +func railFromEndpoint(endpoint model.PaymentEndpoint, attrs map[string]string, isSource bool) (model.Rail, string, error) { + override := railOverrideFromAttributes(attrs, isSource) + if override != model.RailUnspecified { + return override, networkFromEndpoint(endpoint), nil + } + switch endpoint.Type { + case model.EndpointTypeLedger: + return model.RailLedger, "", nil + case model.EndpointTypeManagedWallet, model.EndpointTypeExternalChain: + return model.RailCrypto, networkFromEndpoint(endpoint), nil + case model.EndpointTypeCard: + return model.RailCardPayout, "", nil + default: + return model.RailUnspecified, "", merrors.InvalidArgument("plan builder: unsupported payment endpoint") + } +} + +func railOverrideFromAttributes(attrs map[string]string, isSource bool) model.Rail { + if len(attrs) == 0 { + return model.RailUnspecified + } + keys := []string{"source_rail", "sourceRail"} + if !isSource { + keys = []string{"destination_rail", "destinationRail"} + } + lookup := map[string]struct{}{} + for _, key := range keys { + lookup[strings.ToLower(key)] = struct{}{} + } + for key, value := range attrs { + if _, ok := lookup[strings.ToLower(strings.TrimSpace(key))]; !ok { + continue + } + rail := parseRailValue(value) + if rail != model.RailUnspecified { + return rail + } + } + return model.RailUnspecified +} + +func parseRailValue(value string) model.Rail { + val := strings.ToUpper(strings.TrimSpace(value)) + switch val { + 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 + } +} + +func gatewayNetworkForRail(rail model.Rail, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string { + switch rail { + case model.RailCrypto: + if sourceRail == model.RailCrypto { + return strings.ToUpper(strings.TrimSpace(sourceNetwork)) + } + if destRail == model.RailCrypto { + return strings.ToUpper(strings.TrimSpace(destNetwork)) + } + case model.RailFiatOnRamp: + if sourceRail == model.RailFiatOnRamp { + return strings.ToUpper(strings.TrimSpace(sourceNetwork)) + } + if destRail == model.RailFiatOnRamp { + return strings.ToUpper(strings.TrimSpace(destNetwork)) + } + } + return "" +} + +func networkFromEndpoint(endpoint model.PaymentEndpoint) string { + switch endpoint.Type { + case model.EndpointTypeManagedWallet: + if endpoint.ManagedWallet != nil && endpoint.ManagedWallet.Asset != nil { + return strings.ToUpper(strings.TrimSpace(endpoint.ManagedWallet.Asset.GetChain())) + } + case model.EndpointTypeExternalChain: + if endpoint.ExternalChain != nil && endpoint.ExternalChain.Asset != nil { + return strings.ToUpper(strings.TrimSpace(endpoint.ExternalChain.Asset.GetChain())) + } + } + return "" +} + +func chainNetworkName(network chainv1.ChainNetwork) string { + switch network { + case chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET: + return "ETH" + case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET: + return "TRON" + case chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE: + return "TRON_NILE" + case chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE: + return "ARBITRUM" + default: + name := strings.TrimSpace(network.String()) + name = strings.TrimPrefix(name, "CHAIN_NETWORK_") + return strings.ToUpper(name) + } +} + +func chainNetworkFromName(network string) chainv1.ChainNetwork { + name := strings.ToUpper(strings.TrimSpace(network)) + switch name { + case "ETH", "ETHEREUM", "ETHEREUM_MAINNET": + return chainv1.ChainNetwork_CHAIN_NETWORK_ETHEREUM_MAINNET + case "TRON", "TRON_MAINNET": + return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_MAINNET + case "TRON_NILE": + return chainv1.ChainNetwork_CHAIN_NETWORK_TRON_NILE + case "ARBITRUM", "ARBITRUM_ONE": + return chainv1.ChainNetwork_CHAIN_NETWORK_ARBITRUM_ONE + default: + return chainv1.ChainNetwork_CHAIN_NETWORK_UNSPECIFIED + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go new file mode 100644 index 0000000..3954cb0 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_gateways.go @@ -0,0 +1,213 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + + "github.com/shopspring/decimal" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +func ensureGatewayForAction(ctx context.Context, registry GatewayRegistry, cache map[model.Rail]*model.GatewayInstanceDescriptor, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { + if registry == nil { + return nil, merrors.InvalidArgument("plan builder: gateway registry is required") + } + if gw, ok := cache[rail]; ok && gw != nil { + if err := validateGatewayAction(gw, network, amount, action, dir); err != nil { + return nil, err + } + return gw, nil + } + gw, err := selectGateway(ctx, registry, rail, network, amount, action, dir) + if err != nil { + return nil, err + } + cache[rail] = gw + return gw, nil +} + +func validateGatewayAction(gw *model.GatewayInstanceDescriptor, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) error { + if gw == nil { + return merrors.InvalidArgument("plan builder: gateway instance is required") + } + currency := "" + amt := decimal.Zero + if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" { + value, err := decimalFromMoney(amount) + if err != nil { + return err + } + amt = value + currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + } + if !isGatewayEligible(gw, gw.Rail, network, currency, action, dir, amt) { + return merrors.InvalidArgument("plan builder: gateway instance is not eligible") + } + return nil +} + +type sendDirection int + +const ( + sendDirectionAny sendDirection = iota + sendDirectionOut + sendDirectionIn +) + +func sendDirectionForRail(rail model.Rail) sendDirection { + switch rail { + case model.RailFiatOnRamp: + return sendDirectionIn + default: + return sendDirectionOut + } +} + +func selectGateway(ctx context.Context, registry GatewayRegistry, rail model.Rail, network string, amount *paymenttypes.Money, action model.RailOperation, dir sendDirection) (*model.GatewayInstanceDescriptor, error) { + if registry == nil { + return nil, merrors.InvalidArgument("plan builder: gateway registry is required") + } + all, err := registry.List(ctx) + if err != nil { + return nil, err + } + if len(all) == 0 { + return nil, merrors.InvalidArgument("plan builder: no gateway instances available") + } + + currency := "" + amt := decimal.Zero + if amount != nil && strings.TrimSpace(amount.GetAmount()) != "" { + amt, err = decimalFromMoney(amount) + if err != nil { + return nil, err + } + currency = strings.ToUpper(strings.TrimSpace(amount.GetCurrency())) + } + network = strings.ToUpper(strings.TrimSpace(network)) + + eligible := make([]*model.GatewayInstanceDescriptor, 0) + for _, gw := range all { + if !isGatewayEligible(gw, rail, network, currency, action, dir, amt) { + continue + } + eligible = append(eligible, gw) + } + if len(eligible) == 0 { + return nil, merrors.InvalidArgument("plan builder: no eligible gateway instance found") + } + sort.Slice(eligible, func(i, j int) bool { + return eligible[i].ID < eligible[j].ID + }) + return eligible[0], nil +} + +func isGatewayEligible(gw *model.GatewayInstanceDescriptor, rail model.Rail, network, currency string, action model.RailOperation, dir sendDirection, amount decimal.Decimal) bool { + if gw == nil || !gw.IsEnabled { + return false + } + if gw.Rail != rail { + return false + } + if network != "" && gw.Network != "" && !strings.EqualFold(gw.Network, network) { + return false + } + if currency != "" && len(gw.Currencies) > 0 { + found := false + for _, c := range gw.Currencies { + if strings.EqualFold(c, currency) { + found = true + break + } + } + if !found { + return false + } + } + + if !capabilityAllowsAction(gw.Capabilities, action, dir) { + return false + } + + if currency != "" { + if !amountWithinLimits(gw.Limits, currency, amount, action) { + return false + } + } + return true +} + +func capabilityAllowsAction(cap model.RailCapabilities, action model.RailOperation, dir sendDirection) bool { + switch action { + case model.RailOperationSend: + switch dir { + case sendDirectionOut: + return cap.CanPayOut + case sendDirectionIn: + return cap.CanPayIn + default: + return cap.CanPayIn || cap.CanPayOut + } + case model.RailOperationFee: + return cap.CanSendFee + case model.RailOperationObserveConfirm: + return cap.RequiresObserveConfirm + default: + return true + } +} + +func amountWithinLimits(limits model.Limits, currency string, amount decimal.Decimal, action model.RailOperation) bool { + min := firstLimitValue(limits.MinAmount, "") + max := firstLimitValue(limits.MaxAmount, "") + perTxMin := firstLimitValue(limits.PerTxMinAmount, "") + perTxMax := firstLimitValue(limits.PerTxMaxAmount, "") + maxFee := firstLimitValue(limits.PerTxMaxFee, "") + + if override, ok := limits.CurrencyLimits[currency]; ok { + min = firstLimitValue(override.MinAmount, min) + max = firstLimitValue(override.MaxAmount, max) + if action == model.RailOperationFee { + maxFee = firstLimitValue(override.MaxFee, maxFee) + } + } + + if min != "" { + if val, err := decimal.NewFromString(min); err == nil && amount.LessThan(val) { + return false + } + } + if perTxMin != "" { + if val, err := decimal.NewFromString(perTxMin); err == nil && amount.LessThan(val) { + return false + } + } + if max != "" { + if val, err := decimal.NewFromString(max); err == nil && amount.GreaterThan(val) { + return false + } + } + if perTxMax != "" { + if val, err := decimal.NewFromString(perTxMax); err == nil && amount.GreaterThan(val) { + return false + } + } + if action == model.RailOperationFee && maxFee != "" { + if val, err := decimal.NewFromString(maxFee); err == nil && amount.GreaterThan(val) { + return false + } + } + + return true +} + +func firstLimitValue(primary, fallback string) string { + val := strings.TrimSpace(primary) + if val != "" { + return val + } + return strings.TrimSpace(fallback) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go new file mode 100644 index 0000000..e23ad4e --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_plans.go @@ -0,0 +1,90 @@ +package orchestrator + +import ( + "time" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func buildFXConversionPlan(payment *model.Payment) (*model.PaymentPlan, error) { + if payment == nil { + return nil, merrors.InvalidArgument("plan builder: payment is required") + } + step := &model.PaymentStep{ + Rail: model.RailLedger, + Action: model.RailOperationFXConvert, + Amount: cloneMoney(payment.Intent.Amount), + } + return &model.PaymentPlan{ + ID: payment.PaymentRef, + Steps: []*model.PaymentStep{step}, + IdempotencyKey: payment.IdempotencyKey, + CreatedAt: planTimestamp(payment), + }, nil +} + +func buildLedgerTransferPlan(payment *model.Payment) (*model.PaymentPlan, error) { + if payment == nil { + return nil, merrors.InvalidArgument("plan builder: payment is required") + } + amount := cloneMoney(payment.Intent.Amount) + steps := []*model.PaymentStep{ + { + Rail: model.RailLedger, + Action: model.RailOperationDebit, + Amount: cloneMoney(amount), + }, + { + Rail: model.RailLedger, + Action: model.RailOperationCredit, + Amount: cloneMoney(amount), + }, + } + return &model.PaymentPlan{ + ID: payment.PaymentRef, + Steps: steps, + IdempotencyKey: payment.IdempotencyKey, + CreatedAt: planTimestamp(payment), + }, nil +} + +func resolveSettlementAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuote, fallback *paymenttypes.Money) *paymenttypes.Money { + if quote != nil && quote.GetExpectedSettlementAmount() != nil { + return moneyFromProto(quote.GetExpectedSettlementAmount()) + } + if payment != nil && payment.LastQuote != nil { + return cloneMoney(payment.LastQuote.ExpectedSettlementAmount) + } + return cloneMoney(fallback) +} + +func resolveFeeAmount(payment *model.Payment, quote *orchestratorv1.PaymentQuote) *paymenttypes.Money { + if quote != nil && quote.GetExpectedFeeTotal() != nil { + return moneyFromProto(quote.GetExpectedFeeTotal()) + } + if payment != nil && payment.LastQuote != nil { + return cloneMoney(payment.LastQuote.ExpectedFeeTotal) + } + return nil +} + +func isPositiveMoney(amount *paymenttypes.Money) bool { + if amount == nil { + return false + } + val, err := decimalFromMoney(amount) + if err != nil { + return false + } + return val.IsPositive() +} + +func planTimestamp(payment *model.Payment) time.Time { + if payment != nil && !payment.CreatedAt.IsZero() { + return payment.CreatedAt.UTC() + } + return time.Now().UTC() +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go new file mode 100644 index 0000000..8c78890 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_routes.go @@ -0,0 +1,149 @@ +package orchestrator + +import ( + "context" + "sort" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" +) + +func buildRoutePath(ctx context.Context, routes RouteStore, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) ([]*model.PaymentRoute, error) { + if routes == nil { + return nil, merrors.InvalidArgument("plan builder: routes store is required") + } + enabled := true + result, err := routes.List(ctx, &model.PaymentRouteFilter{IsEnabled: &enabled}) + if err != nil { + return nil, err + } + if result == nil || len(result.Items) == 0 { + return nil, merrors.InvalidArgument("plan builder: route not allowed") + } + network := routeNetworkForPath(sourceRail, destRail, sourceNetwork, destNetwork) + path, err := routePath(result.Items, sourceRail, destRail, network) + if err != nil { + return nil, err + } + return path, nil +} + +func routePath(routes []*model.PaymentRoute, sourceRail, destRail model.Rail, network string) ([]*model.PaymentRoute, error) { + if sourceRail == destRail { + return nil, nil + } + adjacency := map[model.Rail][]*model.PaymentRoute{} + for _, route := range routes { + if route == nil || !route.IsEnabled { + continue + } + from := route.FromRail + to := route.ToRail + if from == "" || to == "" || from == model.RailUnspecified || to == model.RailUnspecified { + continue + } + adjacency[from] = append(adjacency[from], route) + } + + for rail, edges := range adjacency { + sort.Slice(edges, func(i, j int) bool { + pi := routePriority(edges[i], network) + pj := routePriority(edges[j], network) + if pi != pj { + return pi < pj + } + if edges[i].ToRail != edges[j].ToRail { + return edges[i].ToRail < edges[j].ToRail + } + if edges[i].Network != edges[j].Network { + return edges[i].Network < edges[j].Network + } + return edges[i].ID.Hex() < edges[j].ID.Hex() + }) + adjacency[rail] = edges + } + + queue := []model.Rail{sourceRail} + visited := map[model.Rail]bool{sourceRail: true} + parents := map[model.Rail]*model.PaymentRoute{} + found := false + + for len(queue) > 0 && !found { + current := queue[0] + queue = queue[1:] + for _, route := range adjacency[current] { + if !routeMatchesNetwork(route, network) { + continue + } + next := route.ToRail + if visited[next] { + continue + } + visited[next] = true + parents[next] = route + if next == destRail { + found = true + break + } + queue = append(queue, next) + } + } + + if !found { + return nil, merrors.InvalidArgument("plan builder: route not allowed") + } + + path := make([]*model.PaymentRoute, 0) + for current := destRail; current != sourceRail; { + edge := parents[current] + if edge == nil { + return nil, merrors.InvalidArgument("plan builder: route not allowed") + } + path = append(path, edge) + current = edge.FromRail + } + + for i, j := 0, len(path)-1; i < j; i, j = i+1, j-1 { + path[i], path[j] = path[j], path[i] + } + return path, nil +} + +func routeNetworkForPath(sourceRail, destRail model.Rail, sourceNetwork, destNetwork string) string { + if sourceRail == model.RailCrypto || sourceRail == model.RailFiatOnRamp { + return strings.ToUpper(strings.TrimSpace(sourceNetwork)) + } + if destRail == model.RailCrypto || destRail == model.RailFiatOnRamp { + return strings.ToUpper(strings.TrimSpace(destNetwork)) + } + return "" +} + +func routeMatchesNetwork(route *model.PaymentRoute, network string) bool { + if route == nil { + return false + } + routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) + if strings.TrimSpace(network) == "" { + return routeNetwork == "" + } + if routeNetwork == "" { + return true + } + return strings.EqualFold(routeNetwork, network) +} + +func routePriority(route *model.PaymentRoute, network string) int { + if route == nil { + return 2 + } + routeNetwork := strings.ToUpper(strings.TrimSpace(route.Network)) + if network != "" && strings.EqualFold(routeNetwork, network) { + return 0 + } + if routeNetwork == "" { + return 1 + } + return 2 +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go new file mode 100644 index 0000000..0caa863 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/plan_builder_steps.go @@ -0,0 +1,257 @@ +package orchestrator + +import ( + "context" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/merrors" + paymenttypes "github.com/tech/sendico/pkg/payments/types" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func (b *defaultPlanBuilder) buildPlanFromRoutePath(ctx context.Context, payment *model.Payment, quote *orchestratorv1.PaymentQuote, path []*model.PaymentRoute, sourceRail, destRail model.Rail, sourceNetwork, destNetwork string, gateways GatewayRegistry) (*model.PaymentPlan, error) { + if len(path) == 0 { + return nil, merrors.InvalidArgument("plan builder: route path is required") + } + + sourceAmount, err := requireMoney(cloneMoney(payment.Intent.Amount), "amount") + if err != nil { + return nil, err + } + settlementAmount, err := requireMoney(resolveSettlementAmount(payment, quote, sourceAmount), "settlement amount") + if err != nil { + return nil, err + } + feeAmount := resolveFeeAmount(payment, quote) + feeRequired := isPositiveMoney(feeAmount) + + var payoutAmount *paymenttypes.Money + if destRail == model.RailCardPayout { + payoutAmount, err = cardPayoutAmount(payment) + if err != nil { + return nil, err + } + } + + ledgerCreditAmount := settlementAmount + ledgerDebitAmount := settlementAmount + if destRail == model.RailCardPayout && payoutAmount != nil { + ledgerDebitAmount = payoutAmount + } + + observeRequired := observeRailsFromPath(path) + intermediate := intermediateRailsFromPath(path, sourceRail, destRail) + + steps := make([]*model.PaymentStep, 0) + gatewaysByRail := map[model.Rail]*model.GatewayInstanceDescriptor{} + observeAdded := map[model.Rail]bool{} + useSourceSend := isSendSourceRail(sourceRail) + useDestSend := isSendDestinationRail(destRail) + + for idx, edge := range path { + if edge == nil { + continue + } + from := edge.FromRail + to := edge.ToRail + + if from == model.RailLedger { + if _, err := requireMoney(ledgerDebitAmount, "ledger debit amount"); err != nil { + return nil, err + } + steps = append(steps, &model.PaymentStep{ + Rail: model.RailLedger, + Action: model.RailOperationDebit, + Amount: cloneMoney(ledgerDebitAmount), + }) + } + + if idx == 0 && useSourceSend && from == sourceRail { + network := gatewayNetworkForRail(from, sourceRail, destRail, sourceNetwork, destNetwork) + gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, from, network, sourceAmount, model.RailOperationSend, sendDirectionForRail(from)) + if err != nil { + return nil, err + } + steps = append(steps, &model.PaymentStep{ + Rail: from, + GatewayID: gw.ID, + Action: model.RailOperationSend, + Amount: cloneMoney(sourceAmount), + }) + if feeRequired { + if err := validateGatewayAction(gw, network, feeAmount, model.RailOperationFee, sendDirectionForRail(from)); err != nil { + return nil, err + } + steps = append(steps, &model.PaymentStep{ + Rail: from, + GatewayID: gw.ID, + Action: model.RailOperationFee, + Amount: cloneMoney(feeAmount), + }) + } + if shouldObserveRail(from, observeRequired, gw) && !observeAdded[from] { + observeAmount := observeAmountForRail(from, sourceAmount, settlementAmount, payoutAmount) + if err := validateGatewayAction(gw, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny); err != nil { + return nil, err + } + steps = append(steps, &model.PaymentStep{ + Rail: from, + GatewayID: gw.ID, + Action: model.RailOperationObserveConfirm, + }) + observeAdded[from] = true + } + } + + if intermediate[to] && !observeAdded[to] { + observeAmount := observeAmountForRail(to, sourceAmount, settlementAmount, payoutAmount) + network := gatewayNetworkForRail(to, sourceRail, destRail, sourceNetwork, destNetwork) + gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, to, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny) + if err != nil { + return nil, err + } + steps = append(steps, &model.PaymentStep{ + Rail: to, + GatewayID: gw.ID, + Action: model.RailOperationObserveConfirm, + }) + observeAdded[to] = true + } + + if to == model.RailLedger { + if _, err := requireMoney(ledgerCreditAmount, "ledger credit amount"); err != nil { + return nil, err + } + steps = append(steps, &model.PaymentStep{ + Rail: model.RailLedger, + Action: model.RailOperationCredit, + Amount: cloneMoney(ledgerCreditAmount), + }) + } + + if idx == len(path)-1 && useDestSend && to == destRail { + if payoutAmount == nil { + payoutAmount = settlementAmount + } + network := gatewayNetworkForRail(to, sourceRail, destRail, sourceNetwork, destNetwork) + gw, err := ensureGatewayForAction(ctx, gateways, gatewaysByRail, to, network, payoutAmount, model.RailOperationSend, sendDirectionForRail(to)) + if err != nil { + return nil, err + } + steps = append(steps, &model.PaymentStep{ + Rail: to, + GatewayID: gw.ID, + Action: model.RailOperationSend, + Amount: cloneMoney(payoutAmount), + }) + if shouldObserveRail(to, observeRequired, gw) && !observeAdded[to] { + observeAmount := observeAmountForRail(to, sourceAmount, settlementAmount, payoutAmount) + if err := validateGatewayAction(gw, network, observeAmount, model.RailOperationObserveConfirm, sendDirectionAny); err != nil { + return nil, err + } + steps = append(steps, &model.PaymentStep{ + Rail: to, + GatewayID: gw.ID, + Action: model.RailOperationObserveConfirm, + }) + observeAdded[to] = true + } + } + } + + if len(steps) == 0 { + return nil, merrors.InvalidArgument("plan builder: empty payment plan") + } + + return &model.PaymentPlan{ + ID: payment.PaymentRef, + Steps: steps, + IdempotencyKey: payment.IdempotencyKey, + CreatedAt: planTimestamp(payment), + }, nil +} + +func observeRailsFromPath(path []*model.PaymentRoute) map[model.Rail]bool { + observe := map[model.Rail]bool{} + for _, edge := range path { + if edge == nil || !edge.RequiresObserve { + continue + } + rail := edge.ToRail + if rail == model.RailLedger || rail == model.RailUnspecified { + rail = edge.FromRail + } + if rail == model.RailLedger || rail == model.RailUnspecified { + continue + } + observe[rail] = true + } + return observe +} + +func intermediateRailsFromPath(path []*model.PaymentRoute, sourceRail, destRail model.Rail) map[model.Rail]bool { + intermediate := map[model.Rail]bool{} + for _, edge := range path { + if edge == nil { + continue + } + rail := edge.ToRail + if rail == model.RailLedger || rail == sourceRail || rail == destRail || rail == model.RailUnspecified { + continue + } + intermediate[rail] = true + } + return intermediate +} + +func isSendSourceRail(rail model.Rail) bool { + switch rail { + case model.RailCrypto, model.RailFiatOnRamp: + return true + default: + return false + } +} + +func isSendDestinationRail(rail model.Rail) bool { + return rail == model.RailCardPayout +} + +func shouldObserveRail(rail model.Rail, observeRequired map[model.Rail]bool, gw *model.GatewayInstanceDescriptor) bool { + if observeRequired[rail] { + return true + } + if gw != nil && gw.Capabilities.RequiresObserveConfirm { + return true + } + return false +} + +func observeAmountForRail(rail model.Rail, source, settlement, payout *paymenttypes.Money) *paymenttypes.Money { + switch rail { + case model.RailCrypto, model.RailFiatOnRamp: + if source != nil { + return source + } + case model.RailProviderSettlement: + if settlement != nil { + return settlement + } + case model.RailCardPayout: + if payout != nil { + return payout + } + } + if settlement != nil { + return settlement + } + return source +} + +func requireMoney(amount *paymenttypes.Money, label string) (*paymenttypes.Money, error) { + if amount == nil || strings.TrimSpace(amount.GetAmount()) == "" || strings.TrimSpace(amount.GetCurrency()) == "" { + return nil, merrors.InvalidArgument("plan builder: " + label + " is required") + } + return amount, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go index 5d9a4a8..5ca73f7 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_engine.go @@ -39,7 +39,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc feeBaseAmount := payAmount if feeBaseAmount == nil { - feeBaseAmount = cloneMoney(amount) + feeBaseAmount = cloneProtoMoney(amount) } feeQuote, err := s.quoteFees(ctx, orgRef, req, feeBaseAmount) @@ -87,9 +87,9 @@ func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestrato return &feesv1.PrecomputeFeesResponse{}, nil } intent := req.GetIntent() - amount := cloneMoney(baseAmount) + amount := cloneProtoMoney(baseAmount) if amount == nil { - amount = cloneMoney(intent.GetAmount()) + amount = cloneProtoMoney(intent.GetAmount()) } feeIntent := &feesv1.Intent{ Trigger: triggerFromKind(intent.GetKind(), intent.GetRequiresFx()), @@ -123,7 +123,7 @@ func (s *Service) estimateNetworkFee(ctx context.Context, intent *orchestratorv1 } req := &chainv1.EstimateTransferFeeRequest{ - Amount: cloneMoney(intent.GetAmount()), + Amount: cloneProtoMoney(intent.GetAmount()), } if src := intent.GetSource().GetManagedWallet(); src != nil { req.SourceWalletRef = strings.TrimSpace(src.GetManagedWalletRef()) @@ -191,14 +191,14 @@ func (s *Service) requestFXQuote(ctx context.Context, orgRef string, req *orches if pair != nil { switch { case strings.EqualFold(amount.GetCurrency(), pair.GetBase()): - params.BaseAmount = cloneMoney(amount) + params.BaseAmount = cloneProtoMoney(amount) case strings.EqualFold(amount.GetCurrency(), pair.GetQuote()): - params.QuoteAmount = cloneMoney(amount) + params.QuoteAmount = cloneProtoMoney(amount) default: - params.BaseAmount = cloneMoney(amount) + params.BaseAmount = cloneProtoMoney(amount) } } else { - params.BaseAmount = cloneMoney(amount) + params.BaseAmount = cloneProtoMoney(amount) } } diff --git a/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go b/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go new file mode 100644 index 0000000..3748f26 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/rail_gateway_fake_test.go @@ -0,0 +1,41 @@ +package orchestrator + +import ( + "context" + + "github.com/tech/sendico/pkg/payments/rail" +) + +type fakeRailGateway struct { + rail string + network string + capabilities rail.RailCapabilities + sendFn func(context.Context, rail.TransferRequest) (rail.RailResult, error) + observeFn func(context.Context, string) (rail.ObserveResult, error) +} + +func (f *fakeRailGateway) Rail() string { + return f.rail +} + +func (f *fakeRailGateway) Network() string { + return f.network +} + +func (f *fakeRailGateway) Capabilities() rail.RailCapabilities { + return f.capabilities +} + +func (f *fakeRailGateway) Send(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { + if f.sendFn != nil { + return f.sendFn(ctx, req) + } + return rail.RailResult{ReferenceID: "transfer-1", Status: rail.TransferStatusPending}, nil +} + +func (f *fakeRailGateway) Observe(ctx context.Context, referenceID string) (rail.ObserveResult, error) { + if f.observeFn != nil { + return f.observeFn(ctx, referenceID) + } + return rail.ObserveResult{ReferenceID: referenceID, Status: rail.TransferStatusPending}, nil +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 249375d..2cffdd6 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -44,10 +44,13 @@ type serviceDependencies struct { fees feesDependency ledger ledgerDependency gateway gatewayDependency + railGateways railGatewayDependency oracle oracleDependency mntx mntxDependency + gatewayRegistry GatewayRegistry cardRoutes map[string]CardGatewayRoute feeLedgerAccounts map[string]string + planBuilder PlanBuilder } type handlerSet struct { @@ -83,7 +86,7 @@ func NewService(logger mlogger.Logger, repo storage.Repository, opts ...Option) engine := defaultPaymentEngine{svc: svc} svc.h.commands = newPaymentCommandFactory(engine, svc.logger) svc.h.queries = newPaymentQueryHandler(svc.storage, svc.ensureRepository, svc.logger.Named("queries")) - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events")) + svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger.Named("events"), svc.submitCardPayout, svc.resumePaymentPlan) svc.comp.executor = newPaymentExecutor(&svc.deps, svc.logger.Named("payment_executor"), svc) return svc @@ -97,7 +100,7 @@ func (s *Service) ensureHandlers() { s.h.queries = newPaymentQueryHandler(s.storage, s.ensureRepository, s.logger.Named("queries")) } if s.h.events == nil { - s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events")) + s.h.events = newPaymentEventHandler(s.storage, s.ensureRepository, s.logger.Named("events"), s.submitCardPayout, s.resumePaymentPlan) } if s.comp.executor == nil { s.comp.executor = newPaymentExecutor(&s.deps, s.logger.Named("payment_executor"), s) @@ -181,3 +184,11 @@ func (s *Service) executePayment(ctx context.Context, store storage.PaymentsStor s.ensureHandlers() return s.comp.executor.executePayment(ctx, store, payment, quote) } + +func (s *Service) resumePaymentPlan(ctx context.Context, store storage.PaymentsStore, payment *model.Payment) error { + if payment == nil || payment.PaymentPlan == nil || len(payment.PaymentPlan.Steps) == 0 { + return nil + } + s.ensureHandlers() + return s.comp.executor.executePaymentPlan(ctx, store, payment, nil) +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go index 4908b82..0d8f58d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_helpers_test.go @@ -5,11 +5,13 @@ import ( "testing" "time" + ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" clockpkg "github.com/tech/sendico/pkg/clock" mloggerfactory "github.com/tech/sendico/pkg/mlogger/factory" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" "go.mongodb.org/mongo-driver/bson/primitive" ) @@ -49,7 +51,7 @@ func TestNewPayment(t *testing.T) { org := primitive.NewObjectID() intent := &orchestratorv1.PaymentIntent{ Amount: &moneyv1.Money{Currency: "USD", Amount: "10"}, - SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED, + SettlementMode: orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, } quote := &orchestratorv1.PaymentQuote{QuoteRef: "q1"} p := newPayment(org, intent, "idem", map[string]string{"k": "v"}, quote) @@ -59,7 +61,7 @@ func TestNewPayment(t *testing.T) { if p.Intent.Amount == nil || p.Intent.Amount.GetAmount() != "10" { t.Fatalf("intent not copied") } - if p.Intent.SettlementMode != orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED { + if p.Intent.SettlementMode != model.SettlementModeFixReceived { t.Fatalf("settlement mode not preserved") } if p.LastQuote == nil || p.LastQuote.QuoteRef != "q1" { @@ -143,12 +145,27 @@ func TestInitiatePaymentIdempotency(t *testing.T) { logger := mloggerfactory.NewLogger(false) org := primitive.NewObjectID() store := newHelperPaymentStore() + ledgerFake := &ledgerclient.Fake{ + PostDebitWithChargesFn: func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + return &ledgerv1.PostResponse{JournalEntryRef: "debit-1"}, nil + }, + PostCreditWithChargesFn: func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil + }, + } svc := NewService(logger, stubRepo{ payments: store, - }, WithClock(clockpkg.NewSystem())) + routes: &stubRoutesStore{}, + }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) svc.ensureHandlers() intent := &orchestratorv1.PaymentIntent{ + Source: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, + }, + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, + }, Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, } req := &orchestratorv1.InitiatePaymentRequest{ @@ -173,16 +190,33 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { logger := mloggerfactory.NewLogger(false) org := primitive.NewObjectID() store := newHelperPaymentStore() - intent := &orchestratorv1.PaymentIntent{Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}} + intent := &orchestratorv1.PaymentIntent{ + Source: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:source"}}, + }, + Destination: &orchestratorv1.PaymentEndpoint{ + Endpoint: &orchestratorv1.PaymentEndpoint_Ledger{Ledger: &orchestratorv1.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}}, + }, + Amount: &moneyv1.Money{Currency: "USD", Amount: "1"}, + } record := &model.PaymentQuoteRecord{ QuoteRef: "q1", Intent: intentFromProto(intent), Quote: &model.PaymentQuoteSnapshot{}, } + ledgerFake := &ledgerclient.Fake{ + PostDebitWithChargesFn: func(ctx context.Context, req *ledgerv1.PostDebitRequest) (*ledgerv1.PostResponse, error) { + return &ledgerv1.PostResponse{JournalEntryRef: "debit-1"}, nil + }, + PostCreditWithChargesFn: func(ctx context.Context, req *ledgerv1.PostCreditRequest) (*ledgerv1.PostResponse, error) { + return &ledgerv1.PostResponse{JournalEntryRef: "credit-1"}, nil + }, + } svc := NewService(logger, stubRepo{ payments: store, quotes: &helperQuotesStore{records: map[string]*model.PaymentQuoteRecord{"q1": record}}, - }, WithClock(clockpkg.NewSystem())) + routes: &stubRoutesStore{}, + }, WithClock(clockpkg.NewSystem()), WithLedgerClient(ledgerFake)) svc.ensureHandlers() req := &orchestratorv1.InitiatePaymentRequest{ @@ -210,12 +244,14 @@ func TestInitiatePaymentByQuoteRef(t *testing.T) { type stubRepo struct { payments storage.PaymentsStore quotes storage.QuotesStore + routes storage.RoutesStore pingErr error } func (s stubRepo) Ping(context.Context) error { return s.pingErr } func (s stubRepo) Payments() storage.PaymentsStore { return s.payments } func (s stubRepo) Quotes() storage.QuotesStore { return s.quotes } +func (s stubRepo) Routes() storage.RoutesStore { return s.routes } type helperPaymentStore struct { byRef map[string]*model.Payment diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_test.go index f2f6e0c..dc7d0da 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_test.go @@ -7,13 +7,14 @@ import ( "testing" "time" - chainclient "github.com/tech/sendico/gateway/chain/client" ledgerclient "github.com/tech/sendico/ledger/client" "github.com/tech/sendico/payments/orchestrator/storage" "github.com/tech/sendico/payments/orchestrator/storage/model" "github.com/tech/sendico/pkg/api/routers/gsresponse" "github.com/tech/sendico/pkg/merrors" mo "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/payments/rail" + paymenttypes "github.com/tech/sendico/pkg/payments/types" moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" @@ -33,11 +34,17 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) { clock: testClock{now: time.Now()}, storage: repo, deps: serviceDependencies{ - ledger: ledgerDependency{client: &ledgerclient.Fake{ - ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { - return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil - }, - }}, + ledger: func() ledgerDependency { + fake := &ledgerclient.Fake{ + ApplyFXWithChargesFn: func(ctx context.Context, req *ledgerv1.FXRequest) (*ledgerv1.PostResponse, error) { + return &ledgerv1.PostResponse{JournalEntryRef: "fx-entry"}, nil + }, + } + return ledgerDependency{ + client: fake, + internal: fake, + } + }(), }, } @@ -55,7 +62,7 @@ func TestExecutePayment_FXConversionSettled(t *testing.T) { Type: model.EndpointTypeLedger, Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "100"}, + Amount: &paymenttypes.Money{Currency: "USD", Amount: "100"}, }, } store.payments[payment.PaymentRef] = payment @@ -85,17 +92,56 @@ func TestExecutePayment_ChainFailure(t *testing.T) { ctx := context.Background() store := newStubPaymentsStore() - repo := &stubRepository{store: store} + routes := &stubRoutesStore{ + routes: []*model.PaymentRoute{ + {FromRail: model.RailCrypto, ToRail: model.RailProviderSettlement, Network: "TRON", RequiresObserve: true, IsEnabled: true}, + {FromRail: model.RailProviderSettlement, ToRail: model.RailLedger, IsEnabled: true}, + }, + } + repo := &stubRepository{store: store, routes: routes} svc := &Service{ logger: zap.NewNop(), clock: testClock{now: time.Now()}, storage: repo, deps: serviceDependencies{ - gateway: gatewayDependency{client: &chainclient.Fake{ - SubmitTransferFn: func(ctx context.Context, req *chainv1.SubmitTransferRequest) (*chainv1.SubmitTransferResponse, error) { - return nil, errors.New("chain failure") + railGateways: buildRailGatewayDependency(map[string]rail.RailGateway{ + "crypto-tron": &fakeRailGateway{ + rail: "CRYPTO", + sendFn: func(ctx context.Context, req rail.TransferRequest) (rail.RailResult, error) { + return rail.RailResult{}, errors.New("chain failure") + }, }, - }}, + }, nil, nil), + gatewayRegistry: &stubGatewayRegistry{ + items: []*model.GatewayInstanceDescriptor{ + { + ID: "crypto-tron", + Rail: model.RailCrypto, + Network: "TRON", + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + CanPayOut: true, + }, + Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, + IsEnabled: true, + }, + { + ID: "settlement", + Rail: model.RailProviderSettlement, + Currencies: []string{"USDT"}, + Capabilities: model.RailCapabilities{ + RequiresObserveConfirm: true, + }, + Limits: model.Limits{MinAmount: "0", MaxAmount: "100000"}, + IsEnabled: true, + }, + }, + }, + cardRoutes: map[string]CardGatewayRoute{ + defaultCardGateway: { + FundingAddress: "funding-address", + }, + }, }, } @@ -109,15 +155,17 @@ func TestExecutePayment_ChainFailure(t *testing.T) { Type: model.EndpointTypeManagedWallet, ManagedWallet: &model.ManagedWalletEndpoint{ ManagedWalletRef: "wallet-src", + Asset: &paymenttypes.Asset{ + Chain: "TRON", + TokenSymbol: "USDT", + }, }, }, Destination: model.PaymentEndpoint{ - Type: model.EndpointTypeManagedWallet, - ManagedWallet: &model.ManagedWalletEndpoint{ - ManagedWalletRef: "wallet-dst", - }, + Type: model.EndpointTypeLedger, + Ledger: &model.LedgerEndpoint{LedgerAccountRef: "ledger:dest"}, }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "50"}, + Amount: &paymenttypes.Money{Currency: "USDT", Amount: "50"}, }, } store.payments[payment.PaymentRef] = payment @@ -150,7 +198,7 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) { clock: testClock{now: time.Now()}, storage: &stubRepository{store: store}, } - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger) + svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, nil, nil) req := &orchestratorv1.ProcessTransferUpdateRequest{ Event: &chainv1.TransferStatusChangedEvent{ @@ -170,6 +218,107 @@ func TestProcessTransferUpdateHandler_Settled(t *testing.T) { } } +func TestProcessTransferUpdateHandler_CardFundingWaitsForSources(t *testing.T) { + ctx := context.Background() + + payment := &model.Payment{ + PaymentRef: "pay-card", + State: model.PaymentStateSubmitted, + Intent: model.PaymentIntent{ + Destination: model.PaymentEndpoint{ + Type: model.EndpointTypeCard, + Card: &model.CardEndpoint{MaskedPan: "4111"}, + }, + }, + Execution: &model.ExecutionRefs{ChainTransferRef: "fund-1"}, + } + + plan := ensureExecutionPlan(payment) + fundStep := ensureExecutionStep(plan, stepCodeFundingTransfer) + fundStep.TransferRef = "fund-1" + setExecutionStepRole(fundStep, executionStepRoleSource) + setExecutionStepStatus(fundStep, executionStepStatusSubmitted) + + feeStep := ensureExecutionStep(plan, stepCodeFeeTransfer) + feeStep.TransferRef = "fee-1" + setExecutionStepRole(feeStep, executionStepRoleSource) + setExecutionStepStatus(feeStep, executionStepStatusSubmitted) + + cardStep := ensureExecutionStep(plan, stepCodeCardPayout) + setExecutionStepRole(cardStep, executionStepRoleConsumer) + setExecutionStepStatus(cardStep, executionStepStatusPlanned) + + store := newStubPaymentsStore() + store.payments[payment.PaymentRef] = payment + store.indexTransfers(payment) + + svc := &Service{ + logger: zap.NewNop(), + clock: testClock{now: time.Now()}, + storage: &stubRepository{store: store}, + } + + payoutCalls := 0 + submit := func(ctx context.Context, payment *model.Payment) error { + payoutCalls++ + if payment.Execution == nil { + payment.Execution = &model.ExecutionRefs{} + } + payment.Execution.CardPayoutRef = "payout-1" + plan := ensureExecutionPlan(payment) + step := ensureExecutionStep(plan, stepCodeCardPayout) + setExecutionStepRole(step, executionStepRoleConsumer) + step.TransferRef = "payout-1" + setExecutionStepStatus(step, executionStepStatusSubmitted) + return nil + } + svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, submit, nil) + + req := &orchestratorv1.ProcessTransferUpdateRequest{ + Event: &chainv1.TransferStatusChangedEvent{ + Transfer: &chainv1.Transfer{ + TransferRef: "fund-1", + Status: chainv1.TransferStatus_TRANSFER_CONFIRMED, + }, + }, + } + resp, err := gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if payoutCalls != 0 { + t.Fatalf("expected no payout on first confirmation, got %d", payoutCalls) + } + if executionStepStatus(fundStep) != executionStepStatusConfirmed { + t.Fatalf("expected funding step confirmed, got %s", executionStepStatus(fundStep)) + } + if resp.GetPayment().GetState() != orchestratorv1.PaymentState_PAYMENT_STATE_SUBMITTED { + t.Fatalf("expected submitted state, got %s", resp.GetPayment().GetState()) + } + + req = &orchestratorv1.ProcessTransferUpdateRequest{ + Event: &chainv1.TransferStatusChangedEvent{ + Transfer: &chainv1.Transfer{ + TransferRef: "fee-1", + Status: chainv1.TransferStatus_TRANSFER_CONFIRMED, + }, + }, + } + resp, err = gsresponse.Execute(ctx, svc.h.events.processTransferUpdate(ctx, req)) + if err != nil { + t.Fatalf("handler returned error: %v", err) + } + if payoutCalls != 1 { + t.Fatalf("expected payout after all sources confirmed, got %d", payoutCalls) + } + if executionStepStatus(feeStep) != executionStepStatusConfirmed { + t.Fatalf("expected fee step confirmed, got %s", executionStepStatus(feeStep)) + } + if resp.GetPayment().GetExecution().GetCardPayoutRef() != "payout-1" { + t.Fatalf("expected card payout ref set, got %s", resp.GetPayment().GetExecution().GetCardPayoutRef()) + } +} + func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { ctx := context.Background() payment := &model.Payment{ @@ -182,7 +331,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { ManagedWalletRef: "wallet-dst", }, }, - Amount: &moneyv1.Money{Currency: "USD", Amount: "40"}, + Amount: &paymenttypes.Money{Currency: "USD", Amount: "40"}, }, } store := newStubPaymentsStore() @@ -194,7 +343,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { clock: testClock{now: time.Now()}, storage: &stubRepository{store: store}, } - svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger) + svc.h.events = newPaymentEventHandler(svc.storage, svc.ensureRepository, svc.logger, nil, nil) req := &orchestratorv1.ProcessDepositObservedRequest{ Event: &chainv1.WalletDepositObservedEvent{ @@ -217,6 +366,7 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) { type stubRepository struct { store *stubPaymentsStore quotes storage.QuotesStore + routes storage.RoutesStore } func (r *stubRepository) Ping(context.Context) error { return nil } @@ -227,6 +377,12 @@ func (r *stubRepository) Quotes() storage.QuotesStore { } return &stubQuotesStore{} } +func (r *stubRepository) Routes() storage.RoutesStore { + if r.routes != nil { + return r.routes + } + return &stubRoutesStore{} +} type stubQuotesStore struct { quotes map[string]*model.PaymentQuoteRecord @@ -253,6 +409,38 @@ func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectI return nil, storage.ErrQuoteNotFound } +type stubRoutesStore struct { + routes []*model.PaymentRoute +} + +func (s *stubRoutesStore) Create(ctx context.Context, route *model.PaymentRoute) error { + return merrors.InvalidArgument("routes store not implemented") +} + +func (s *stubRoutesStore) Update(ctx context.Context, route *model.PaymentRoute) error { + return merrors.InvalidArgument("routes store not implemented") +} + +func (s *stubRoutesStore) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error) { + return nil, storage.ErrRouteNotFound +} + +func (s *stubRoutesStore) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) { + items := make([]*model.PaymentRoute, 0, len(s.routes)) + for _, route := range s.routes { + if route == nil { + continue + } + if filter != nil && filter.IsEnabled != nil { + if route.IsEnabled != *filter.IsEnabled { + continue + } + } + items = append(items, route) + } + return &model.PaymentRouteList{Items: items}, nil +} + type stubPaymentsStore struct { payments map[string]*model.Payment byChain map[string]*model.Payment @@ -271,9 +459,7 @@ func (s *stubPaymentsStore) Create(ctx context.Context, payment *model.Payment) return storage.ErrDuplicatePayment } s.payments[payment.PaymentRef] = payment - if payment.Execution != nil && payment.Execution.ChainTransferRef != "" { - s.byChain[payment.Execution.ChainTransferRef] = payment - } + s.indexTransfers(payment) return nil } @@ -282,9 +468,7 @@ func (s *stubPaymentsStore) Update(ctx context.Context, payment *model.Payment) return storage.ErrPaymentNotFound } s.payments[payment.PaymentRef] = payment - if payment.Execution != nil && payment.Execution.ChainTransferRef != "" { - s.byChain[payment.Execution.ChainTransferRef] = payment - } + s.indexTransfers(payment) return nil } @@ -318,6 +502,24 @@ func (s *stubPaymentsStore) List(ctx context.Context, filter *model.PaymentFilte return &model.PaymentList{}, nil } +func (s *stubPaymentsStore) indexTransfers(payment *model.Payment) { + if payment == nil { + return + } + if payment.Execution != nil && payment.Execution.ChainTransferRef != "" { + s.byChain[payment.Execution.ChainTransferRef] = payment + } + if payment.ExecutionPlan == nil { + return + } + for _, step := range payment.ExecutionPlan.Steps { + if step == nil || strings.TrimSpace(step.TransferRef) == "" { + continue + } + s.byChain[strings.TrimSpace(step.TransferRef)] = payment + } +} + var _ storage.PaymentsStore = (*stubPaymentsStore)(nil) // testClock satisfies clock.Clock diff --git a/api/payments/orchestrator/storage/model/payment.go b/api/payments/orchestrator/storage/model/payment.go index 12cb97e..4d01956 100644 --- a/api/payments/orchestrator/storage/model/payment.go +++ b/api/payments/orchestrator/storage/model/payment.go @@ -2,16 +2,12 @@ package model import ( "strings" + "time" "github.com/tech/sendico/pkg/db/storable" "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" - feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" - fxv1 "github.com/tech/sendico/pkg/proto/common/fx/v1" - moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" - chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" - oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" - orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + paymenttypes "github.com/tech/sendico/pkg/payments/types" ) // PaymentKind captures the orchestrator intent type. @@ -24,6 +20,15 @@ const ( PaymentKindFXConversion PaymentKind = "fx_conversion" ) +// SettlementMode defines how fees/FX variance is handled. +type SettlementMode string + +const ( + SettlementModeUnspecified SettlementMode = "unspecified" + SettlementModeFixSource SettlementMode = "fix_source" + SettlementModeFixReceived SettlementMode = "fix_received" +) + // PaymentState enumerates lifecycle phases. type PaymentState string @@ -50,6 +55,73 @@ const ( PaymentFailureCodePolicy PaymentFailureCode = "policy" ) +// Rail identifies a payment rail for orchestration. +type Rail string + +const ( + RailUnspecified Rail = "UNSPECIFIED" + RailCrypto Rail = "CRYPTO" + RailProviderSettlement Rail = "PROVIDER_SETTLEMENT" + RailLedger Rail = "LEDGER" + RailCardPayout Rail = "CARD_PAYOUT" + RailFiatOnRamp Rail = "FIAT_ONRAMP" +) + +// RailOperation identifies an explicit action within a payment plan. +type RailOperation string + +const ( + RailOperationUnspecified RailOperation = "UNSPECIFIED" + RailOperationDebit RailOperation = "DEBIT" + RailOperationCredit RailOperation = "CREDIT" + RailOperationSend RailOperation = "SEND" + RailOperationFee RailOperation = "FEE" + RailOperationObserveConfirm RailOperation = "OBSERVE_CONFIRM" + RailOperationFXConvert RailOperation = "FX_CONVERT" +) + +// RailCapabilities are declared per gateway instance. +type RailCapabilities struct { + CanPayIn bool `bson:"canPayIn,omitempty" json:"canPayIn,omitempty"` + CanPayOut bool `bson:"canPayOut,omitempty" json:"canPayOut,omitempty"` + CanReadBalance bool `bson:"canReadBalance,omitempty" json:"canReadBalance,omitempty"` + CanSendFee bool `bson:"canSendFee,omitempty" json:"canSendFee,omitempty"` + RequiresObserveConfirm bool `bson:"requiresObserveConfirm,omitempty" json:"requiresObserveConfirm,omitempty"` +} + +// LimitsOverride applies per-currency overrides for limits. +type LimitsOverride struct { + MaxVolume string `bson:"maxVolume,omitempty" json:"maxVolume,omitempty"` + MinAmount string `bson:"minAmount,omitempty" json:"minAmount,omitempty"` + MaxAmount string `bson:"maxAmount,omitempty" json:"maxAmount,omitempty"` + MaxFee string `bson:"maxFee,omitempty" json:"maxFee,omitempty"` + MaxOps int `bson:"maxOps,omitempty" json:"maxOps,omitempty"` +} + +// Limits define time-bucketed and per-tx constraints. +type Limits struct { + MinAmount string `bson:"minAmount,omitempty" json:"minAmount,omitempty"` + MaxAmount string `bson:"maxAmount,omitempty" json:"maxAmount,omitempty"` + PerTxMaxFee string `bson:"perTxMaxFee,omitempty" json:"perTxMaxFee,omitempty"` + PerTxMinAmount string `bson:"perTxMinAmount,omitempty" json:"perTxMinAmount,omitempty"` + PerTxMaxAmount string `bson:"perTxMaxAmount,omitempty" json:"perTxMaxAmount,omitempty"` + VolumeLimit map[string]string `bson:"volumeLimit,omitempty" json:"volumeLimit,omitempty"` + VelocityLimit map[string]int `bson:"velocityLimit,omitempty" json:"velocityLimit,omitempty"` + CurrencyLimits map[string]LimitsOverride `bson:"currencyLimits,omitempty" json:"currencyLimits,omitempty"` +} + +// GatewayInstanceDescriptor standardizes gateway instance self-declaration. +type GatewayInstanceDescriptor struct { + ID string `bson:"id" json:"id"` + Rail Rail `bson:"rail" json:"rail"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + Currencies []string `bson:"currencies,omitempty" json:"currencies,omitempty"` + Capabilities RailCapabilities `bson:"capabilities,omitempty" json:"capabilities,omitempty"` + Limits Limits `bson:"limits,omitempty" json:"limits,omitempty"` + Version string `bson:"version,omitempty" json:"version,omitempty"` + IsEnabled bool `bson:"isEnabled" json:"isEnabled"` +} + // PaymentEndpointType indicates how value should be routed. type PaymentEndpointType string @@ -69,15 +141,15 @@ type LedgerEndpoint struct { // ManagedWalletEndpoint describes managed wallet routing. type ManagedWalletEndpoint struct { - ManagedWalletRef string `bson:"managedWalletRef" json:"managedWalletRef"` - Asset *chainv1.Asset `bson:"asset,omitempty" json:"asset,omitempty"` + ManagedWalletRef string `bson:"managedWalletRef" json:"managedWalletRef"` + Asset *paymenttypes.Asset `bson:"asset,omitempty" json:"asset,omitempty"` } // ExternalChainEndpoint describes an external address. type ExternalChainEndpoint struct { - Asset *chainv1.Asset `bson:"asset,omitempty" json:"asset,omitempty"` - Address string `bson:"address" json:"address"` - Memo string `bson:"memo,omitempty" json:"memo,omitempty"` + Asset *paymenttypes.Asset `bson:"asset,omitempty" json:"asset,omitempty"` + Address string `bson:"address" json:"address"` + Memo string `bson:"memo,omitempty" json:"memo,omitempty"` } // CardEndpoint describes a card payout destination. @@ -116,26 +188,26 @@ type PaymentEndpoint struct { // FXIntent captures FX conversion preferences. type FXIntent struct { - Pair *fxv1.CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"` - Side fxv1.Side `bson:"side,omitempty" json:"side,omitempty"` - Firm bool `bson:"firm,omitempty" json:"firm,omitempty"` - TTLMillis int64 `bson:"ttlMillis,omitempty" json:"ttlMillis,omitempty"` - PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"` - MaxAgeMillis int32 `bson:"maxAgeMillis,omitempty" json:"maxAgeMillis,omitempty"` + Pair *paymenttypes.CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"` + Side paymenttypes.FXSide `bson:"side,omitempty" json:"side,omitempty"` + Firm bool `bson:"firm,omitempty" json:"firm,omitempty"` + TTLMillis int64 `bson:"ttlMillis,omitempty" json:"ttlMillis,omitempty"` + PreferredProvider string `bson:"preferredProvider,omitempty" json:"preferredProvider,omitempty"` + MaxAgeMillis int32 `bson:"maxAgeMillis,omitempty" json:"maxAgeMillis,omitempty"` } // PaymentIntent models the requested payment operation. type PaymentIntent struct { - Kind PaymentKind `bson:"kind" json:"kind"` - Source PaymentEndpoint `bson:"source" json:"source"` - Destination PaymentEndpoint `bson:"destination" json:"destination"` - Amount *moneyv1.Money `bson:"amount" json:"amount"` - RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"` - FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"` - FeePolicy *feesv1.PolicyOverrides `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` - SettlementMode orchestratorv1.SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` - Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` - Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"` + Kind PaymentKind `bson:"kind" json:"kind"` + Source PaymentEndpoint `bson:"source" json:"source"` + Destination PaymentEndpoint `bson:"destination" json:"destination"` + Amount *paymenttypes.Money `bson:"amount" json:"amount"` + RequiresFX bool `bson:"requiresFx,omitempty" json:"requiresFx,omitempty"` + FX *FXIntent `bson:"fx,omitempty" json:"fx,omitempty"` + FeePolicy *paymenttypes.FeePolicy `bson:"feePolicy,omitempty" json:"feePolicy,omitempty"` + SettlementMode SettlementMode `bson:"settlementMode,omitempty" json:"settlementMode,omitempty"` + Attributes map[string]string `bson:"attributes,omitempty" json:"attributes,omitempty"` + Customer *Customer `bson:"customer,omitempty" json:"customer,omitempty"` } // Customer captures payer/recipient identity details for downstream processing. @@ -154,14 +226,14 @@ type Customer struct { // PaymentQuoteSnapshot stores the latest quote info. type PaymentQuoteSnapshot struct { - DebitAmount *moneyv1.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"` - ExpectedSettlementAmount *moneyv1.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"` - ExpectedFeeTotal *moneyv1.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"` - FeeLines []*feesv1.DerivedPostingLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"` - FeeRules []*feesv1.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"` - FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` - NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"` - QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` + DebitAmount *paymenttypes.Money `bson:"debitAmount,omitempty" json:"debitAmount,omitempty"` + ExpectedSettlementAmount *paymenttypes.Money `bson:"expectedSettlementAmount,omitempty" json:"expectedSettlementAmount,omitempty"` + ExpectedFeeTotal *paymenttypes.Money `bson:"expectedFeeTotal,omitempty" json:"expectedFeeTotal,omitempty"` + FeeLines []*paymenttypes.FeeLine `bson:"feeLines,omitempty" json:"feeLines,omitempty"` + FeeRules []*paymenttypes.AppliedRule `bson:"feeRules,omitempty" json:"feeRules,omitempty"` + FXQuote *paymenttypes.FXQuote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"` + NetworkFee *paymenttypes.NetworkFeeEstimate `bson:"networkFee,omitempty" json:"networkFee,omitempty"` + QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` } // ExecutionRefs links to downstream systems. @@ -174,22 +246,39 @@ type ExecutionRefs struct { FeeTransferRef string `bson:"feeTransferRef,omitempty" json:"feeTransferRef,omitempty"` } +// PaymentStep is an explicit action within a payment plan. +type PaymentStep struct { + Rail Rail `bson:"rail" json:"rail"` + GatewayID string `bson:"gatewayId,omitempty" json:"gatewayId,omitempty"` + Action RailOperation `bson:"action" json:"action"` + Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` + Ref string `bson:"ref,omitempty" json:"ref,omitempty"` +} + +// PaymentPlan captures the ordered list of steps to execute a payment. +type PaymentPlan struct { + ID string `bson:"id,omitempty" json:"id,omitempty"` + Steps []*PaymentStep `bson:"steps,omitempty" json:"steps,omitempty"` + IdempotencyKey string `bson:"idempotencyKey,omitempty" json:"idempotencyKey,omitempty"` + CreatedAt time.Time `bson:"createdAt,omitempty" json:"createdAt,omitempty"` +} + // ExecutionStep describes a planned or executed payment step for reporting. type ExecutionStep struct { - Code string `bson:"code,omitempty" json:"code,omitempty"` - Description string `bson:"description,omitempty" json:"description,omitempty"` - Amount *moneyv1.Money `bson:"amount,omitempty" json:"amount,omitempty"` - NetworkFee *moneyv1.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"` - SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"` - DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"` - TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"` - Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` + Code string `bson:"code,omitempty" json:"code,omitempty"` + Description string `bson:"description,omitempty" json:"description,omitempty"` + Amount *paymenttypes.Money `bson:"amount,omitempty" json:"amount,omitempty"` + NetworkFee *paymenttypes.Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"` + SourceWalletRef string `bson:"sourceWalletRef,omitempty" json:"sourceWalletRef,omitempty"` + DestinationRef string `bson:"destinationRef,omitempty" json:"destinationRef,omitempty"` + TransferRef string `bson:"transferRef,omitempty" json:"transferRef,omitempty"` + Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` } // ExecutionPlan captures the ordered list of steps to execute a payment. type ExecutionPlan struct { - Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"` - TotalNetworkFee *moneyv1.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"` + Steps []*ExecutionStep `bson:"steps,omitempty" json:"steps,omitempty"` + TotalNetworkFee *paymenttypes.Money `bson:"totalNetworkFee,omitempty" json:"totalNetworkFee,omitempty"` } // Payment persists orchestrated payment lifecycle. @@ -206,6 +295,7 @@ type Payment struct { LastQuote *PaymentQuoteSnapshot `bson:"lastQuote,omitempty" json:"lastQuote,omitempty"` Execution *ExecutionRefs `bson:"execution,omitempty" json:"execution,omitempty"` ExecutionPlan *ExecutionPlan `bson:"executionPlan,omitempty" json:"executionPlan,omitempty"` + PaymentPlan *PaymentPlan `bson:"paymentPlan,omitempty" json:"paymentPlan,omitempty"` Metadata map[string]string `bson:"metadata,omitempty" json:"metadata,omitempty"` CardPayout *CardPayout `bson:"cardPayout,omitempty" json:"cardPayout,omitempty"` } @@ -282,6 +372,19 @@ func (p *Payment) Normalize() { } } } + if p.PaymentPlan != nil { + p.PaymentPlan.ID = strings.TrimSpace(p.PaymentPlan.ID) + p.PaymentPlan.IdempotencyKey = strings.TrimSpace(p.PaymentPlan.IdempotencyKey) + for _, step := range p.PaymentPlan.Steps { + if step == nil { + continue + } + step.Rail = Rail(strings.TrimSpace(string(step.Rail))) + step.GatewayID = strings.TrimSpace(step.GatewayID) + step.Action = RailOperation(strings.TrimSpace(string(step.Action))) + step.Ref = strings.TrimSpace(step.Ref) + } + } } func normalizeEndpoint(ep *PaymentEndpoint) { @@ -303,6 +406,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) { if ep.ManagedWallet != nil { ep.ManagedWallet.ManagedWalletRef = strings.TrimSpace(ep.ManagedWallet.ManagedWalletRef) if ep.ManagedWallet.Asset != nil { + ep.ManagedWallet.Asset.Chain = strings.TrimSpace(strings.ToUpper(ep.ManagedWallet.Asset.Chain)) ep.ManagedWallet.Asset.TokenSymbol = strings.TrimSpace(strings.ToUpper(ep.ManagedWallet.Asset.TokenSymbol)) ep.ManagedWallet.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ManagedWallet.Asset.ContractAddress)) } @@ -312,6 +416,7 @@ func normalizeEndpoint(ep *PaymentEndpoint) { ep.ExternalChain.Address = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Address)) ep.ExternalChain.Memo = strings.TrimSpace(ep.ExternalChain.Memo) if ep.ExternalChain.Asset != nil { + ep.ExternalChain.Asset.Chain = strings.TrimSpace(strings.ToUpper(ep.ExternalChain.Asset.Chain)) ep.ExternalChain.Asset.TokenSymbol = strings.TrimSpace(strings.ToUpper(ep.ExternalChain.Asset.TokenSymbol)) ep.ExternalChain.Asset.ContractAddress = strings.TrimSpace(strings.ToLower(ep.ExternalChain.Asset.ContractAddress)) } diff --git a/api/payments/orchestrator/storage/model/route.go b/api/payments/orchestrator/storage/model/route.go new file mode 100644 index 0000000..2144072 --- /dev/null +++ b/api/payments/orchestrator/storage/model/route.go @@ -0,0 +1,47 @@ +package model + +import ( + "strings" + + "github.com/tech/sendico/pkg/db/storable" + "github.com/tech/sendico/pkg/mservice" +) + +// PaymentRoute defines an allowed rail transition for orchestration. +type PaymentRoute struct { + storable.Base `bson:",inline" json:",inline"` + + FromRail Rail `bson:"fromRail" json:"fromRail"` + ToRail Rail `bson:"toRail" json:"toRail"` + Network string `bson:"network,omitempty" json:"network,omitempty"` + RequiresObserve bool `bson:"requiresObserve,omitempty" json:"requiresObserve,omitempty"` + IsEnabled bool `bson:"isEnabled" json:"isEnabled"` +} + +// Collection implements storable.Storable. +func (*PaymentRoute) Collection() string { + return mservice.PaymentRoutes +} + +// Normalize standardizes route fields for consistent indexing and matching. +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.Network = strings.ToUpper(strings.TrimSpace(r.Network)) +} + +// PaymentRouteFilter selects routes for lookup. +type PaymentRouteFilter struct { + FromRail Rail + ToRail Rail + Network string + IsEnabled *bool +} + +// PaymentRouteList holds route results. +type PaymentRouteList struct { + Items []*PaymentRoute +} diff --git a/api/payments/orchestrator/storage/mongo/repository.go b/api/payments/orchestrator/storage/mongo/repository.go index c8a24a0..d8ad407 100644 --- a/api/payments/orchestrator/storage/mongo/repository.go +++ b/api/payments/orchestrator/storage/mongo/repository.go @@ -19,6 +19,7 @@ type Store struct { payments storage.PaymentsStore quotes storage.QuotesStore + routes storage.RoutesStore } // New constructs a Mongo-backed payments repository from a Mongo connection. @@ -28,11 +29,12 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) { } paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection()) quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection()) - return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo) + routesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentRoute{}).Collection()) + return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo, routesRepo) } // NewWithRepository constructs a payments repository using the provided primitives. -func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository) (*Store, error) { +func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository, routesRepo repository.Repository) (*Store, error) { if ping == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil") } @@ -42,6 +44,9 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, if quotesRepo == nil { return nil, merrors.InvalidArgument("payments.storage.mongo: quotes repository is nil") } + if routesRepo == nil { + return nil, merrors.InvalidArgument("payments.storage.mongo: routes repository is nil") + } childLogger := logger.Named("storage").Named("mongo") paymentsStore, err := store.NewPayments(childLogger, paymentsRepo) @@ -52,11 +57,16 @@ func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, if err != nil { return nil, err } + routesStore, err := store.NewRoutes(childLogger, routesRepo) + if err != nil { + return nil, err + } result := &Store{ logger: childLogger, ping: ping, payments: paymentsStore, quotes: quotesStore, + routes: routesStore, } return result, nil @@ -80,4 +90,9 @@ func (s *Store) Quotes() storage.QuotesStore { return s.quotes } +// Routes returns the routing store. +func (s *Store) Routes() storage.RoutesStore { + return s.routes +} + var _ storage.Repository = (*Store)(nil) diff --git a/api/payments/orchestrator/storage/mongo/store/payments.go b/api/payments/orchestrator/storage/mongo/store/payments.go index 4e2dd18..a7ae4c3 100644 --- a/api/payments/orchestrator/storage/mongo/store/payments.go +++ b/api/payments/orchestrator/storage/mongo/store/payments.go @@ -54,6 +54,9 @@ func NewPayments(logger mlogger.Logger, repo repository.Repository) (*Payments, { Keys: []ri.Key{{Field: "execution.chainTransferRef", Sort: ri.Asc}}, }, + { + Keys: []ri.Key{{Field: "executionPlan.steps.transferRef", Sort: ri.Asc}}, + }, } for _, def := range indexes { @@ -160,7 +163,11 @@ func (p *Payments) GetByChainTransferRef(ctx context.Context, transferRef string return nil, merrors.InvalidArgument("paymentsStore: empty chain transfer reference") } entity := &model.Payment{} - if err := p.repo.FindOneByFilter(ctx, repository.Filter("execution.chainTransferRef", transferRef), entity); err != nil { + query := repository.Query().Or( + repository.Filter("execution.chainTransferRef", transferRef), + repository.Filter("executionPlan.steps.transferRef", transferRef), + ) + if err := p.repo.FindOneByFilter(ctx, query, entity); err != nil { if errors.Is(err, merrors.ErrNoData) { return nil, storage.ErrPaymentNotFound } diff --git a/api/payments/orchestrator/storage/mongo/store/routes.go b/api/payments/orchestrator/storage/mongo/store/routes.go new file mode 100644 index 0000000..e66c533 --- /dev/null +++ b/api/payments/orchestrator/storage/mongo/store/routes.go @@ -0,0 +1,165 @@ +package store + +import ( + "context" + "errors" + "strings" + + "github.com/tech/sendico/payments/orchestrator/storage" + "github.com/tech/sendico/payments/orchestrator/storage/model" + "github.com/tech/sendico/pkg/db/repository" + ri "github.com/tech/sendico/pkg/db/repository/index" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/mlogger" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.mongodb.org/mongo-driver/mongo" + "go.uber.org/zap" +) + +type Routes struct { + logger mlogger.Logger + repo repository.Repository +} + +// NewRoutes constructs a Mongo-backed routes store. +func NewRoutes(logger mlogger.Logger, repo repository.Repository) (*Routes, error) { + if repo == nil { + return nil, merrors.InvalidArgument("routesStore: repository is nil") + } + + indexes := []*ri.Definition{ + { + Keys: []ri.Key{ + {Field: "fromRail", Sort: ri.Asc}, + {Field: "toRail", Sort: ri.Asc}, + {Field: "network", Sort: ri.Asc}, + }, + Unique: true, + }, + { + Keys: []ri.Key{{Field: "fromRail", Sort: ri.Asc}}, + }, + { + Keys: []ri.Key{{Field: "toRail", Sort: ri.Asc}}, + }, + { + Keys: []ri.Key{{Field: "isEnabled", Sort: ri.Asc}}, + }, + } + + for _, def := range indexes { + if err := repo.CreateIndex(def); err != nil { + logger.Error("failed to ensure routes index", zap.Error(err), zap.String("collection", repo.Collection())) + return nil, err + } + } + + return &Routes{ + logger: logger.Named("routes"), + repo: repo, + }, nil +} + +func (r *Routes) Create(ctx context.Context, route *model.PaymentRoute) error { + if route == nil { + return merrors.InvalidArgument("routesStore: nil route") + } + route.Normalize() + if route.FromRail == "" || route.FromRail == model.RailUnspecified { + return merrors.InvalidArgument("routesStore: from_rail is required") + } + if route.ToRail == "" || route.ToRail == model.RailUnspecified { + return merrors.InvalidArgument("routesStore: to_rail is required") + } + if route.ID.IsZero() { + route.SetID(primitive.NewObjectID()) + } else { + route.Update() + } + + filter := repository.Filter("fromRail", route.FromRail).And( + repository.Filter("toRail", route.ToRail), + repository.Filter("network", route.Network), + ) + + if err := r.repo.Insert(ctx, route, filter); err != nil { + if errors.Is(err, merrors.ErrDataConflict) { + return storage.ErrDuplicateRoute + } + return err + } + return nil +} + +func (r *Routes) Update(ctx context.Context, route *model.PaymentRoute) error { + if route == nil { + return merrors.InvalidArgument("routesStore: nil route") + } + if route.ID.IsZero() { + return merrors.InvalidArgument("routesStore: missing route id") + } + route.Normalize() + route.Update() + if err := r.repo.Update(ctx, route); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return storage.ErrRouteNotFound + } + return err + } + return nil +} + +func (r *Routes) GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error) { + if id == primitive.NilObjectID { + return nil, merrors.InvalidArgument("routesStore: route id is required") + } + entity := &model.PaymentRoute{} + if err := r.repo.Get(ctx, id, entity); err != nil { + if errors.Is(err, merrors.ErrNoData) { + return nil, storage.ErrRouteNotFound + } + return nil, err + } + return entity, nil +} + +func (r *Routes) List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) { + if filter == nil { + filter = &model.PaymentRouteFilter{} + } + + query := repository.Query() + + if from := strings.ToUpper(strings.TrimSpace(string(filter.FromRail))); from != "" { + query = query.Filter(repository.Field("fromRail"), from) + } + if to := strings.ToUpper(strings.TrimSpace(string(filter.ToRail))); to != "" { + query = query.Filter(repository.Field("toRail"), to) + } + if network := strings.ToUpper(strings.TrimSpace(filter.Network)); network != "" { + query = query.Filter(repository.Field("network"), network) + } + if filter.IsEnabled != nil { + query = query.Filter(repository.Field("isEnabled"), *filter.IsEnabled) + } + + routes := make([]*model.PaymentRoute, 0) + decoder := func(cur *mongo.Cursor) error { + item := &model.PaymentRoute{} + if err := cur.Decode(item); err != nil { + return err + } + routes = append(routes, item) + return nil + } + + if err := r.repo.FindManyByFilter(ctx, query, decoder); err != nil && !errors.Is(err, merrors.ErrNoData) { + return nil, err + } + + return &model.PaymentRouteList{ + Items: routes, + }, nil +} + +var _ storage.RoutesStore = (*Routes)(nil) diff --git a/api/payments/orchestrator/storage/storage.go b/api/payments/orchestrator/storage/storage.go index a06fd70..2f4e524 100644 --- a/api/payments/orchestrator/storage/storage.go +++ b/api/payments/orchestrator/storage/storage.go @@ -22,6 +22,10 @@ var ( ErrQuoteNotFound = storageError("payments.orchestrator.storage: quote not found") // ErrDuplicateQuote signals that a quote reference already exists. ErrDuplicateQuote = storageError("payments.orchestrator.storage: duplicate quote") + // ErrRouteNotFound signals that a payment route record does not exist. + ErrRouteNotFound = storageError("payments.orchestrator.storage: route not found") + // ErrDuplicateRoute signals that a route already exists for the same transition. + ErrDuplicateRoute = storageError("payments.orchestrator.storage: duplicate route") ) // Repository exposes persistence primitives for the orchestrator domain. @@ -29,6 +33,7 @@ type Repository interface { Ping(ctx context.Context) error Payments() PaymentsStore Quotes() QuotesStore + Routes() RoutesStore } // PaymentsStore manages payment lifecycle state. @@ -46,3 +51,11 @@ type QuotesStore interface { Create(ctx context.Context, quote *model.PaymentQuoteRecord) error GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) } + +// RoutesStore manages allowed routing transitions. +type RoutesStore interface { + Create(ctx context.Context, route *model.PaymentRoute) error + Update(ctx context.Context, route *model.PaymentRoute) error + GetByID(ctx context.Context, id primitive.ObjectID) (*model.PaymentRoute, error) + List(ctx context.Context, filter *model.PaymentRouteFilter) (*model.PaymentRouteList, error) +} diff --git a/api/pkg/discovery/announcer.go b/api/pkg/discovery/announcer.go new file mode 100644 index 0000000..5480974 --- /dev/null +++ b/api/pkg/discovery/announcer.go @@ -0,0 +1,157 @@ +package discovery + +import ( + "os" + "strings" + "sync" + "time" + + "github.com/google/uuid" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/mlogger" +) + +type Announcer struct { + logger mlogger.Logger + producer msg.Producer + sender string + announce Announcement + + startOnce sync.Once + stopOnce sync.Once + stopCh chan struct{} + doneCh chan struct{} +} + +func NewAnnouncer(logger mlogger.Logger, producer msg.Producer, sender string, announce Announcement) *Announcer { + if logger != nil { + logger = logger.Named("discovery") + } + announce = normalizeAnnouncement(announce) + if announce.Service == "" { + announce.Service = strings.TrimSpace(sender) + } + if announce.ID == "" { + announce.ID = DefaultInstanceID(announce.Service) + } + if announce.InvokeURI == "" && announce.Service != "" { + announce.InvokeURI = DefaultInvokeURI(announce.Service) + } + return &Announcer{ + logger: logger, + producer: producer, + sender: strings.TrimSpace(sender), + announce: announce, + stopCh: make(chan struct{}), + doneCh: make(chan struct{}), + } +} + +func (a *Announcer) Start() { + if a == nil { + return + } + a.startOnce.Do(func() { + if a.producer == nil { + a.logWarn("Discovery announce skipped: producer not configured") + close(a.doneCh) + return + } + if strings.TrimSpace(a.announce.ID) == "" { + a.logWarn("Discovery announce skipped: missing instance id") + close(a.doneCh) + return + } + a.sendAnnouncement() + a.sendHeartbeat() + go a.heartbeatLoop() + }) +} + +func (a *Announcer) Stop() { + if a == nil { + return + } + a.stopOnce.Do(func() { + close(a.stopCh) + <-a.doneCh + }) +} + +func (a *Announcer) heartbeatLoop() { + defer close(a.doneCh) + interval := time.Duration(a.announce.Health.IntervalSec) * time.Second + if interval <= 0 { + interval = time.Duration(DefaultHealthIntervalSec) * time.Second + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + for { + select { + case <-a.stopCh: + return + case <-ticker.C: + a.sendHeartbeat() + } + } +} + +func (a *Announcer) sendAnnouncement() { + env := NewServiceAnnounceEnvelope(a.sender, a.announce) + if a.announce.Rail != "" { + env = NewGatewayAnnounceEnvelope(a.sender, a.announce) + } + if err := a.producer.SendMessage(env); err != nil { + a.logWarn("Failed to publish discovery announce: " + err.Error()) + return + } + a.logInfo("Discovery announce published") +} + +func (a *Announcer) sendHeartbeat() { + hb := Heartbeat{ + ID: a.announce.ID, + Status: "ok", + TS: time.Now().Unix(), + } + if err := a.producer.SendMessage(NewHeartbeatEnvelope(a.sender, hb)); err != nil { + a.logWarn("Failed to publish discovery heartbeat: " + err.Error()) + } +} + +func (a *Announcer) logInfo(message string) { + if a.logger == nil { + return + } + a.logger.Info(message) +} + +func (a *Announcer) logWarn(message string) { + if a.logger == nil { + return + } + a.logger.Warn(message) +} + +func DefaultInstanceID(service string) string { + clean := strings.ToLower(strings.TrimSpace(service)) + if clean == "" { + clean = "service" + } + host, _ := os.Hostname() + host = strings.ToLower(strings.TrimSpace(host)) + uid := uuid.NewString() + if host == "" { + return clean + "_" + uid + } + return clean + "_" + host + "_" + uid +} + +func DefaultInvokeURI(service string) string { + clean := strings.ToLower(strings.TrimSpace(service)) + if clean == "" { + return "" + } + return "grpc://" + clean +} diff --git a/api/pkg/discovery/client.go b/api/pkg/discovery/client.go new file mode 100644 index 0000000..40ff434 --- /dev/null +++ b/api/pkg/discovery/client.go @@ -0,0 +1,137 @@ +package discovery + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + + "github.com/google/uuid" + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/messaging/broker" + cons "github.com/tech/sendico/pkg/messaging/consumer" + me "github.com/tech/sendico/pkg/messaging/envelope" + msgproducer "github.com/tech/sendico/pkg/messaging/producer" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type Client struct { + logger mlogger.Logger + producer msg.Producer + consumer msg.Consumer + sender string + + mu sync.Mutex + pending map[string]chan LookupResponse +} + +func NewClient(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, sender string) (*Client, error) { + if broker == nil { + return nil, errors.New("discovery client: broker is nil") + } + if logger != nil { + logger = logger.Named("discovery_client") + } + if producer == nil { + producer = msgproducer.NewProducer(logger, broker) + } + sender = strings.TrimSpace(sender) + if sender == "" { + sender = "discovery_client" + } + + consumer, err := cons.NewConsumer(logger, broker, LookupResponseEvent()) + if err != nil { + return nil, err + } + + client := &Client{ + logger: logger, + producer: producer, + consumer: consumer, + sender: sender, + pending: map[string]chan LookupResponse{}, + } + + go func() { + if err := consumer.ConsumeMessages(client.handleLookupResponse); err != nil && client.logger != nil { + client.logger.Warn("Discovery lookup consumer stopped", zap.Error(err)) + } + }() + + return client, nil +} + +func (c *Client) Close() { + if c == nil { + return + } + if c.consumer != nil { + c.consumer.Close() + } + c.mu.Lock() + for key, ch := range c.pending { + close(ch) + delete(c.pending, key) + } + c.mu.Unlock() +} + +func (c *Client) Lookup(ctx context.Context) (LookupResponse, error) { + if c == nil || c.producer == nil { + return LookupResponse{}, errors.New("discovery client: producer not configured") + } + requestID := uuid.NewString() + ch := make(chan LookupResponse, 1) + + c.mu.Lock() + c.pending[requestID] = ch + c.mu.Unlock() + + defer func() { + c.mu.Lock() + delete(c.pending, requestID) + c.mu.Unlock() + }() + + req := LookupRequest{RequestID: requestID} + if err := c.producer.SendMessage(NewLookupRequestEnvelope(c.sender, req)); err != nil { + return LookupResponse{}, err + } + + select { + case <-ctx.Done(): + return LookupResponse{}, ctx.Err() + case resp := <-ch: + return resp, nil + } +} + +func (c *Client) handleLookupResponse(_ context.Context, env me.Envelope) error { + var payload LookupResponse + if err := json.Unmarshal(env.GetData(), &payload); err != nil { + c.logWarn("Failed to decode discovery lookup response", zap.Error(err)) + return err + } + requestID := strings.TrimSpace(payload.RequestID) + if requestID == "" { + return nil + } + + c.mu.Lock() + ch := c.pending[requestID] + c.mu.Unlock() + if ch != nil { + ch <- payload + } + return nil +} + +func (c *Client) logWarn(message string, fields ...zap.Field) { + if c == nil || c.logger == nil { + return + } + c.logger.Warn(message, fields...) +} diff --git a/api/pkg/discovery/events.go b/api/pkg/discovery/events.go new file mode 100644 index 0000000..2eeb44b --- /dev/null +++ b/api/pkg/discovery/events.go @@ -0,0 +1,31 @@ +package discovery + +import ( + "github.com/tech/sendico/pkg/model" + nm "github.com/tech/sendico/pkg/model/notification" + "github.com/tech/sendico/pkg/mservice" +) + +func ServiceAnnounceEvent() model.NotificationEvent { + return model.NewNotification(mservice.Discovery, nm.NADiscoveryServiceAnnounce) +} + +func GatewayAnnounceEvent() model.NotificationEvent { + return model.NewNotification(mservice.Discovery, nm.NADiscoveryGatewayAnnounce) +} + +func HeartbeatEvent() model.NotificationEvent { + return model.NewNotification(mservice.Discovery, nm.NADiscoveryHeartbeat) +} + +func LookupRequestEvent() model.NotificationEvent { + return model.NewNotification(mservice.Discovery, nm.NADiscoveryLookupRequest) +} + +func LookupResponseEvent() model.NotificationEvent { + return model.NewNotification(mservice.Discovery, nm.NADiscoveryLookupResponse) +} + +func RefreshUIEvent() model.NotificationEvent { + return model.NewNotification(mservice.Discovery, nm.NADiscoveryRefreshUI) +} diff --git a/api/pkg/discovery/lookup.go b/api/pkg/discovery/lookup.go new file mode 100644 index 0000000..2658307 --- /dev/null +++ b/api/pkg/discovery/lookup.go @@ -0,0 +1,69 @@ +package discovery + +type LookupRequest struct { + RequestID string `json:"requestId,omitempty"` +} + +type LookupResponse struct { + RequestID string `json:"requestId,omitempty"` + Services []ServiceSummary `json:"services,omitempty"` + Gateways []GatewaySummary `json:"gateways,omitempty"` +} + +type ServiceSummary struct { + ID string `json:"id"` + Service string `json:"service"` + Ops []string `json:"ops,omitempty"` + Version string `json:"version,omitempty"` + Healthy bool `json:"healthy,omitempty"` + InvokeURI string `json:"invokeURI,omitempty"` +} + +type GatewaySummary struct { + ID string `json:"id"` + Rail string `json:"rail"` + Network string `json:"network,omitempty"` + Currencies []string `json:"currencies,omitempty"` + Ops []string `json:"ops,omitempty"` + Limits *Limits `json:"limits,omitempty"` + Version string `json:"version,omitempty"` + Healthy bool `json:"healthy,omitempty"` + RoutingPriority int `json:"routingPriority,omitempty"` + InvokeURI string `json:"invokeURI,omitempty"` +} + +func (r *Registry) Lookup(now time.Time) LookupResponse { + entries := r.List(now, true) + resp := LookupResponse{ + Services: make([]ServiceSummary, 0), + Gateways: make([]GatewaySummary, 0), + } + + for _, entry := range entries { + if entry.Rail != "" { + resp.Gateways = append(resp.Gateways, GatewaySummary{ + ID: entry.ID, + Rail: entry.Rail, + Network: entry.Network, + Currencies: cloneStrings(entry.Currencies), + Ops: cloneStrings(entry.Operations), + Limits: cloneLimits(entry.Limits), + Version: entry.Version, + Healthy: entry.Healthy, + RoutingPriority: entry.RoutingPriority, + InvokeURI: entry.InvokeURI, + }) + continue + } + resp.Services = append(resp.Services, ServiceSummary{ + ID: entry.ID, + Service: entry.Service, + Ops: cloneStrings(entry.Operations), + Version: entry.Version, + Healthy: entry.Healthy, + InvokeURI: entry.InvokeURI, + }) + } + + return resp +} diff --git a/api/pkg/discovery/messages.go b/api/pkg/discovery/messages.go new file mode 100644 index 0000000..7acb5e4 --- /dev/null +++ b/api/pkg/discovery/messages.go @@ -0,0 +1,56 @@ +package discovery + +import ( + "encoding/json" + "errors" + + messaging "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/model" +) + +type jsonEnvelope struct { + messaging.Envelope + payload any +} + +func (e *jsonEnvelope) Serialize() ([]byte, error) { + if e.payload == nil { + return nil, errors.New("discovery envelope payload is nil") + } + data, err := json.Marshal(e.payload) + if err != nil { + return nil, err + } + return e.Envelope.Wrap(data) +} + +func newEnvelope(sender string, event model.NotificationEvent, payload any) messaging.Envelope { + return &jsonEnvelope{ + Envelope: messaging.CreateEnvelope(sender, event), + payload: payload, + } +} + +func NewServiceAnnounceEnvelope(sender string, payload Announcement) messaging.Envelope { + return newEnvelope(sender, ServiceAnnounceEvent(), payload) +} + +func NewGatewayAnnounceEnvelope(sender string, payload Announcement) messaging.Envelope { + return newEnvelope(sender, GatewayAnnounceEvent(), payload) +} + +func NewHeartbeatEnvelope(sender string, payload Heartbeat) messaging.Envelope { + return newEnvelope(sender, HeartbeatEvent(), payload) +} + +func NewLookupRequestEnvelope(sender string, payload LookupRequest) messaging.Envelope { + return newEnvelope(sender, LookupRequestEvent(), payload) +} + +func NewLookupResponseEnvelope(sender string, payload LookupResponse) messaging.Envelope { + return newEnvelope(sender, LookupResponseEvent(), payload) +} + +func NewRefreshUIEnvelope(sender string, payload RefreshEvent) messaging.Envelope { + return newEnvelope(sender, RefreshUIEvent(), payload) +} diff --git a/api/pkg/discovery/registry.go b/api/pkg/discovery/registry.go new file mode 100644 index 0000000..8644d35 --- /dev/null +++ b/api/pkg/discovery/registry.go @@ -0,0 +1,258 @@ +package discovery + +import ( + "strings" + "sync" + "time" +) + +const ( + DefaultHealthIntervalSec = 10 + DefaultHealthTimeoutSec = 30 +) + +type RegistryEntry struct { + ID string `json:"id"` + Service string `json:"service"` + Rail string `json:"rail,omitempty"` + Network string `json:"network,omitempty"` + Operations []string `json:"operations,omitempty"` + Currencies []string `json:"currencies,omitempty"` + Limits *Limits `json:"limits,omitempty"` + InvokeURI string `json:"invokeURI,omitempty"` + RoutingPriority int `json:"routingPriority,omitempty"` + Version string `json:"version,omitempty"` + Health HealthParams `json:"health,omitempty"` + LastHeartbeat time.Time `json:"lastHeartbeat,omitempty"` + Status string `json:"status,omitempty"` + Healthy bool `json:"healthy,omitempty"` +} + +type Registry struct { + mu sync.RWMutex + entries map[string]*RegistryEntry +} + +type UpdateResult struct { + Entry RegistryEntry + IsNew bool + WasHealthy bool + BecameHealthy bool +} + +func NewRegistry() *Registry { + return &Registry{ + entries: map[string]*RegistryEntry{}, + } +} + +func (r *Registry) UpsertFromAnnouncement(announce Announcement, now time.Time) UpdateResult { + entry := registryEntryFromAnnouncement(normalizeAnnouncement(announce), now) + + r.mu.Lock() + defer r.mu.Unlock() + + existing, ok := r.entries[entry.ID] + wasHealthy := false + if ok && existing != nil { + wasHealthy = existing.isHealthyAt(now) + } + entry.Healthy = entry.isHealthyAt(now) + r.entries[entry.ID] = &entry + + return UpdateResult{ + Entry: entry, + IsNew: !ok, + WasHealthy: wasHealthy, + BecameHealthy: !wasHealthy && entry.Healthy, + } +} + +func (r *Registry) UpdateHeartbeat(id string, status string, ts time.Time, now time.Time) (UpdateResult, bool) { + id = strings.TrimSpace(id) + if id == "" { + return UpdateResult{}, false + } + if status == "" { + status = "ok" + } + if ts.IsZero() { + ts = now + } + + r.mu.Lock() + defer r.mu.Unlock() + + entry, ok := r.entries[id] + if !ok || entry == nil { + return UpdateResult{}, false + } + wasHealthy := entry.isHealthyAt(now) + entry.Status = status + entry.LastHeartbeat = ts + entry.Healthy = entry.isHealthyAt(now) + + return UpdateResult{ + Entry: *entry, + IsNew: false, + WasHealthy: wasHealthy, + BecameHealthy: !wasHealthy && entry.Healthy, + }, true +} + +func (r *Registry) List(now time.Time, onlyHealthy bool) []RegistryEntry { + r.mu.Lock() + defer r.mu.Unlock() + + result := make([]RegistryEntry, 0, len(r.entries)) + for _, entry := range r.entries { + if entry == nil { + continue + } + entry.Healthy = entry.isHealthyAt(now) + if onlyHealthy && !entry.Healthy { + continue + } + cp := *entry + result = append(result, cp) + } + return result +} + +func registryEntryFromAnnouncement(announce Announcement, now time.Time) RegistryEntry { + status := "ok" + return RegistryEntry{ + ID: strings.TrimSpace(announce.ID), + Service: strings.TrimSpace(announce.Service), + Rail: strings.ToUpper(strings.TrimSpace(announce.Rail)), + Network: strings.ToUpper(strings.TrimSpace(announce.Network)), + Operations: cloneStrings(announce.Operations), + Currencies: cloneStrings(announce.Currencies), + Limits: cloneLimits(announce.Limits), + InvokeURI: strings.TrimSpace(announce.InvokeURI), + RoutingPriority: announce.RoutingPriority, + Version: strings.TrimSpace(announce.Version), + Health: normalizeHealth(announce.Health), + LastHeartbeat: now, + Status: status, + } +} + +func normalizeAnnouncement(announce Announcement) Announcement { + announce.ID = strings.TrimSpace(announce.ID) + announce.Service = strings.TrimSpace(announce.Service) + announce.Rail = strings.ToUpper(strings.TrimSpace(announce.Rail)) + announce.Network = strings.ToUpper(strings.TrimSpace(announce.Network)) + announce.Operations = normalizeStrings(announce.Operations, false) + announce.Currencies = normalizeStrings(announce.Currencies, true) + announce.InvokeURI = strings.TrimSpace(announce.InvokeURI) + announce.Version = strings.TrimSpace(announce.Version) + announce.Health = normalizeHealth(announce.Health) + if announce.Limits != nil { + announce.Limits = normalizeLimits(*announce.Limits) + } + return announce +} + +func normalizeHealth(h HealthParams) HealthParams { + if h.IntervalSec <= 0 { + h.IntervalSec = DefaultHealthIntervalSec + } + if h.TimeoutSec <= 0 { + h.TimeoutSec = DefaultHealthTimeoutSec + } + if h.TimeoutSec < h.IntervalSec { + h.TimeoutSec = h.IntervalSec * 2 + } + return h +} + +func normalizeLimits(l Limits) *Limits { + res := l + if len(res.VolumeLimit) == 0 { + res.VolumeLimit = nil + } + if len(res.VelocityLimit) == 0 { + res.VelocityLimit = nil + } + return &res +} + +func cloneLimits(src *Limits) *Limits { + if src == nil { + return nil + } + dst := *src + if src.VolumeLimit != nil { + dst.VolumeLimit = map[string]string{} + for key, value := range src.VolumeLimit { + if strings.TrimSpace(key) == "" { + continue + } + dst.VolumeLimit[strings.TrimSpace(key)] = strings.TrimSpace(value) + } + } + if src.VelocityLimit != nil { + dst.VelocityLimit = map[string]int{} + for key, value := range src.VelocityLimit { + if strings.TrimSpace(key) == "" { + continue + } + dst.VelocityLimit[strings.TrimSpace(key)] = value + } + } + return &dst +} + +func normalizeStrings(values []string, upper bool) []string { + if len(values) == 0 { + return nil + } + seen := map[string]bool{} + result := make([]string, 0, len(values)) + for _, value := range values { + clean := strings.TrimSpace(value) + if clean == "" { + continue + } + if upper { + clean = strings.ToUpper(clean) + } + if seen[clean] { + continue + } + seen[clean] = true + result = append(result, clean) + } + if len(result) == 0 { + return nil + } + return result +} + +func cloneStrings(values []string) []string { + if len(values) == 0 { + return nil + } + out := make([]string, len(values)) + copy(out, values) + return out +} + +func (e *RegistryEntry) isHealthyAt(now time.Time) bool { + if e == nil { + return false + } + status := strings.ToLower(strings.TrimSpace(e.Status)) + if status != "" && status != "ok" { + return false + } + if e.LastHeartbeat.IsZero() { + return false + } + timeout := time.Duration(e.Health.TimeoutSec) * time.Second + if timeout <= 0 { + timeout = time.Duration(DefaultHealthTimeoutSec) * time.Second + } + return now.Sub(e.LastHeartbeat) <= timeout +} diff --git a/api/pkg/discovery/service.go b/api/pkg/discovery/service.go new file mode 100644 index 0000000..491c203 --- /dev/null +++ b/api/pkg/discovery/service.go @@ -0,0 +1,188 @@ +package discovery + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "time" + + msg "github.com/tech/sendico/pkg/messaging" + "github.com/tech/sendico/pkg/messaging/broker" + cons "github.com/tech/sendico/pkg/messaging/consumer" + me "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/mlogger" + "go.uber.org/zap" +) + +type RegistryService struct { + logger mlogger.Logger + registry *Registry + producer msg.Producer + sender string + consumers []consumerHandler + + startOnce sync.Once + stopOnce sync.Once +} + +type consumerHandler struct { + consumer msg.Consumer + handler msg.MessageHandlerT +} + +func NewRegistryService(logger mlogger.Logger, broker broker.Broker, producer msg.Producer, registry *Registry, sender string) (*RegistryService, error) { + if broker == nil { + return nil, errors.New("discovery registry: broker is nil") + } + if registry == nil { + registry = NewRegistry() + } + if logger != nil { + logger = logger.Named("discovery_registry") + } + sender = strings.TrimSpace(sender) + if sender == "" { + sender = "discovery" + } + + serviceConsumer, err := cons.NewConsumer(logger, broker, ServiceAnnounceEvent()) + if err != nil { + return nil, err + } + gatewayConsumer, err := cons.NewConsumer(logger, broker, GatewayAnnounceEvent()) + if err != nil { + return nil, err + } + heartbeatConsumer, err := cons.NewConsumer(logger, broker, HeartbeatEvent()) + if err != nil { + return nil, err + } + lookupConsumer, err := cons.NewConsumer(logger, broker, LookupRequestEvent()) + if err != nil { + return nil, err + } + + svc := &RegistryService{ + logger: logger, + registry: registry, + producer: producer, + sender: sender, + consumers: []consumerHandler{ + {consumer: serviceConsumer, handler: func(ctx context.Context, env me.Envelope) error { + return svc.handleAnnounce(ctx, env) + }}, + {consumer: gatewayConsumer, handler: func(ctx context.Context, env me.Envelope) error { + return svc.handleAnnounce(ctx, env) + }}, + {consumer: heartbeatConsumer, handler: svc.handleHeartbeat}, + {consumer: lookupConsumer, handler: svc.handleLookup}, + }, + } + return svc, nil +} + +func (s *RegistryService) Start() { + if s == nil { + return + } + s.startOnce.Do(func() { + for _, ch := range s.consumers { + ch := ch + go func() { + if err := ch.consumer.ConsumeMessages(ch.handler); err != nil && s.logger != nil { + s.logger.Warn("Discovery consumer stopped with error", zap.Error(err)) + } + }() + } + }) +} + +func (s *RegistryService) Stop() { + if s == nil { + return + } + s.stopOnce.Do(func() { + for _, ch := range s.consumers { + if ch.consumer != nil { + ch.consumer.Close() + } + } + }) +} + +func (s *RegistryService) handleAnnounce(_ context.Context, env me.Envelope) error { + var payload Announcement + if err := json.Unmarshal(env.GetData(), &payload); err != nil { + s.logWarn("Failed to decode discovery announce payload", zap.Error(err)) + return err + } + now := time.Now() + result := s.registry.UpsertFromAnnouncement(payload, now) + if result.IsNew || result.BecameHealthy { + s.publishRefresh(result.Entry) + } + return nil +} + +func (s *RegistryService) handleHeartbeat(_ context.Context, env me.Envelope) error { + var payload Heartbeat + if err := json.Unmarshal(env.GetData(), &payload); err != nil { + s.logWarn("Failed to decode discovery heartbeat payload", zap.Error(err)) + return err + } + if payload.ID == "" { + return nil + } + ts := time.Unix(payload.TS, 0) + if ts.Unix() <= 0 { + ts = time.Now() + } + result, ok := s.registry.UpdateHeartbeat(payload.ID, strings.TrimSpace(payload.Status), ts, time.Now()) + if ok && result.BecameHealthy { + s.publishRefresh(result.Entry) + } + return nil +} + +func (s *RegistryService) handleLookup(_ context.Context, env me.Envelope) error { + if s.producer == nil { + s.logWarn("Discovery lookup request ignored: producer not configured") + return nil + } + var payload LookupRequest + if err := json.Unmarshal(env.GetData(), &payload); err != nil { + s.logWarn("Failed to decode discovery lookup payload", zap.Error(err)) + return err + } + resp := s.registry.Lookup(time.Now()) + resp.RequestID = strings.TrimSpace(payload.RequestID) + if err := s.producer.SendMessage(NewLookupResponseEnvelope(s.sender, resp)); err != nil { + s.logWarn("Failed to publish discovery lookup response", zap.Error(err)) + return err + } + return nil +} + +func (s *RegistryService) publishRefresh(entry RegistryEntry) { + if s == nil || s.producer == nil { + return + } + payload := RefreshEvent{ + Service: entry.Service, + Rail: entry.Rail, + Network: entry.Network, + Message: "new module available", + } + if err := s.producer.SendMessage(NewRefreshUIEnvelope(s.sender, payload)); err != nil { + s.logWarn("Failed to publish discovery refresh event", zap.Error(err)) + } +} + +func (s *RegistryService) logWarn(message string, fields ...zap.Field) { + if s.logger == nil { + return + } + s.logger.Warn(message, fields...) +} diff --git a/api/pkg/discovery/subjects.go b/api/pkg/discovery/subjects.go new file mode 100644 index 0000000..2ffcc11 --- /dev/null +++ b/api/pkg/discovery/subjects.go @@ -0,0 +1,10 @@ +package discovery + +const ( + SubjectServiceAnnounce = "discovery.service.announce" + SubjectGatewayAnnounce = "discovery.gateway.announce" + SubjectHeartbeat = "discovery.service.heartbeat" + SubjectLookupRequest = "discovery.request.lookup" + SubjectLookupResponse = "discovery.response.lookup" + SubjectRefreshUI = "discovery.event.refresh_ui" +) diff --git a/api/pkg/discovery/types.go b/api/pkg/discovery/types.go new file mode 100644 index 0000000..8a0e19a --- /dev/null +++ b/api/pkg/discovery/types.go @@ -0,0 +1,40 @@ +package discovery + +type HealthParams struct { + IntervalSec int `json:"intervalSec"` + TimeoutSec int `json:"timeoutSec"` +} + +type Limits struct { + MinAmount string `json:"minAmount,omitempty"` + MaxAmount string `json:"maxAmount,omitempty"` + VolumeLimit map[string]string `json:"volumeLimit,omitempty"` + VelocityLimit map[string]int `json:"velocityLimit,omitempty"` +} + +type Announcement struct { + ID string `json:"id"` + Service string `json:"service"` + Rail string `json:"rail,omitempty"` + Network string `json:"network,omitempty"` + Operations []string `json:"operations,omitempty"` + Currencies []string `json:"currencies,omitempty"` + Limits *Limits `json:"limits,omitempty"` + InvokeURI string `json:"invokeURI,omitempty"` + RoutingPriority int `json:"routingPriority,omitempty"` + Version string `json:"version,omitempty"` + Health HealthParams `json:"health,omitempty"` +} + +type Heartbeat struct { + ID string `json:"id"` + Status string `json:"status"` + TS int64 `json:"ts"` +} + +type RefreshEvent struct { + Service string `json:"service,omitempty"` + Rail string `json:"rail,omitempty"` + Network string `json:"network,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/api/pkg/messaging/consumer/consumer.go b/api/pkg/messaging/consumer/consumer.go new file mode 100644 index 0000000..dae287c --- /dev/null +++ b/api/pkg/messaging/consumer/consumer.go @@ -0,0 +1,66 @@ +package consumer + +import ( + "context" + + messaging "github.com/tech/sendico/pkg/messaging" + mb "github.com/tech/sendico/pkg/messaging/broker" + me "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/mlogger" + "github.com/tech/sendico/pkg/model" + "go.uber.org/zap" +) + +type ChannelConsumer struct { + logger mlogger.Logger + broker mb.Broker + event model.NotificationEvent + ch <-chan me.Envelope + ctx context.Context + cancel context.CancelFunc +} + +func (c *ChannelConsumer) ConsumeMessages(handleFunc messaging.MessageHandlerT) error { + c.logger.Info("Message consumer is ready") + for { + select { + case msg := <-c.ch: + if msg == nil { // nil message indicates the channel was closed + c.logger.Info("Consumer shutting down") + return nil + } + if err := handleFunc(c.ctx, msg); err != nil { + c.logger.Warn("Error processing message", zap.Error(err)) + } + case <-c.ctx.Done(): + c.logger.Info("Context done, shutting down") + return c.ctx.Err() + } + } +} + +func (c *ChannelConsumer) Close() { + c.logger.Info("Shutting down...") + c.cancel() + if err := c.broker.Unsubscribe(c.event, c.ch); err != nil { + c.logger.Warn("Failed to unsubscribe", zap.Error(err)) + } +} + +func NewConsumer(logger mlogger.Logger, broker mb.Broker, event model.NotificationEvent) (*ChannelConsumer, error) { + ctx, cancel := context.WithCancel(context.Background()) + ch, err := broker.Subscribe(event) + if err != nil { + logger.Warn("Failed to create channel consumer", zap.Error(err), zap.String("topic", event.ToString())) + cancel() + return nil, err + } + return &ChannelConsumer{ + logger: logger.Named("consumer").Named(event.ToString()), + broker: broker, + event: event, + ch: ch, + ctx: ctx, + cancel: cancel, + }, nil +} diff --git a/api/pkg/model/notification/notification.go b/api/pkg/model/notification/notification.go index 39f3215..0d08515 100644 --- a/api/pkg/model/notification/notification.go +++ b/api/pkg/model/notification/notification.go @@ -11,4 +11,11 @@ const ( NAArchived NotificationAction = "archived" NASent NotificationAction = "sent" NAPasswordReset NotificationAction = "password_reset" + + NADiscoveryServiceAnnounce NotificationAction = "service.announce" + NADiscoveryGatewayAnnounce NotificationAction = "gateway.announce" + NADiscoveryHeartbeat NotificationAction = "service.heartbeat" + NADiscoveryLookupRequest NotificationAction = "request.lookup" + NADiscoveryLookupResponse NotificationAction = "response.lookup" + NADiscoveryRefreshUI NotificationAction = "event.refresh_ui" ) diff --git a/api/pkg/model/notificationevent.go b/api/pkg/model/notificationevent.go index d6d0024..9208114 100644 --- a/api/pkg/model/notificationevent.go +++ b/api/pkg/model/notificationevent.go @@ -36,7 +36,11 @@ func (ne *NotificationEventImp) Equals(other *NotificationEventImp) bool { } func (ne *NotificationEventImp) ToString() string { - return ne.StringType() + messageDelimiter + ne.StringAction() + action := ne.StringAction() + if strings.Contains(action, ".") { + return ne.StringType() + "." + action + } + return ne.StringType() + messageDelimiter + action } func (ne *NotificationEventImp) StringType() string { @@ -76,7 +80,13 @@ func StringToNotificationAction(s string) (nm.NotificationAction, error) { nm.NAUpdated, nm.NADeleted, nm.NAAssigned, - nm.NAPasswordReset: + nm.NAPasswordReset, + nm.NADiscoveryServiceAnnounce, + nm.NADiscoveryGatewayAnnounce, + nm.NADiscoveryHeartbeat, + nm.NADiscoveryLookupRequest, + nm.NADiscoveryLookupResponse, + nm.NADiscoveryRefreshUI: return nm.NotificationAction(s), nil default: return "", merrors.DataConflict("invalid Notification action: " + s) diff --git a/api/pkg/mservice/services.go b/api/pkg/mservice/services.go index d5f8293..1571cd3 100644 --- a/api/pkg/mservice/services.go +++ b/api/pkg/mservice/services.go @@ -8,6 +8,7 @@ const ( Accounts Type = "accounts" // Represents user accounts in the system Confirmations Type = "confirmations" // Represents confirmation code flows Amplitude Type = "amplitude" // Represents analytics integration with Amplitude + Discovery Type = "discovery" // Represents service discovery registry Site Type = "site" // Represents public site endpoints Changes Type = "changes" // Tracks changes made to resources Clients Type = "clients" // Represents client information @@ -34,6 +35,7 @@ const ( Notifications Type = "notifications" // Represents notifications sent to users Organizations Type = "organizations" // Represents organizations in the system Payments Type = "payments" // Represents payments service + PaymentRoutes Type = "payment_routes" // Represents payment routing definitions PaymentMethods Type = "payment_methods" // Represents payment methods service Permissions Type = "permissions" // Represents permissiosns service Policies Type = "policies" // Represents access control policies @@ -52,8 +54,8 @@ func StringToSType(s string) (Type, error) { case Accounts, Confirmations, Amplitude, Site, Changes, Clients, ChainGateway, ChainWallets, ChainWalletBalances, ChainTransfers, ChainDeposits, MntxGateway, FXOracle, FeePlans, FilterProjects, Invitations, Invoices, Logo, Ledger, LedgerAccounts, LedgerBalances, LedgerEntries, LedgerOutbox, LedgerParties, LedgerPlines, Notifications, - Organizations, Payments, PaymentOrchestrator, Permissions, Policies, PolicyAssignements, - RefreshTokens, Roles, Storage, Tenants, Workflows: + Organizations, Payments, PaymentRoutes, PaymentOrchestrator, Permissions, Policies, PolicyAssignements, + RefreshTokens, Roles, Storage, Tenants, Workflows, Discovery: return Type(s), nil default: return "", merrors.InvalidArgument("invalid service type", s) diff --git a/api/pkg/payments/rail/gateway.go b/api/pkg/payments/rail/gateway.go new file mode 100644 index 0000000..1bf2aee --- /dev/null +++ b/api/pkg/payments/rail/gateway.go @@ -0,0 +1,83 @@ +package rail + +import ( + "context" + + paymenttypes "github.com/tech/sendico/pkg/payments/types" +) + +// Money represents a currency amount using decimal-safe strings. +type Money = paymenttypes.Money + +const ( + TransferStatusUnspecified = "UNSPECIFIED" + TransferStatusSuccess = "SUCCESS" + TransferStatusFailed = "FAILED" + TransferStatusRejected = "REJECTED" + TransferStatusPending = "PENDING" +) + +// RailCapabilities are declared per gateway instance. +type RailCapabilities struct { + CanPayIn bool + CanPayOut bool + CanReadBalance bool + CanSendFee bool + RequiresObserveConfirm bool +} + +// FeeBreakdown provides a gateway-level fee description. +type FeeBreakdown struct { + FeeCode string + Amount *Money + Description string +} + +// TransferRequest defines the inputs for sending value through a rail gateway. +type TransferRequest struct { + OrganizationRef string + FromAccountID string + ToAccountID string + Currency string + Network string + Amount string + Fee *Money + Fees []FeeBreakdown + IdempotencyKey string + Metadata map[string]string + ClientReference string + DestinationMemo string +} + +// RailResult reports the outcome of a rail gateway operation. +type RailResult struct { + ReferenceID string + Status string + FinalAmount *Money + Error *RailError +} + +// ObserveResult reports the outcome of a confirmation observation. +type ObserveResult struct { + ReferenceID string + Status string + FinalAmount *Money + Error *RailError +} + +// RailError captures structured failure details from a gateway. +type RailError struct { + Code string + Message string + CanRetry bool + ShouldRollback bool +} + +// RailGateway exposes unified gateway operations for external rails. +type RailGateway interface { + Rail() string + Network() string + Capabilities() RailCapabilities + Send(ctx context.Context, req TransferRequest) (RailResult, error) + Observe(ctx context.Context, referenceID string) (ObserveResult, error) +} diff --git a/api/pkg/payments/rail/ledger.go b/api/pkg/payments/rail/ledger.go new file mode 100644 index 0000000..988c5bc --- /dev/null +++ b/api/pkg/payments/rail/ledger.go @@ -0,0 +1,38 @@ +package rail + +import ( + "context" + "time" + + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + ledgerv1 "github.com/tech/sendico/pkg/proto/ledger/v1" +) + +// InternalLedger exposes unified ledger operations for orchestration. +type InternalLedger interface { + ReadBalance(ctx context.Context, accountID string) (*moneyv1.Money, error) + CreateTransaction(ctx context.Context, tx LedgerTx) (string, error) + HoldBalance(ctx context.Context, accountID string, amount string) error +} + +// LedgerTx captures ledger posting context used by orchestration. +type LedgerTx struct { + PaymentPlanID string + Currency string + Amount string + FeeAmount string + FromRail string + ToRail string + ExternalReferenceID string + FXRateUsed string + IdempotencyKey string + CreatedAt time.Time + + // Internal fields required to map into the ledger API. + OrganizationRef string + LedgerAccountRef string + ContraLedgerAccountRef string + Description string + Charges []*ledgerv1.PostingLine + Metadata map[string]string +} diff --git a/api/pkg/payments/types/chain.go b/api/pkg/payments/types/chain.go new file mode 100644 index 0000000..782cb1d --- /dev/null +++ b/api/pkg/payments/types/chain.go @@ -0,0 +1,49 @@ +package types + +// Asset captures a chain and token identifier. +type Asset struct { + Chain string `bson:"chain,omitempty" json:"chain,omitempty"` + TokenSymbol string `bson:"tokenSymbol,omitempty" json:"tokenSymbol,omitempty"` + ContractAddress string `bson:"contractAddress,omitempty" json:"contractAddress,omitempty"` +} + +func (a *Asset) GetChain() string { + if a == nil { + return "" + } + return a.Chain +} + +func (a *Asset) GetTokenSymbol() string { + if a == nil { + return "" + } + return a.TokenSymbol +} + +func (a *Asset) GetContractAddress() string { + if a == nil { + return "" + } + return a.ContractAddress +} + +// NetworkFeeEstimate captures network fee estimation output. +type NetworkFeeEstimate struct { + NetworkFee *Money `bson:"networkFee,omitempty" json:"networkFee,omitempty"` + EstimationContext string `bson:"estimationContext,omitempty" json:"estimationContext,omitempty"` +} + +func (n *NetworkFeeEstimate) GetNetworkFee() *Money { + if n == nil { + return nil + } + return n.NetworkFee +} + +func (n *NetworkFeeEstimate) GetEstimationContext() string { + if n == nil { + return "" + } + return n.EstimationContext +} diff --git a/api/pkg/payments/types/fees.go b/api/pkg/payments/types/fees.go new file mode 100644 index 0000000..716a5b7 --- /dev/null +++ b/api/pkg/payments/types/fees.go @@ -0,0 +1,94 @@ +package types + +// InsufficientNetPolicy indicates how to handle insufficient net funds for fees. +type InsufficientNetPolicy string + +const ( + InsufficientNetUnspecified InsufficientNetPolicy = "UNSPECIFIED" + InsufficientNetBlockPosting InsufficientNetPolicy = "BLOCK_POSTING" + InsufficientNetSweepOrgCash InsufficientNetPolicy = "SWEEP_ORG_CASH" + InsufficientNetInvoiceLater InsufficientNetPolicy = "INVOICE_LATER" +) + +// FeePolicy captures optional fee policy overrides. +type FeePolicy struct { + InsufficientNet InsufficientNetPolicy `bson:"insufficientNet,omitempty" json:"insufficientNet,omitempty"` +} + +// EntrySide captures debit/credit semantics for fee lines. +type EntrySide string + +const ( + EntrySideUnspecified EntrySide = "UNSPECIFIED" + EntrySideDebit EntrySide = "DEBIT" + EntrySideCredit EntrySide = "CREDIT" +) + +// PostingLineType captures the semantic type of a fee line. +type PostingLineType string + +const ( + PostingLineTypeUnspecified PostingLineType = "UNSPECIFIED" + PostingLineTypeFee PostingLineType = "FEE" + PostingLineTypeTax PostingLineType = "TAX" + PostingLineTypeSpread PostingLineType = "SPREAD" + PostingLineTypeReversal PostingLineType = "REVERSAL" +) + +// RoundingMode captures rounding behavior for fee rules. +type RoundingMode string + +const ( + RoundingModeUnspecified RoundingMode = "UNSPECIFIED" + RoundingModeHalfEven RoundingMode = "HALF_EVEN" + RoundingModeHalfUp RoundingMode = "HALF_UP" + RoundingModeDown RoundingMode = "DOWN" +) + +// FeeLine stores derived fee posting data. +type FeeLine struct { + LedgerAccountRef string `bson:"ledgerAccountRef,omitempty" json:"ledgerAccountRef,omitempty"` + Money *Money `bson:"money,omitempty" json:"money,omitempty"` + LineType PostingLineType `bson:"lineType,omitempty" json:"lineType,omitempty"` + Side EntrySide `bson:"side,omitempty" json:"side,omitempty"` + Meta map[string]string `bson:"meta,omitempty" json:"meta,omitempty"` +} + +func (l *FeeLine) GetMoney() *Money { + if l == nil { + return nil + } + return l.Money +} + +func (l *FeeLine) GetSide() EntrySide { + if l == nil { + return EntrySideUnspecified + } + return l.Side +} + +func (l *FeeLine) GetLineType() PostingLineType { + if l == nil { + return PostingLineTypeUnspecified + } + return l.LineType +} + +func (l *FeeLine) GetLedgerAccountRef() string { + if l == nil { + return "" + } + return l.LedgerAccountRef +} + +// AppliedRule stores fee rule audit data. +type AppliedRule struct { + RuleID string `bson:"ruleId,omitempty" json:"ruleId,omitempty"` + RuleVersion string `bson:"ruleVersion,omitempty" json:"ruleVersion,omitempty"` + Formula string `bson:"formula,omitempty" json:"formula,omitempty"` + Rounding RoundingMode `bson:"rounding,omitempty" json:"rounding,omitempty"` + TaxCode string `bson:"taxCode,omitempty" json:"taxCode,omitempty"` + TaxRate string `bson:"taxRate,omitempty" json:"taxRate,omitempty"` + Parameters map[string]string `bson:"parameters,omitempty" json:"parameters,omitempty"` +} diff --git a/api/pkg/payments/types/fx.go b/api/pkg/payments/types/fx.go new file mode 100644 index 0000000..3d4cb81 --- /dev/null +++ b/api/pkg/payments/types/fx.go @@ -0,0 +1,107 @@ +package types + +// CurrencyPair describes base/quote currencies. +type CurrencyPair struct { + Base string `bson:"base,omitempty" json:"base,omitempty"` + Quote string `bson:"quote,omitempty" json:"quote,omitempty"` +} + +func (p *CurrencyPair) GetBase() string { + if p == nil { + return "" + } + return p.Base +} + +func (p *CurrencyPair) GetQuote() string { + if p == nil { + return "" + } + return p.Quote +} + +// FXSide indicates the direction of an FX trade. +type FXSide string + +const ( + FXSideUnspecified FXSide = "UNSPECIFIED" + FXSideBuyBaseSellQuote FXSide = "BUY_BASE_SELL_QUOTE" + FXSideSellBaseBuyQuote FXSide = "SELL_BASE_BUY_QUOTE" +) + +// FXQuote captures a priced FX quote. +type FXQuote struct { + QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"` + Pair *CurrencyPair `bson:"pair,omitempty" json:"pair,omitempty"` + Side FXSide `bson:"side,omitempty" json:"side,omitempty"` + Price *Decimal `bson:"price,omitempty" json:"price,omitempty"` + BaseAmount *Money `bson:"baseAmount,omitempty" json:"baseAmount,omitempty"` + QuoteAmount *Money `bson:"quoteAmount,omitempty" json:"quoteAmount,omitempty"` + ExpiresAtUnixMs int64 `bson:"expiresAtUnixMs,omitempty" json:"expiresAtUnixMs,omitempty"` + Provider string `bson:"provider,omitempty" json:"provider,omitempty"` + RateRef string `bson:"rateRef,omitempty" json:"rateRef,omitempty"` + Firm bool `bson:"firm,omitempty" json:"firm,omitempty"` +} + +func (q *FXQuote) GetPair() *CurrencyPair { + if q == nil { + return nil + } + return q.Pair +} + +func (q *FXQuote) GetSide() FXSide { + if q == nil { + return FXSideUnspecified + } + return q.Side +} + +func (q *FXQuote) GetPrice() *Decimal { + if q == nil { + return nil + } + return q.Price +} + +func (q *FXQuote) GetBaseAmount() *Money { + if q == nil { + return nil + } + return q.BaseAmount +} + +func (q *FXQuote) GetQuoteAmount() *Money { + if q == nil { + return nil + } + return q.QuoteAmount +} + +func (q *FXQuote) GetExpiresAtUnixMs() int64 { + if q == nil { + return 0 + } + return q.ExpiresAtUnixMs +} + +func (q *FXQuote) GetProvider() string { + if q == nil { + return "" + } + return q.Provider +} + +func (q *FXQuote) GetRateRef() string { + if q == nil { + return "" + } + return q.RateRef +} + +func (q *FXQuote) GetFirm() bool { + if q == nil { + return false + } + return q.Firm +} diff --git a/api/pkg/payments/types/money.go b/api/pkg/payments/types/money.go new file mode 100644 index 0000000..c1966f0 --- /dev/null +++ b/api/pkg/payments/types/money.go @@ -0,0 +1,33 @@ +package types + +// Decimal holds a decimal value as a string. +type Decimal struct { + Value string `bson:"value,omitempty" json:"value,omitempty"` +} + +func (d *Decimal) GetValue() string { + if d == nil { + return "" + } + return d.Value +} + +// Money represents a currency amount using decimal-safe strings. +type Money struct { + Amount string `bson:"amount,omitempty" json:"amount,omitempty"` + Currency string `bson:"currency,omitempty" json:"currency,omitempty"` +} + +func (m *Money) GetAmount() string { + if m == nil { + return "" + } + return m.Amount +} + +func (m *Money) GetCurrency() string { + if m == nil { + return "" + } + return m.Currency +} diff --git a/api/proto/common/gateway/v1/gateway.proto b/api/proto/common/gateway/v1/gateway.proto index 3fdaf55..303e7cc 100644 --- a/api/proto/common/gateway/v1/gateway.proto +++ b/api/proto/common/gateway/v1/gateway.proto @@ -26,6 +26,27 @@ enum PaymentMethodType { PM_LOCAL_BANK = 7; // generic local rails, refine later if needed } +// Rail identifiers for orchestration. Extend with new rails as needed. +enum Rail { + RAIL_UNSPECIFIED = 0; + RAIL_CRYPTO = 1; + RAIL_PROVIDER_SETTLEMENT = 2; + RAIL_LEDGER = 3; + RAIL_CARD_PAYOUT = 4; + RAIL_FIAT_ONRAMP = 5; +} + +// Operations supported in a payment plan. +enum RailOperation { + RAIL_OPERATION_UNSPECIFIED = 0; + RAIL_OPERATION_DEBIT = 1; + RAIL_OPERATION_CREDIT = 2; + RAIL_OPERATION_SEND = 3; + RAIL_OPERATION_FEE = 4; + RAIL_OPERATION_OBSERVE_CONFIRM = 5; + RAIL_OPERATION_FX_CONVERT = 6; +} + // Limits in minor units, e.g. cents message AmountLimits { int64 min_minor = 1; @@ -95,3 +116,44 @@ message GatewayDescriptor { GatewayCapabilities capabilities = 6; } + +// Capabilities declared per gateway instance for orchestration. +message RailCapabilities { + bool can_pay_in = 1; + bool can_pay_out = 2; + bool can_read_balance = 3; + bool can_send_fee = 4; + bool requires_observe_confirm = 5; +} + +message LimitsOverride { + string max_volume = 1; + string min_amount = 2; + string max_amount = 3; + string max_fee = 4; + int32 max_ops = 5; +} + +// Limits are decimal-safe string amounts unless otherwise noted. +message Limits { + string min_amount = 1; + string max_amount = 2; + string per_tx_max_fee = 3; + string per_tx_min_amount = 4; + string per_tx_max_amount = 5; + map volume_limit = 6; // bucket -> max volume + map velocity_limit = 7; // bucket -> max operations count + map currency_limits = 8; +} + +// A specific gateway instance for a given rail/network pairing. +message GatewayInstanceDescriptor { + string id = 1; // unique instance id + Rail rail = 2; + string network = 3; + repeated string currencies = 4; + RailCapabilities capabilities = 5; + Limits limits = 6; + string version = 7; + bool is_enabled = 8; +} diff --git a/api/proto/gateway/mntx/v1/mntx.proto b/api/proto/gateway/mntx/v1/mntx.proto index 0fd4609..3cbd688 100644 --- a/api/proto/gateway/mntx/v1/mntx.proto +++ b/api/proto/gateway/mntx/v1/mntx.proto @@ -5,7 +5,7 @@ package mntx.gateway.v1; option go_package = "github.com/tech/sendico/pkg/proto/gateway/mntx/v1;mntxv1"; import "google/protobuf/timestamp.proto"; -import "common/money/v1/money.proto"; +import "common/gateway/v1/gateway.proto"; // Status of a payout request handled by Monetix. enum PayoutStatus { @@ -15,75 +15,6 @@ enum PayoutStatus { PAYOUT_STATUS_FAILED = 3; } -// Basic destination data for the payout. -message BankAccount { - string iban = 1; - string bic = 2; - string account_holder = 3; - string country = 4; -} - -// Card destination for payouts (PAN-based or tokenized). -message CardDestination { - oneof card { - string pan = 1; // raw primary account number - string token = 2; // network or gateway-issued token - } - string cardholder_name = 3; - string exp_month = 4; - string exp_year = 5; - string country = 6; -} - -// Wrapper allowing multiple payout destination types. -message PayoutDestination { - oneof destination { - BankAccount bank_account = 1; - CardDestination card = 2; - } -} - -message Payout { - string payout_ref = 1; - string idempotency_key = 2; - string organization_ref = 3; - PayoutDestination destination = 4; - common.money.v1.Money amount = 5; - string description = 6; - map metadata = 7; - PayoutStatus status = 8; - string failure_reason = 9; - google.protobuf.Timestamp created_at = 10; - google.protobuf.Timestamp updated_at = 11; -} - -message SubmitPayoutRequest { - string idempotency_key = 1; - string organization_ref = 2; - PayoutDestination destination = 3; - common.money.v1.Money amount = 4; - string description = 5; - map metadata = 6; - string simulated_failure_reason = 7; // optional trigger to force a failed payout for testing -} - -message SubmitPayoutResponse { - Payout payout = 1; -} - -message GetPayoutRequest { - string payout_ref = 1; -} - -message GetPayoutResponse { - Payout payout = 1; -} - -// Event emitted over messaging when payout status changes. -message PayoutStatusChangedEvent { - Payout payout = 1; -} - // Request to initiate a Monetix card payout. message CardPayoutRequest { string payout_id = 1; // internal payout id, mapped to Monetix payment_id @@ -144,6 +75,12 @@ message CardPayoutStatusChangedEvent { CardPayoutState payout = 1; } +message ListGatewayInstancesRequest {} + +message ListGatewayInstancesResponse { + repeated common.gateway.v1.GatewayInstanceDescriptor items = 1; +} + // Request to initiate a token-based card payout. message CardTokenPayoutRequest { string payout_id = 1; @@ -229,10 +166,9 @@ message CardTokenizeResponse { } service MntxGatewayService { - rpc SubmitPayout(SubmitPayoutRequest) returns (SubmitPayoutResponse); - rpc GetPayout(GetPayoutRequest) returns (GetPayoutResponse); rpc CreateCardPayout(CardPayoutRequest) returns (CardPayoutResponse); rpc GetCardPayoutStatus(GetCardPayoutStatusRequest) returns (GetCardPayoutStatusResponse); rpc CreateCardTokenPayout(CardTokenPayoutRequest) returns (CardTokenPayoutResponse); rpc CreateCardToken(CardTokenizeRequest) returns (CardTokenizeResponse); + rpc ListGatewayInstances(ListGatewayInstancesRequest) returns (ListGatewayInstancesResponse); } diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index dd1b996..aa6ce76 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -7,6 +7,7 @@ option go_package = "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1; import "google/protobuf/timestamp.proto"; import "common/money/v1/money.proto"; import "common/fx/v1/fx.proto"; +import "common/gateway/v1/gateway.proto"; import "common/trace/v1/trace.proto"; import "common/pagination/v1/cursor.proto"; import "billing/fees/v1/fees.proto"; @@ -23,9 +24,9 @@ enum PaymentKind { // SettlementMode defines how to treat fees/FX variance for payouts. enum SettlementMode { - SETTLEMENT_MODE_UNSPECIFIED = 0; - SETTLEMENT_MODE_FIX_SOURCE = 1; // customer pays fees; sent amount fixed - SETTLEMENT_MODE_FIX_RECEIVED = 2; // receiver gets fixed amount; source flexes + SETTLEMENT_UNSPECIFIED = 0; + SETTLEMENT_FIX_SOURCE = 1; // customer pays fees; sent amount fixed + SETTLEMENT_FIX_RECEIVED = 2; // receiver gets fixed amount; source flexes } enum PaymentState { @@ -39,13 +40,13 @@ enum PaymentState { } enum PaymentFailureCode { - PAYMENT_FAILURE_CODE_UNSPECIFIED = 0; - PAYMENT_FAILURE_CODE_BALANCE = 1; - PAYMENT_FAILURE_CODE_LEDGER = 2; - PAYMENT_FAILURE_CODE_FX = 3; - PAYMENT_FAILURE_CODE_CHAIN = 4; - PAYMENT_FAILURE_CODE_FEES = 5; - PAYMENT_FAILURE_CODE_POLICY = 6; + FAILURE_UNSPECIFIED = 0; + FAILURE_BALANCE = 1; + FAILURE_LEDGER = 2; + FAILURE_FX = 3; + FAILURE_CHAIN = 4; + FAILURE_FEES = 5; + FAILURE_POLICY = 6; } message RequestMeta { @@ -171,6 +172,21 @@ message ExecutionPlan { common.money.v1.Money total_network_fee = 2; } +message PaymentStep { + common.gateway.v1.Rail rail = 1; + string gateway_id = 2; // required for external rails + common.gateway.v1.RailOperation action = 3; + common.money.v1.Money amount = 4; + string ref = 5; +} + +message PaymentPlan { + string id = 1; + repeated PaymentStep steps = 2; + string idempotency_key = 3; + google.protobuf.Timestamp created_at = 4; +} + // Card payout gateway tracking info. message CardPayout { string payout_ref = 1; @@ -197,6 +213,7 @@ message Payment { google.protobuf.Timestamp updated_at = 11; CardPayout card_payout = 12; ExecutionPlan execution_plan = 13; + PaymentPlan payment_plan = 14; } message QuotePaymentRequest { diff --git a/api/server/internal/server/paymentapiimp/discovery.go b/api/server/internal/server/paymentapiimp/discovery.go new file mode 100644 index 0000000..0fe0100 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/discovery.go @@ -0,0 +1,95 @@ +package paymentapiimp + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "time" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/discovery" + me "github.com/tech/sendico/pkg/messaging/envelope" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/server/interface/api/sresponse" + mutil "github.com/tech/sendico/server/internal/mutil/param" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +const discoveryLookupTimeout = 3 * time.Second + +func (a *PaymentAPI) listDiscoveryRegistry(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc { + if a.discovery == nil { + return response.Internal(a.logger, a.Name(), errors.New("discovery client is not configured")) + } + + orgRef, err := a.oph.GetRef(r) + if err != nil { + a.logger.Warn("Failed to parse organization reference for discovery registry", zap.Error(err), mutil.PLog(a.oph, r)) + return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + } + + ctx := r.Context() + allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + if err != nil { + a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r)) + return response.Auto(a.logger, a.Name(), err) + } + if !allowed { + a.logger.Debug("Access denied when listing discovery registry", mutil.PLog(a.oph, r)) + return response.AccessDenied(a.logger, a.Name(), "payments read permission denied") + } + + reqCtx, cancel := context.WithTimeout(ctx, discoveryLookupTimeout) + defer cancel() + + payload, err := a.discovery.Lookup(reqCtx) + if err != nil { + a.logger.Warn("Failed to fetch discovery registry", zap.Error(err)) + return response.Auto(a.logger, a.Name(), err) + } + + return response.Ok(a.logger, payload) +} + +func (a *PaymentAPI) getDiscoveryRefresh(r *http.Request, account *model.Account, _ *sresponse.TokenData) http.HandlerFunc { + if a.refreshConsumer == nil { + return response.Internal(a.logger, a.Name(), errors.New("discovery refresh consumer is not configured")) + } + + orgRef, err := a.oph.GetRef(r) + if err != nil { + a.logger.Warn("Failed to parse organization reference for discovery refresh", zap.Error(err), mutil.PLog(a.oph, r)) + return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + } + + ctx := r.Context() + allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + if err != nil { + a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r)) + return response.Auto(a.logger, a.Name(), err) + } + if !allowed { + a.logger.Debug("Access denied when listing discovery refresh", mutil.PLog(a.oph, r)) + return response.AccessDenied(a.logger, a.Name(), "payments read permission denied") + } + + a.refreshMu.RLock() + payload := a.refreshEvent + a.refreshMu.RUnlock() + + return response.Ok(a.logger, payload) +} + +func (a *PaymentAPI) handleRefreshEvent(_ context.Context, env me.Envelope) error { + var payload discovery.RefreshEvent + if err := json.Unmarshal(env.GetData(), &payload); err != nil { + a.logger.Warn("Failed to decode discovery refresh payload", zap.Error(err)) + return err + } + a.refreshMu.Lock() + a.refreshEvent = &payload + a.refreshMu.Unlock() + return nil +} diff --git a/api/server/internal/server/paymentapiimp/mapper.go b/api/server/internal/server/paymentapiimp/mapper.go index 0ea02fb..80089ad 100644 --- a/api/server/internal/server/paymentapiimp/mapper.go +++ b/api/server/internal/server/paymentapiimp/mapper.go @@ -274,13 +274,13 @@ func mapPaymentKind(kind srequest.PaymentKind) (orchestratorv1.PaymentKind, erro func mapSettlementMode(mode srequest.SettlementMode) (orchestratorv1.SettlementMode, error) { switch strings.TrimSpace(string(mode)) { case "", string(srequest.SettlementModeUnspecified): - return orchestratorv1.SettlementMode_SETTLEMENT_MODE_UNSPECIFIED, nil + return orchestratorv1.SettlementMode_SETTLEMENT_UNSPECIFIED, nil case string(srequest.SettlementModeFixSource): - return orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_SOURCE, nil + return orchestratorv1.SettlementMode_SETTLEMENT_FIX_SOURCE, nil case string(srequest.SettlementModeFixReceived): - return orchestratorv1.SettlementMode_SETTLEMENT_MODE_FIX_RECEIVED, nil + return orchestratorv1.SettlementMode_SETTLEMENT_FIX_RECEIVED, nil default: - return orchestratorv1.SettlementMode_SETTLEMENT_MODE_UNSPECIFIED, merrors.InvalidArgument("unsupported settlement mode: " + string(mode)) + return orchestratorv1.SettlementMode_SETTLEMENT_UNSPECIFIED, merrors.InvalidArgument("unsupported settlement mode: " + string(mode)) } } diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index d8b7eaa..fe87ef7 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -5,12 +5,16 @@ import ( "fmt" "os" "strings" + "sync" "time" orchestratorclient "github.com/tech/sendico/payments/orchestrator/client" api "github.com/tech/sendico/pkg/api/http" "github.com/tech/sendico/pkg/auth" + "github.com/tech/sendico/pkg/discovery" "github.com/tech/sendico/pkg/merrors" + msg "github.com/tech/sendico/pkg/messaging" + msgconsumer "github.com/tech/sendico/pkg/messaging/consumer" "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/mservice" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" @@ -30,10 +34,14 @@ type paymentClient interface { } type PaymentAPI struct { - logger mlogger.Logger - client paymentClient - enf auth.Enforcer - oph mutil.ParamHelper + logger mlogger.Logger + client paymentClient + enf auth.Enforcer + oph mutil.ParamHelper + discovery *discovery.Client + refreshConsumer msg.Consumer + refreshMu sync.RWMutex + refreshEvent *discovery.RefreshEvent permissionRef primitive.ObjectID } @@ -46,6 +54,12 @@ func (a *PaymentAPI) Finish(ctx context.Context) error { a.logger.Warn("Failed to close payment orchestrator client", zap.Error(err)) } } + if a.discovery != nil { + a.discovery.Close() + } + if a.refreshConsumer != nil { + a.refreshConsumer.Close() + } return nil } @@ -67,6 +81,9 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err)) return nil, err } + if err := p.initDiscoveryClient(apiCtx.Config()); err != nil { + p.logger.Warn("Failed to initialize discovery client", zap.Error(err)) + } apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/quote"), api.Post, p.quotePayment) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/multiquote"), api.Post, p.quotePayments) @@ -74,6 +91,8 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments) + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry"), api.Get, p.listDiscoveryRegistry) + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/registry/refresh"), api.Get, p.getDiscoveryRefresh) return p, nil } @@ -106,3 +125,33 @@ func (a *PaymentAPI) initPaymentClient(cfg *eapi.PaymentOrchestratorConfig) erro a.client = client return nil } + +func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error { + if cfg == nil || cfg.Mw == nil { + return nil + } + msgCfg := cfg.Mw.Messaging + if msgCfg.Driver == "" { + return nil + } + broker, err := msg.CreateMessagingBroker(a.logger.Named("discovery_bus"), &msgCfg) + if err != nil { + return err + } + client, err := discovery.NewClient(a.logger, broker, nil, string(a.Name())) + if err != nil { + return err + } + a.discovery = client + refreshConsumer, err := msgconsumer.NewConsumer(a.logger, broker, discovery.RefreshUIEvent()) + if err != nil { + return err + } + a.refreshConsumer = refreshConsumer + go func() { + if err := refreshConsumer.ConsumeMessages(a.handleRefreshEvent); err != nil { + a.logger.Warn("Discovery refresh consumer stopped", zap.Error(err)) + } + }() + return nil +}